Files
gstack/test/codex-ci-access.test.ts
T
ac20ef1e13 v1.91.16.0 fix: safe installs and upgrades, skills start everywhere, truthful completion (parallel wave) (#3014)
* fix(make-pdf,design): real TOC page numbers and anchors; design models move to gpt-5.5 + gpt-image-2 (#2903, #2807)

* ci: pin every action to a SHA, Scorecard, checksummed Bun, scoped Codex CLI in the CI image, macOS named-regressions job (#1948, #1706, #1997)

* fix(cso,browse): Windows/macOS lease identity, retention skips bad runs, truthful 'not assessed' reports, env.kv false positive, Windows browse spawn fixes (#2894, #2912, #2048, #2637, #2805)

* fix(outside-voice): one Codex model precedence for probe and dispatch, bounded timeouts with KILL escalation, probe cache identity, guarded mktemp, skills hidden in nested Codex (#2776, #2914, #2787, #2881, #2847)

* feat(setup): install registry, ./setup --status, staged activation, per-scope migrations, host tiers and conformance kit, OpenCode/Hermes fixes, Copilot CLI (experimental), disabled_skills, slimmer CLAUDE.md (#1925, #2879, #2347, #2338, #2626, #2629, #2825, #2015, #393, #1206, #2096)

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>

* fix(preamble): skills start in worktree-isolated Claude Code sessions — one literal skill-start line, context-recovery helper, no eval in skill blocks, $N token tripwire, honest plan-mode precedence (#2763, #2896, #2851)

* fix(setup): per-install renders for renamed, vendored and custom-config installs, space-safe alias roots, per-host /cso launcher path (#1882, #2906)

* test: free tests never write the real HOME (home-write tripwire), no orphaned browse daemons, symlinked-TMPDIR (macOS) fixes, gbrain-on-PATH isolation (#2895, #2816, #2829, #2841, #2982, #2911, #2909, #2908, #2910)

* chore: integrate wave follow-ups — regenerate all hosts, README model docs, TODOS, touchfiles, duration seeds, Bun 1.3 telemetry test fix, replay fixtures pinned to their recorded bytes

* v1.91.14.0: release metadata

* test(browse): group-kill cleanup falls back to the leader when macOS returns EPERM for a live group

* ci: Codex login steps run under bash (the CI container's default sh rejects pipefail)

* fix: full-suite repairs — private state roots for 7 real-HOME writers, truthful home-guard attribution with --attribute-home, gstack-paths shim in ship-hook fixtures, Copilot in team-init probes, test-of-test detector, cookie review fixture pinned to the approved excerpt

* fix(windows): LF for Dockerfiles, normalized home-guard exclusions, path.relative fixture containment, ship push test spawn timeouts

* test(design-detect): widen the whole-scan budget test's timing margins so a loaded macOS runner can't time out the single-batch call

* fix(evals): qa protocol names defined for the judge, async PTY judge so overlapping sessions keep their cleanup deadline, bws setup cases use the standard JUDGE_MS tier

* test(pty): the non-blocking judge case is POSIX-only (its fake judge is a shebang script, like the PTY harness it covers)

* test(evals): ship-coverage-audit names absolute skill paths, uses Read, and skips the Step 7 artifact nothing consumes (removes the mechanism behind 3 of 4 historical timeouts)

* test: deflake terminal-agent concurrent startup (write the placeholder record before spawning, like the product) and qa-evidence resumed pipe (barrier on a full high-water mark instead of a sleep)

* fix(design): daemon log stream handles open errors instead of crashing the daemon; shutdown test awaits the stubbed exit

* test(evals): skillify checks the product's resolved state root (no GSTACK_HOME bypass), negative cases check every root; tpa shims live outside the agent's cwd and answer consistently

* test(evals): coverage-audit detector splits parallel columns at │ rails and [+] groups; coverage prompts name absolute skill paths; TODOs from the gate eval census

* fix(plan-eng-review): scope gate always sends the menu (no headless 'Scope pending' exit); PTY harness ends idle turn-ends and API-error panels promptly with a precise diagnostic

* test(evals): one position-bound coverage-read rule credits read-only && chains (labels, context ranges, git displays in either order) and still rejects forged output

* fix(ship): documentation section has one saved source inserted by cat (never retyped); spawned-marker evidence is the child output; refreshed codex/factory ship goldens

* test(docsync): the byte-exact insert positive is POSIX-only (the bash-command observer refuses backslash paths by design)

* test(evals): plan-design-with-ui focus-title rule accepts the comma-less 'dimensions or focus' wording (replayed on the captured CI run, negative controls kept)

* fix(qa-callers): exploratory preflight says the scope/method Reads come after exploratory.md; design-review scan check credits an executed scan proven by one engine run over the changed files

---------

Co-authored-by: Andrey Esipov <61357035+andrey-esipov@users.noreply.github.com>
Co-authored-by: Matthew Williams <5863886+ridermw@users.noreply.github.com>
Co-authored-by: Rocky Wu <92764750+lolisaigao1234@users.noreply.github.com>
2026-10-03 07:54:09 -07:00

170 lines
10 KiB
TypeScript

/**
* The CI image's Codex CLI and credential reach only the Codex eval files.
*
* Every other paid case must see Codex exactly as it did before Codex entered
* the image: not on PATH and not logged in, so a skill's outside-voice probe
* (`command -v codex`) reports not_installed and gate verdicts and slice
* budgets do not move. This pins the image (Codex off PATH), the workflows
* (login under a non-default home, never exported as CODEX_HOME), the
* per-shard scoping helper, and the shard runner's use of it.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { CODEX_CI_CASES, CODEX_CI_ENV, CODEX_CI_FILES, buildRunManifest, codexShardAccess, runPaidShards, scopeCodexAccess, shardFile } from '../scripts/test-paid-shards';
import { buildHermeticEnv } from './helpers/hermetic-env';
import { E2E_TIERS, E2E_TOUCHFILES } from './helpers/touchfiles';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = '/opt/codex/bin';
const HOME = '/home/runner/.gstack-ci-codex';
const ciEnv = (tier: string): NodeJS.ProcessEnv => ({ PATH: '/usr/local/bin:/usr/bin:/bin', EVALS_TIER: tier, [CODEX_CI_ENV.binDir]: BIN, [CODEX_CI_ENV.home]: HOME });
const sees = (env: NodeJS.ProcessEnv) => ({
onPath: (env.PATH ?? '').split(path.delimiter).includes(BIN),
home: env.CODEX_HOME ?? null,
leaked: Object.keys(env).filter(name => name.startsWith('GSTACK_CI_CODEX')),
});
const planned = (tier: 'gate' | 'periodic') => buildRunManifest({ tier, profile: 'full', sliceCount: 1, evalsAll: true, env: { EVALS_ALL: '1' } })
.entries.filter(entry => entry.status === 'planned').map(entry => entry.file);
/** The paid test files a registered E2E case runs in. */
const caseFiles = (id: string) => (E2E_TOUCHFILES[id] ?? []).filter(dep => /^test\/.*\.test\.ts$/.test(dep));
describe('codex CI access scope', () => {
test('the Codex files and opted-in cases hold the approved periodic Codex cases and no gate case', () => {
for (const file of [...CODEX_CI_FILES, ...Object.keys(CODEX_CI_CASES)]) expect(fs.existsSync(path.join(ROOT, file)), file).toBe(true);
const reaches = (id: string) => caseFiles(id).some(file => CODEX_CI_FILES.includes(file) || CODEX_CI_CASES[file]?.includes(id));
for (const id of ['codex-review', 'codex-review-findings', 'codex-discover-skill', 'outside-voice-codex-to-claude-code',
'outside-voice-claude-code-to-codex', 'outside-plan-disabled-no-fallback', 'codex-sol-scope-termination']) {
expect(E2E_TIERS[id], id).toBe('periodic');
expect(reaches(id), `${id} cannot reach the CI Codex`).toBe(true);
}
for (const ids of Object.values(CODEX_CI_CASES)) for (const id of ids) expect(E2E_TIERS[id], id).toBe('periodic');
const gateInCodexFiles = Object.keys(E2E_TIERS)
.filter(id => E2E_TIERS[id] === 'gate' && caseFiles(id).some(file => CODEX_CI_FILES.includes(file)));
expect(gateInCodexFiles, 'a gate case would run with an authenticated Codex').toEqual([]);
});
test('only the opted-in case of a mixed file asks for Codex, and hermetic sessions drop the CI variables', () => {
for (const [file, ids] of Object.entries(CODEX_CI_CASES)) {
const source = fs.readFileSync(path.join(ROOT, file), 'utf8');
const calls = [...source.matchAll(/process\.env\.GSTACK_CI_CODEX_BIN_DIR\b/g)].map(match => match.index!);
expect(calls, file).toHaveLength(ids.length);
for (const [i, id] of ids.entries()) {
const start = source.indexOf(`IfSelected('${id}'`);
const next = source.slice(start + 1).search(/IfSelected\('/);
const end = next < 0 ? source.length : start + 1 + next;
expect(calls[i]! > start && calls[i]! < end, `${file}: CI Codex read outside the ${id} case`).toBe(true);
}
}
const child = buildHermeticEnv({ ...ciEnv('periodic'), HOME: '/home/runner' }, {});
expect(Object.keys(child).filter(name => name.startsWith('GSTACK_CI_CODEX'))).toEqual([]);
expect((child.PATH ?? '').split(path.delimiter)).not.toContain(BIN);
});
test('no planned gate shard can see the CI Codex CLI or its credential', () => {
const gate = planned('gate');
expect(gate.length).toBeGreaterThan(20);
for (const key of gate) {
const env = ciEnv('gate');
scopeCodexAccess(env, [shardFile(key)]);
expect(sees(env), key).toEqual({ onPath: false, home: null, leaked: [] });
expect(codexShardAccess([shardFile(key)], 'periodic') === 'path', `${key}: a gate shard is a Codex file`).toBe(false);
}
});
test('in the periodic census only the Codex files see Codex on PATH', () => {
const periodic = planned('periodic');
const access = (key: string) => {
const env = ciEnv('periodic');
scopeCodexAccess(env, [shardFile(key)]);
return { key, ...sees(env) };
};
const results = periodic.map(access);
const granted = results.filter(result => result.onPath);
expect([...new Set(granted.map(result => shardFile(result.key)))].sort()).toEqual([...CODEX_CI_FILES].sort());
for (const result of granted) expect(result.home, result.key).toBe(HOME);
for (const result of results.filter(result => !result.onPath)) {
expect(result.home, result.key).toBeNull();
expect(result.leaked.length === 0 || shardFile(result.key) in CODEX_CI_CASES, result.key).toBe(true);
}
});
test('a mixed shard is denied, case and trial keys follow their file, and local runs are untouched', () => {
expect(codexShardAccess(['test/codex-e2e.test.ts', 'test/skill-e2e-plan.test.ts'], 'periodic')).toBe('none');
expect(codexShardAccess([shardFile('test/codex-e2e.test.ts#codex-review~t2')], 'periodic')).toBe('path');
expect(codexShardAccess(['test/codex-e2e.test.ts'], 'gate')).toBe('none');
expect(codexShardAccess(['test/skill-e2e-workflow.test.ts'], 'gate')).toBe('none');
expect(codexShardAccess(['test/skill-e2e-workflow.test.ts'], 'periodic')).toBe('case-opt-in');
expect(codexShardAccess([], 'periodic')).toBe('none');
const ci = { ...ciEnv('periodic'), CODEX_HOME: '/somewhere/else' };
scopeCodexAccess(ci, [shardFile('test/skill-e2e-plan.test.ts#codex-offered-eng-review')]);
expect(sees(ci)).toEqual({ onPath: false, home: null, leaked: [] });
const optIn = { ...ciEnv('periodic'), CODEX_HOME: '/somewhere/else' };
scopeCodexAccess(optIn, ['test/skill-e2e-workflow.test.ts']);
expect(sees(optIn)).toEqual({ onPath: false, home: null, leaked: [CODEX_CI_ENV.binDir, CODEX_CI_ENV.home] });
const local: NodeJS.ProcessEnv = { PATH: '/usr/bin', CODEX_HOME: '/home/dev/.codex' };
scopeCodexAccess(local, ['test/skill-e2e-plan.test.ts']);
expect(local).toEqual({ PATH: '/usr/bin', CODEX_HOME: '/home/dev/.codex' });
});
test.skipIf(process.platform === 'win32')('the paid shard runner gives each child the scoped environment', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-ci-access-'));
try {
const bin = path.join(dir, 'bin'), home = path.join(dir, 'home');
fs.mkdirSync(bin); fs.mkdirSync(home);
fs.writeFileSync(path.join(bin, 'codex'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
const shards = [['test/codex-e2e.test.ts'], ['test/skill-e2e-workflow.test.ts'],
['test/skill-e2e-plan.test.ts#codex-offered-eng-review'], ['test/skill-e2e-outside-voice.test.ts', 'test/skill-e2e-review.test.ts']];
const probe = 'p=$(command -v codex || echo none); printf "%s|%s|%s" "$p" "${CODEX_HOME:-}" "$(env | grep -c "^GSTACK_CI_CODEX" || true)"';
const run = async (tier: string) => {
const out = fs.mkdtempSync(path.join(dir, `${tier}-`));
await runPaidShards(shards, {
jobs: 1, timeoutMs: 30_000, log: () => {}, logDir: dir,
env: { PATH: '/usr/bin:/bin', HOME: dir, EVALS_TIER: tier, GSTACK_CLAUDE_CLI_VERSION: 'test', [CODEX_CI_ENV.binDir]: bin, [CODEX_CI_ENV.home]: home },
commandFor: files => ({ command: '/bin/sh', args: ['-c', `${probe} > "${path.join(out, String(shards.indexOf(files)))}"`] }),
});
return shards.map((_, index) => fs.readFileSync(path.join(out, String(index)), 'utf8'));
};
expect(await run('periodic')).toEqual([`${path.join(bin, 'codex')}|${home}|0`, 'none||2', 'none||0', 'none||0']);
expect(await run('gate')).toEqual(['none||0', 'none||0', 'none||0', 'none||0']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
});
describe('codex CI access in the image and workflows', () => {
test('the image installs Codex off PATH at the directory it advertises', () => {
const dockerfile = fs.readFileSync(path.join(ROOT, '.github/docker/Dockerfile.ci'), 'utf8');
expect(dockerfile).toMatch(/^ENV GSTACK_CI_CODEX_BIN_DIR=\/opt\/codex\/bin$/m);
expect(dockerfile).toMatch(/npm i -g --prefix=\/opt\/codex @openai\/codex@\d+\.\d+\.\d+/);
expect(dockerfile).toContain('! command -v codex');
expect(dockerfile).not.toMatch(/^ENV PATH=.*\/opt\/codex/m);
});
test('the login step never makes the credential the default Codex home', () => {
const steps: string[] = [];
for (const name of ['evals.yml', 'evals-periodic.yml', 'evals-marathon.yml']) {
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(ROOT, '.github/workflows', name), 'utf8')) as any;
for (const job of Object.values<any>(workflow.jobs)) {
for (const step of job.steps ?? []) {
if (!/login --with-api-key/.test(String(step.run ?? ''))) continue;
steps.push(String(step.run));
// The paid jobs run inside the CI container whose default shell is `sh -e` (dash),
// which rejects `set -o pipefail`; the login step must opt into bash.
if (/pipefail/.test(String(step.run))) expect({ step: step.name, shell: step.shell }).toEqual({ step: step.name, shell: 'bash' });
}
}
}
expect(steps.length).toBeGreaterThanOrEqual(4);
for (const run of steps) {
expect(run).toContain('echo "GSTACK_CI_CODEX_HOME=$CODEX_HOME" >> "$GITHUB_ENV"');
expect(run).not.toMatch(/echo "CODEX_HOME=/);
expect(run).not.toContain('$HOME/.codex"');
expect(run).toContain('"${GSTACK_CI_CODEX_BIN_DIR:?}/codex"');
}
});
});