mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +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>
183 lines
8.7 KiB
TypeScript
183 lines
8.7 KiB
TypeScript
/**
|
|
* Provider CLIs baked into the CI image must be pinned to EXACT versions.
|
|
*
|
|
* The PTY harness (test/helpers/claude-pty-runner.ts) screen-scrapes the
|
|
* claude CLI's TUI — trust dialog, input-prompt ready marker, spinner glyphs.
|
|
* The image used to install `npm i -g @anthropic-ai/claude-code` UNPINNED and
|
|
* rebuild weekly "to pick up CLI updates", while bun sat carefully pinned at
|
|
* 1.3.13 two RUN lines above — the exact drift class the bun pin exists for.
|
|
* Receipts: TUI drift broke the harness three separate times (welcome-screen
|
|
* wedge vs CLI 2.1.233, skillify HOME discovery on 2.1.237, guard/freeze
|
|
* hooks on 2.1.162), each debugged as a "flake" before being traced to an
|
|
* unpinned weekly-latest CLI.
|
|
*
|
|
* This tripwire fails the free suite when any globally-installed npm package
|
|
* in Dockerfile.ci lacks an exact `@X.Y.Z` pin. Bumps are deliberate: edit
|
|
* the pin in a PR and run the PTY gate against the new TUI before merging.
|
|
*
|
|
* Bun comes from its release archive, verified by SHA-256 for every
|
|
* architecture the image builds (#1706): nothing in the image is piped from
|
|
* curl into a shell.
|
|
*/
|
|
import { describe, expect, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const DOCKERFILE = path.join(ROOT, '.github', 'docker', 'Dockerfile.ci');
|
|
|
|
/** Package specs from every `npm i -g` / `npm install -g` in the Dockerfile. */
|
|
export function globalNpmInstallSpecs(source: string): string[] {
|
|
const specs: string[] = [];
|
|
for (const match of source.matchAll(/npm\s+(?:i|install)\s+(?:-g|--global)\s+([^\n\\&|;]+)/g)) {
|
|
for (const spec of match[1].trim().split(/\s+/)) {
|
|
if (spec.startsWith('-')) continue; // flags like --no-fund
|
|
specs.push(spec);
|
|
}
|
|
}
|
|
return specs;
|
|
}
|
|
|
|
/** Exact pin = a trailing @<semver> with no range operator (no ^ ~ x *). */
|
|
export function isExactlyPinned(spec: string): boolean {
|
|
// Scoped (@scope/name@1.2.3) or bare (name@1.2.3); version must be exact.
|
|
const at = spec.lastIndexOf('@');
|
|
if (at <= 0) return false; // no version at all (or a bare scope)
|
|
const version = spec.slice(at + 1);
|
|
return /^\d+\.\d+\.\d+(?:-[\w.]+)?$/.test(version);
|
|
}
|
|
|
|
describe('ci image provider-CLI pins', () => {
|
|
const source = fs.readFileSync(DOCKERFILE, 'utf-8');
|
|
const specs = globalNpmInstallSpecs(source);
|
|
|
|
test('the image installs at least the claude CLI globally (scan must not rot)', () => {
|
|
expect(
|
|
specs.some((s) => s.startsWith('@anthropic-ai/claude-code@')),
|
|
`expected a pinned @anthropic-ai/claude-code install in ${path.relative(ROOT, DOCKERFILE)}; found: ${specs.join(', ') || '(none)'}`,
|
|
).toBe(true);
|
|
});
|
|
|
|
test('every global npm install carries an exact @X.Y.Z pin', () => {
|
|
const unpinned = specs.filter((s) => !isExactlyPinned(s));
|
|
expect(
|
|
unpinned,
|
|
`unpinned global npm installs in Dockerfile.ci: ${unpinned.join(', ')}\n`
|
|
+ 'Pin the exact version (name@X.Y.Z) and bump via a PR that runs the '
|
|
+ 'PTY gate against the new TUI — weekly-latest CLI drift broke the '
|
|
+ 'harness three times before this tripwire existed.',
|
|
).toHaveLength(0);
|
|
});
|
|
|
|
test('the image installs the codex CLI globally so the Codex evals execute in CI', () => {
|
|
expect(
|
|
specs.some((s) => s.startsWith('@openai/codex@')),
|
|
`expected a pinned @openai/codex install in ${path.relative(ROOT, DOCKERFILE)}; found: ${specs.join(', ')}`,
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
/** Dockerfile source with `\`-continued lines joined into logical lines. */
|
|
function logicalLines(source: string): string[] {
|
|
return source.replace(/\\\n/g, ' ').split('\n');
|
|
}
|
|
|
|
/** Logical lines that pipe a download straight into a shell. */
|
|
export function curlPipedToShell(source: string): string[] {
|
|
return logicalLines(source).filter((line) => !/^\s*#/.test(line) &&
|
|
/\b(?:curl|wget)\b/.test(line) && /\|\s*(?:sudo\s+)?(?:ba|z|da)?sh\b/.test(line));
|
|
}
|
|
|
|
/** `<arch>) bun_target=...; bun_sha256=<hex>` arms of the Bun install `case`. */
|
|
export function bunArchiveArms(source: string): Map<string, string> {
|
|
const arms = new Map<string, string>();
|
|
for (const m of source.matchAll(/^\s*([\w|]+)\)\s*bun_target=[\w-]+;\s*bun_sha256=([0-9a-f]+)\s*;;/gm)) {
|
|
for (const arch of m[1].split('|')) arms.set(arch, m[2]);
|
|
}
|
|
return arms;
|
|
}
|
|
|
|
/** Docker architectures each build of Dockerfile.ci produces (no `platforms:` = the amd64 runner's). */
|
|
export function builtArchitectures(workflowsDir: string): Set<string> {
|
|
const archs = new Set<string>();
|
|
for (const name of fs.readdirSync(workflowsDir)) {
|
|
if (!/\.ya?ml$/.test(name)) continue;
|
|
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(workflowsDir, name), 'utf-8')) as any;
|
|
for (const job of Object.values<any>(workflow?.jobs ?? {})) {
|
|
for (const step of job?.steps ?? []) {
|
|
if (!String(step?.uses ?? '').startsWith('docker/build-push-action@')) continue;
|
|
if (step.with?.file !== '.github/docker/Dockerfile.ci') continue;
|
|
const platforms = String(step.with?.platforms ?? 'linux/amd64').split(/[\s,]+/).filter(Boolean);
|
|
for (const platform of platforms) archs.add(platform.replace(/^linux\//, ''));
|
|
}
|
|
}
|
|
}
|
|
return archs;
|
|
}
|
|
|
|
describe('ci image Bun install (#1706)', () => {
|
|
const source = fs.readFileSync(DOCKERFILE, 'utf-8');
|
|
const bunRun = logicalLines(source).find((line) => line.includes('oven-sh/bun/releases/download/bun-v${BUN_VERSION}/')) ?? '';
|
|
|
|
test('nothing is piped from a download into a shell', () => {
|
|
expect(curlPipedToShell(source), 'download the release archive and verify its SHA-256 instead').toEqual([]);
|
|
});
|
|
|
|
test('the tripwire recognizes the old installer pipe and leaves non-shell pipes alone', () => {
|
|
expect(curlPipedToShell('RUN curl -fsSL https://bun.sh/install \\\n | bash -s "bun-v1.4.0"\n')).toHaveLength(1);
|
|
expect(curlPipedToShell('RUN curl -fsSL https://example.test/x.sh | sh\n')).toHaveLength(1);
|
|
expect(curlPipedToShell('RUN curl -fsSL https://example.test/key.gpg | gpg --dearmor -o /k.gpg\n')).toEqual([]);
|
|
});
|
|
|
|
test('every architecture the image builds has a SHA-256-verified Bun archive', () => {
|
|
const built = builtArchitectures(path.join(ROOT, '.github', 'workflows'));
|
|
expect([...built], 'scan found no Dockerfile.ci build').toContain('amd64');
|
|
const arms = bunArchiveArms(source);
|
|
for (const arch of built) {
|
|
expect(arms.get(arch), `no verified Bun archive for ${arch}`).toMatch(/^[0-9a-f]{64}$/);
|
|
}
|
|
});
|
|
|
|
test('the archive is checked before it is unpacked, and unknown architectures fail closed', () => {
|
|
expect(bunRun, 'Bun must come from the GitHub release archive').not.toBe('');
|
|
const download = bunRun.indexOf('releases/download/');
|
|
const verify = bunRun.indexOf('sha256sum -c');
|
|
const unpack = bunRun.indexOf('unzip');
|
|
expect(verify).toBeGreaterThan(download);
|
|
expect(unpack).toBeGreaterThan(verify);
|
|
expect(bunRun).toMatch(/\*\)(?:(?!;;).)*exit 1\s*;;/);
|
|
expect(bunRun).toContain('test "$(bun --version)" = "${BUN_VERSION}"');
|
|
});
|
|
});
|
|
|
|
describe('ci image codex authentication', () => {
|
|
const workflowsDir = path.join(ROOT, '.github', 'workflows');
|
|
const lanes: Array<{ lane: string; steps: any[] }> = [];
|
|
for (const name of fs.readdirSync(workflowsDir).sort()) {
|
|
if (!/\.ya?ml$/.test(name)) continue;
|
|
const workflow = Bun.YAML.parse(fs.readFileSync(path.join(workflowsDir, name), 'utf-8')) as any;
|
|
for (const [id, job] of Object.entries<any>(workflow?.jobs ?? {})) {
|
|
const steps: any[] = job?.steps ?? [];
|
|
if (job?.container && steps.some((step) => /test-paid-shards\.ts .*--slice/.test(String(step?.run ?? '')))) {
|
|
lanes.push({ lane: `${name}:${id}`, steps });
|
|
}
|
|
}
|
|
}
|
|
|
|
test('every paid lane in the image logs the codex CLI in from the secret before it runs', () => {
|
|
expect(lanes.length, 'scan found no paid lanes').toBeGreaterThanOrEqual(4);
|
|
for (const { lane, steps } of lanes) {
|
|
const login = steps.findIndex((step) => /codex"? login --with-api-key/.test(String(step?.run ?? '')));
|
|
const run = steps.findIndex((step) => /test-paid-shards\.ts .*--slice/.test(String(step?.run ?? '')));
|
|
expect(login, `${lane}: no codex login step`).toBeGreaterThanOrEqual(0);
|
|
expect(login, `${lane}: codex login must precede the paid run`).toBeLessThan(run);
|
|
const step = steps[login];
|
|
expect(step.env?.OPENAI_API_KEY, `${lane}: key must arrive through env`).toBe('${{ secrets.OPENAI_API_KEY }}');
|
|
expect(String(step.run), `${lane}: never inline a secret into run`).not.toContain('secrets.');
|
|
expect(String(step.run)).toMatch(/codex"? login status/);
|
|
expect(String(step.if ?? ''), `${lane}: same-repo runs only`)
|
|
.toBe("github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository");
|
|
}
|
|
});
|
|
});
|