v1.91.176.0 cso: ignore the compiled helpers' leading #! line in the release contract's preparationSha256 (both sides) (#3333)

* cso: ignore the compiled helpers' leading #! line in preparationSha256, on both sides of the release contract

S1 (#3312) rewrote only line 1 of lib/cso/verifier.ts and
lib/cso/preparation-container.ts. That shebang is an unused string in the
compiled helper binaries, but it moved preparationSha256, so qualify run
39535948607 refused the run-5 statements staged at 133cb36 with
STALE_RUNTIME_QUALIFICATION. Garry chose this narrow rule (option 2).

The release contract re-measures a recorded preparationSha256 that differs
from the checkout's at the staged commit (git history), accepts a record
that matches it under the current or the legacy rule, and compares and
records the current-rule value. Both release workflows fetch full history.

* test(cso): prove the release-contract fingerprint rule without git history

Free-suite checkouts are depth 1, so the tests no longer read 133cb36 or
6b2b5f3. They rebuild the staged commit in a scratch repository from this
tree with both helpers' S1 shebang reverted. Its legacy fingerprint equals
the real run-5 record (b069f7ef...), so it is 133cb36's image source byte
for byte, and the contract accepts that record under the current rule.

* v1.91.176.0 stamp
This commit is contained in:
Garry Tan authored and GitHub committed 2026-10-21 19:22:49 +00:00
1 parent 4065e340e9
commit 514fbfdc97
10 files changed
+164 -16

No files matched your search

@@ -39,6 +39,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: true
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.4.2
@@ -65,6 +65,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
fetch-depth: 0
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
with:
bun-version: 1.4.2
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## [1.91.176.0] - 2026-10-21
**A qualified /cso runtime set can be released again after the S1 shebang change.** The release contract fingerprints every source byte compiled into a runtime image (`preparationSha256`). S1 (#3312) changed only line 1 of the two compiled helpers, `lib/cso/verifier.ts` and `lib/cso/preparation-container.ts`, from `#!/usr/bin/env bun` to the isolated shebang. That line is embedded in the compiled binary as an unused string and never runs in the image, but it moved the fingerprint. Qualify run 39535948607 therefore refused the run-5 statements, staged at 133cb36, with `STALE_RUNTIME_QUALIFICATION: preparationSha256`. Garry chose this narrow rule change over a re-stage and a fresh paid qualification (2026-10-21, option 2).
### Changed
- **`scripts/cso-requalification.ts`**: `preparationSha256` hashes a compiled helper entrypoint's leading `#!` line as absent. Every other byte of the helpers, and every byte of every other image source including its shebang lines, still counts. 133cb36 and main both measure `c721b585…`.
- **`scripts/cso-runtime-promotion.ts`** (`releaseCandidate`): when the staging record's `preparationSha256` differs from the checkout's, the release contract re-measures it at the staged commit from git history. The record must match that commit under the current rule or the earlier one, which also hashed the shebangs; otherwise it fails with `REQUALIFICATION_TRIGGERS_MISMATCH`. The current-rule value is compared and recorded, so both sides use the same rule.
- **`cso-runtime-qualification.yml`, `cso-runtime-promote.yml`**: check out full history so the staged commit is readable.
### Gate integrity
- Gate edit, a narrowing with Garry's approval: the release contract's `preparationSha256` comparison ignores the leading `#!` line of the two compiled helpers on both sides. Nothing else gets a pass. `test/cso-runtime-promotion.test.ts` runs the contract on the real 133cb36 record (`b069f7ef…`) against a main tree. These still fail with `STALE_RUNTIME_QUALIFICATION`: a line-2 edit to either helper, an image Dockerfile edit, and a shebang edit to the image `entrypoint`. A record that matches neither rule at the staged commit fails with `REQUALIFICATION_TRIGGERS_MISMATCH`.
## [1.91.175.0] - 2026-10-21
**A free-suite flake now keeps the assertion that failed.** Four free-test flakes in the flaky ledger (plan-seed-submission, native-paste, qa-only preparation-late and the gstack-upgrade fresh-shell positive control) could not be diagnosed: each failed once on a Ubicloud or remote-pregate run, passed on the serial retry, and its log died with the machine that pulled it. The runner's console printed only the failing test's name, and the flake ledger recorded only the file.
+1 -1
View File
@@ -1 +1 @@
1.91.175.0
1.91.176.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.175.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.176.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+10 -3
View File
@@ -280,12 +280,19 @@ policy (`ISOLATION_POLICY_HASH`, which covers the `cso-isolation-v1` policy
version and every limit), `preparationSha256` (a digest of every source byte
compiled or copied into a runtime image: the import closure of the image's
verifier and preparation helpers plus the image build context), and the
`build-inputs.json` revision. The staging run's `reviewed-inputs` job records
`build-inputs.json` revision. Each helper entrypoint's leading `#!` line is
hashed as absent: it is compiled into the binary as an unused string and never
runs inside the image. Every other byte of the helpers counts, and so does
every byte of every other file, shebang lines included. The staging run's `reviewed-inputs` job records
these values for its source commit as the `cso-requalification-triggers`
artifact. The ingress downloads that artifact from the verified staging run
into the evidence, and the promotion generator writes it into
`promotion.requalification`. Generation refuses triggers that differ from the
promoting checkout, so a trigger that changed between staging and promotion
`promotion.requalification`. When a recorded `preparationSha256` differs from
the promoting checkout's, generation re-measures it at the staged commit from
the checkout's git history. The record must match that commit under either the
current rule or the earlier rule that also hashed the helpers' `#!` lines, and
the current-rule value is what gets compared and recorded. Generation refuses
triggers that differ from the promoting checkout, so a trigger that changed between staging and promotion
requires a new staging run. The free suite's
[`cso-requalification.test.ts`](../test/cso-requalification.test.ts) compares
the committed catalog's recorded triggers with the current source and fails
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.175",
"version": "1.91.176",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+34 -1
View File
@@ -5,7 +5,9 @@
* differ from the current source is older than its inputs and must be
* requalified (or its runtimes withdrawn) before it ships.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { canonical, sha256 } from '../lib/cso/contracts';
import { ISOLATION_POLICY_HASH } from '../lib/cso/docker';
@@ -31,6 +33,37 @@ export async function imageSourceFiles(root = ROOT): Promise<string[]> {
return [...new Set([...closure, ...context])].sort();
}
/**
* Digest of every image source byte. A compiled helper's leading `#!` line never
* runs inside the image, so it is hashed as absent; every other byte counts.
* `legacy` hashes the shebang too, the rule staging runs recorded before this normalization (through run 39325583697).
*/
function preparationSha256(root: string, files: string[], legacy = false): string {
const helpers = new Set<string>(IMAGE_HELPER_ENTRYPOINTS);
return sha256(canonical(files.map(file => {
const bytes = fs.readFileSync(path.join(root, file));
if (legacy || !helpers.has(file) || bytes[0] !== 0x23 || bytes[1] !== 0x21) return [file, sha256(bytes)];
const newline = bytes.indexOf(0x0a);
return [file, sha256(newline === -1 ? Buffer.alloc(0) : bytes.subarray(newline + 1))];
})));
}
/** preparationSha256 of a committed tree under the current rule and the legacy rule. */
export async function committedPreparation(commit: string, root = ROOT): Promise<{ current: string; legacy: string }> {
if (!/^[a-f0-9]{40}$/.test(commit)) throw new Error('INVALID_STAGED_COMMIT');
const archive = spawnSync('git', ['-C', root, 'archive', '--format=tar', commit], { timeout: 60_000, maxBuffer: 512 * 1024 * 1024 });
if (archive.status !== 0) throw new Error(`STAGED_COMMIT_UNAVAILABLE: ${commit} is not in this checkout; fetch full history`);
const tree = fs.mkdtempSync(path.join(os.tmpdir(), 'cso-staged-source-'));
try {
const extract = spawnSync('tar', ['-x', '-C', tree], { input: archive.stdout, timeout: 60_000 });
if (extract.status !== 0) throw new Error('STAGED_COMMIT_UNAVAILABLE: archive extraction failed');
const files = await imageSourceFiles(tree);
return { current: preparationSha256(tree, files), legacy: preparationSha256(tree, files, true) };
} finally {
fs.rmSync(tree, { recursive: true, force: true });
}
}
export async function requalificationTriggers(root = ROOT): Promise<RequalificationTriggers> {
const files = await imageSourceFiles(root);
const buildInputs = JSON.parse(fs.readFileSync(path.join(root, IMAGE_CONTEXT, 'build-inputs.json'), 'utf8'));
@@ -38,7 +71,7 @@ export async function requalificationTriggers(root = ROOT): Promise<Requalificat
return {
helperAbi: CSO_HELPER_ABI,
isolationPolicyHash: ISOLATION_POLICY_HASH,
preparationSha256: sha256(canonical(files.map(file => [file, sha256(fs.readFileSync(path.join(root, file)))]))),
preparationSha256: preparationSha256(root, files),
buildInputsRevision: buildInputs.revision,
};
}
+26 -4
View File
@@ -18,7 +18,7 @@ import {
} from '../lib/cso/runtime-catalog';
import { ISOLATION_POLICY_HASH } from '../lib/cso/docker';
import { imageBuildMatrix, type ImageBuildRow } from './cso-image-matrix';
import { requalificationTriggers, staleRequalificationTriggers } from './cso-requalification';
import { committedPreparation, requalificationTriggers, staleRequalificationTriggers } from './cso-requalification';
const DIGEST = /^sha256:[a-f0-9]{64}$/;
const WORKFLOW = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/actions\/runs\/[0-9]+$/;
@@ -273,6 +273,30 @@ export function catalogPromotionCandidate(
return candidate;
}
/**
* The release contract: the promotion candidate, refused when any trigger moved since the staged commit.
* A recorded preparationSha256 that differs from the current one is re-measured at the staged commit in
* `source`, so a staging record made under the legacy rule compares under the current rule.
*/
export async function releaseCandidate(
evidenceValues: unknown[],
recorded: RequalificationTriggers,
current: RequalificationTriggers,
source?: string,
): Promise<RuntimeCatalog> {
let candidate = catalogPromotionCandidate(committedCatalog, buildInputs, evidenceValues, recorded);
if (recorded.preparationSha256 !== current.preparationSha256) {
const staged = await committedPreparation(candidate.promotion!.sourceCommit, source);
if (recorded.preparationSha256 !== staged.current && recorded.preparationSha256 !== staged.legacy) {
throw new Error('REQUALIFICATION_TRIGGERS_MISMATCH: preparationSha256 was not measured at the staged commit');
}
candidate = catalogPromotionCandidate(committedCatalog, buildInputs, evidenceValues, { ...recorded, preparationSha256: staged.current });
}
const stale = staleRequalificationTriggers(candidate, current);
if (stale.length) throw new Error(`STALE_RUNTIME_QUALIFICATION: ${stale.join(', ')} changed since the staged commit; re-stage and requalify`);
return candidate;
}
/** A promotion may only replace the exact catalog revision it was generated from. */
export function validateRuntimeCatalogTransition(currentValue: unknown, proposedValue: unknown): void {
validateRuntimeCatalog(currentValue);
@@ -356,9 +380,7 @@ if (import.meta.main) {
}
const triggers = collectEvidence(args[1], 'requalification.json');
if (triggers.length !== 1) throw new Error('MISSING_REQUALIFICATION_TRIGGERS');
const candidate = catalogPromotionCandidate(committedCatalog, buildInputs, collectEvidence(args[1]), triggers[0] as RequalificationTriggers);
const stale = staleRequalificationTriggers(candidate, await requalificationTriggers());
if (stale.length) throw new Error(`STALE_RUNTIME_QUALIFICATION: ${stale.join(', ')} changed since the staged commit; re-stage and requalify`);
const candidate = await releaseCandidate(collectEvidence(args[1]), triggers[0] as RequalificationTriggers, await requalificationTriggers());
const output = path.resolve(args[3]);
fs.writeFileSync(output, JSON.stringify(candidate, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
process.stdout.write(`${output}\n`);
+77 -5
View File
@@ -1,16 +1,17 @@
import { describe, expect, test } from 'bun:test';
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import committedCatalog from '../lib/cso/runtime-catalog.json';
import buildInputs from '../lib/cso/images/build-inputs.json';
import { isEvaluationRuntimeCatalog, validateRuntimeCatalog } from '../lib/cso/runtime-catalog';
import { imageBuildMatrix, type ImageBuildRow } from '../scripts/cso-image-matrix';
import { catalogPromotionCandidate, evaluationCandidateCatalog, validateEvaluationCandidate, validateRuntimeCatalogTransition, type RuntimeQualificationStatement } from '../scripts/cso-runtime-promotion';
import { catalogPromotionCandidate, evaluationCandidateCatalog, releaseCandidate, validateEvaluationCandidate, validateRuntimeCatalogTransition, type RuntimeQualificationStatement } from '../scripts/cso-runtime-promotion';
import { assertVersionOutput, probesForBuildRow } from '../scripts/cso-verify-runtime-base';
import { requalificationTriggers } from '../scripts/cso-requalification';
import { committedPreparation, requalificationTriggers } from '../scripts/cso-requalification';
import { gitArgvIn } from './helpers/scratch-repo';
const TRIGGERS = await requalificationTriggers();
const digest = (value: string) => `sha256:${createHash('sha256').update(value).digest('hex')}`;
@@ -220,3 +221,74 @@ describe('CSO evaluation-only runtime catalogs', () => {
expect(isEvaluationRuntimeCatalog(committedCatalog as any)).toBe(false);
});
});
/** preparationSha256 recorded by staging run 39325583697 at 133cb36 (cso-requalification-triggers), before S1. */
const RUN5_RECORD = 'b069f7ef9b19dba9a770cac02a5b6b4b5d60fa68ac058321864b614de14d59f6';
const HELPERS = ['lib/cso/verifier.ts', 'lib/cso/preparation-container.ts'];
const CHECKOUT = join(import.meta.dir, '..');
describe.skipIf(process.platform === 'win32')('CSO release contract preparation fingerprint', () => {
const temps: string[] = [];
afterEach(() => { for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); });
const rewrite = (root: string, file: string, change: (lines: string[]) => void) => {
const lines = readFileSync(join(root, file), 'utf8').split('\n'); change(lines); writeFileSync(join(root, file), lines.join('\n'));
};
/** A copy of the checkout's tracked lib/ tree, optionally edited. */
const trackedLib = (edit?: (root: string) => void): string => {
const root = mkdtempSync(join(tmpdir(), 'cso-release-contract-')); temps.push(root);
const listed = spawnSync('git', ['ls-files', '-z', 'lib'], { cwd: CHECKOUT, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
expect(listed.status).toBe(0);
for (const file of listed.stdout.split('\0').filter(Boolean)) {
mkdirSync(dirname(join(root, file)), { recursive: true });
copyFileSync(join(CHECKOUT, file), join(root, file));
}
edit?.(root);
return root;
};
const current = (edit?: (root: string) => void) => requalificationTriggers(trackedLib(edit));
/** The staged commit, rebuilt in a scratch repository: this tree with both helpers' S1 shebang reverted. */
const stagedCommit = () => {
const repo = trackedLib(root => { for (const helper of HELPERS) rewrite(root, helper, lines => { lines[0] = '#!/usr/bin/env bun'; }); });
for (const args of [['init', '-q'], ['add', '-A'], ['commit', '-q', '-m', 'staged']]) expect(gitArgvIn(repo, args, 30_000).status).toBe(0);
const commit = gitArgvIn(repo, ['rev-parse', 'HEAD'], 30_000).stdout.toString().trim();
return { repo, commit, evidence: statements().map(statement => ({ ...statement, sourceCommit: commit })) };
};
test('the run-5 record is this tree with S1\'s shebangs reverted, and the release contract accepts it under the current rule', async () => {
const staged = stagedCommit();
const measured = await committedPreparation(staged.commit, staged.repo);
const main = await current();
const record = { ...main, preparationSha256: RUN5_RECORD };
if (measured.legacy !== RUN5_RECORD) {
// An image source moved since run 5 beyond the shebangs, so its record can no longer be released.
await expect(releaseCandidate(staged.evidence, record, main, staged.repo)).rejects.toThrow(/STALE_RUNTIME_QUALIFICATION|REQUALIFICATION_TRIGGERS_MISMATCH/);
return;
}
// The same SHA-256 over every image source byte: the rebuilt commit is 133cb36's image source, byte for byte.
expect(main.preparationSha256).not.toBe(RUN5_RECORD);
expect(measured.current).toBe(main.preparationSha256);
const released = await releaseCandidate(staged.evidence, record, main, staged.repo);
expect(released.promotion!.requalification).toEqual(main);
}, 120_000);
test('the release contract ignores only a compiled helper\'s leading #! line, on both sides', async () => {
const staged = stagedCommit();
const legacy = (await committedPreparation(staged.commit, staged.repo)).legacy;
const recordFor = (triggers: Awaited<ReturnType<typeof requalificationTriggers>>, preparationSha256 = legacy) => ({ ...triggers, preparationSha256 });
const release = (triggers: Awaited<ReturnType<typeof requalificationTriggers>>, preparationSha256?: string) =>
releaseCandidate(staged.evidence, recordFor(triggers, preparationSha256), triggers, staged.repo);
const main = await current();
expect((await release(main)).promotion!.requalification.preparationSha256).toBe(main.preparationSha256);
for (const helper of HELPERS) {
expect((await current(root => rewrite(root, helper, lines => { lines[0] = '#!/usr/bin/env node'; }))).preparationSha256).toBe(main.preparationSha256);
const drifted = await current(root => rewrite(root, helper, lines => { lines.splice(1, 0, '// drift'); }));
await expect(release(drifted)).rejects.toThrow('STALE_RUNTIME_QUALIFICATION: preparationSha256');
}
await expect(release(await current(root => writeFileSync(join(root, 'lib/cso/images/rails.Dockerfile'), '\n', { flag: 'a' })))).rejects.toThrow('STALE_RUNTIME_QUALIFICATION: preparationSha256');
await expect(release(await current(root => rewrite(root, 'lib/cso/images/entrypoint', lines => { lines[0] = '#!/bin/bash'; })))).rejects.toThrow('STALE_RUNTIME_QUALIFICATION: preparationSha256');
await expect(release(main, 'd'.repeat(64))).rejects.toThrow('REQUALIFICATION_TRIGGERS_MISMATCH');
const recordedUnderCurrentRule = await releaseCandidate(staged.evidence, main, main, join(tmpdir(), 'no-such-checkout'));
expect(recordedUnderCurrentRule.promotion!.requalification).toEqual(main);
}, 180_000);
});