mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
* 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>
170 lines
10 KiB
TypeScript
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"');
|
|
}
|
|
});
|
|
});
|