From 31b63d11276e62f00ef8be89fab1f68ec09d293e Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 29 Sep 2026 15:46:38 -0700 Subject: [PATCH 1/2] fix(gh-aw): submit bootstrap chunks without agent transcription Closes #2112 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/guide/gh-aw.md | 7 + test/gh-aw-bootstrap-workflow.test.ts | 136 +++++++++++++++++- workflows/package/squad-bootstrap.md | 54 ++++--- .../shared/squad-bootstrap-validator.mjs | 48 ++++++- workflows/squad-bootstrap.md | 54 ++++--- workflows/squad-workflows.manifest.json | 8 +- 6 files changed, 236 insertions(+), 71 deletions(-) diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 066e6165f..8eff81f8c 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -150,6 +150,13 @@ validated payload: - a draft Cast PR on `squad/bootstrap-cast`; and - `[Research Proposals] Agent-discovered repo opportunities`. +Bootstrap submits the shared payload through one authenticated command. Runtime +code assembles and validates its bounded chunks, then passes JSON directly to +the existing typed safe-output tool; the agent never copies chunks into tool +arguments. Missing, duplicate, reordered, malformed, oversized, or mismatched +payloads fail closed before submission, and the write job independently +revalidates the payload. A submission error is terminal, not permission to retry. + Review and merge the Cast PR, then rerun `/squad triage` on the linked issue to classify its existing bootstrap proposals. If a proposal needs deeper or newer evidence, use one of the issue's focused `/squad research ...` commands first; diff --git a/test/gh-aw-bootstrap-workflow.test.ts b/test/gh-aw-bootstrap-workflow.test.ts index 4ef39ee80..c1fa5b053 100644 --- a/test/gh-aw-bootstrap-workflow.test.ts +++ b/test/gh-aw-bootstrap-workflow.test.ts @@ -1,6 +1,8 @@ import { afterAll, describe, expect, it } from 'vitest'; import { cpSync, + chmodSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -12,6 +14,7 @@ import { createHash } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; +import { parse } from 'yaml'; import { BOOTSTRAP_BRANCH, BOOTSTRAP_ISSUE_MARKER, @@ -375,6 +378,48 @@ function validateFixture( ); } +function submissionFixture() { + const fixture = createFixture(); + const targetBytes = 40_843; + const remaining = targetBytes - Buffer.byteLength(JSON.stringify(fixture.payload)); + const rationale = ' Repository evidence informs this proposed specialist; human review is required before activation.'; + fixture.payload.pr_body += rationale.repeat(Math.ceil(remaining / rationale.length)).slice(0, remaining); + const text = JSON.stringify(fixture.payload); + expect(Buffer.byteLength(text)).toBe(targetBytes); + writeFileSync(fixture.payloadPath, text); + const envelope = createBootstrapPayloadEnvelope(text) as Record; + expect(envelope.payload_chunk_count).toBe('7'); + const envelopePath = join(fixture.root, 'envelope.json'); + writeFileSync(envelopePath, JSON.stringify(envelope)); + const capture = join(fixture.root, 'calls.jsonl'); + const proxy = join(fixture.root, 'bin/safeoutputs'); + write(fixture.root, 'bin/safeoutputs', `#!${process.execPath} +const fs = require('node:fs'); +const args = process.argv.slice(2); +if (JSON.stringify(args) !== JSON.stringify(['materialize_bootstrap', '.'])) process.exit(2); +const input = JSON.parse(fs.readFileSync(0, 'utf8')); +if (Object.values(input).some(value => typeof value !== 'string' || Buffer.byteLength(value) > 10240)) process.exit(3); +fs.appendFileSync(process.env.CAPTURE, JSON.stringify(input) + '\\n'); +if (process.env.PROXY_FAIL === '1') { console.error('proxy rejected request'); process.exit(4); } +console.log('tool accepted'); +`); + chmodSync(proxy, 0o700); + const env = { + ...process.env, + PATH: `${join(fixture.root, 'bin')}:${process.env.PATH}`, + CAPTURE: capture, + GITHUB_WORKSPACE: fixture.root, + GITHUB_REPOSITORY: 'octo/example', + DEFAULT_BRANCH: 'main', + }; + const submit = (overrides = {}) => spawnSync(process.execPath, [ + VALIDATOR, '--submit-envelope', envelopePath, + '--root', fixture.root, '--payload', fixture.payloadPath, + '--repository', 'octo/example', '--default-branch', 'main', '--link-mode', 'placeholder', + ], { env: { ...env, ...overrides }, encoding: 'utf8' }); + return { ...fixture, text, envelope, envelopePath, capture, proxy, env, submit }; +} + function compileWorkflow(source = WORKFLOW): string { const root = mkdtempSync(join(tmpdir(), 'gh-aw-bootstrap-compile-')); workspaces.push(root); @@ -601,12 +646,8 @@ describe('automatic Squad bootstrap workflow', () => { }); it('fails closed for every chunk transport corruption class', () => { - const payloadText = JSON.stringify({ - shared: 'Cast and research stay in one validated payload.', - content: 'x'.repeat(PAYLOAD_CHUNK_BYTES * 2), - }); - const valid = createBootstrapPayloadEnvelope(payloadText) as Record; - expect(Number(valid.payload_chunk_count)).toBeGreaterThanOrEqual(3); + const fixture = submissionFixture(); + const valid = fixture.envelope; const mutations: Array<[string, (envelope: Record) => void, RegExp]> = [ ['missing', (envelope) => delete envelope.payload_chunk_00, /payload_chunk_00 is missing/], @@ -653,6 +694,8 @@ describe('automatic Squad bootstrap workflow', () => { /decoded length does not match|length mismatch/, ], ['hash mismatch', (envelope) => { envelope.payload_sha256 = '0'.repeat(64); }, /SHA-256 mismatch/], + ['noncanonical count', (envelope) => { envelope.payload_chunk_count = '07'; }, /canonical decimal/], + ['invalid slot', (envelope) => { envelope.payload_chunk_16 = '16:QQ=='; }, /not a valid fixed chunk slot/], [ 'trailing data', (envelope) => { @@ -680,7 +723,74 @@ describe('automatic Squad bootstrap workflow', () => { () => reconstructBootstrapPayload(envelope), `${name} corruption must fail closed`, ).toThrow(error); + writeFileSync(fixture.envelopePath, JSON.stringify(envelope)); + const result = fixture.submit(); + expect(result.status, name).not.toBe(0); + expect(result.stderr, name).toMatch(error); + expect(existsSync(fixture.capture), `${name} must not invoke safeoutputs`).toBe(false); + } + }); + + it('submits seven chunks through the authenticated runner without printing or transcribing them', () => { + const fixture = submissionFixture(); + cpSync(resolve(ROOT, 'workflows/shared'), join(fixture.root, '.github/workflows/shared'), { recursive: true }); + write(fixture.root, '.github/workflows/squad-bootstrap-payload.json', fixture.text); + const config = parse(WORKFLOW.split('---')[1]); + const prepare = config['pre-agent-steps'].find( + (step: { name: string }) => step.name === 'Prepare authenticated bootstrap validator', + ); + const setup = spawnSync('bash', ['-c', prepare.run], { env: fixture.env, encoding: 'utf8' }); + expect(setup.status, setup.stderr).toBe(0); + const result = spawnSync( + join(fixture.root, '.github/workflows/run-squad-bootstrap-validator'), + [], + { env: fixture.env, encoding: 'utf8' }, + ); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe('Squad bootstrap validation passed; materialize_bootstrap submitted.\n'); + const calls = readFileSync(fixture.capture, 'utf8').trim().split('\n'); + expect(calls).toHaveLength(1); + expect(JSON.parse(calls[0])).toEqual(fixture.envelope); + expect(reconstructBootstrapPayload(JSON.parse(calls[0]))).toBe(fixture.text); + expect(result.stdout.length).toBeLessThan(100); + expect(result.stderr).toBe(''); + }); + + it('rejects schema, repository, Cast, and stale payload mutations before submitting', () => { + for (const mutate of [ + (payload: ReturnType['payload']) => { payload.schema_version = '2'; }, + (payload: ReturnType['payload']) => { payload.repository = 'other/repo'; }, + (payload: ReturnType['payload']) => { payload.default_branch = 'other'; }, + (payload: ReturnType['payload']) => { payload.files[0].content += '\nchanged'; }, + ]) { + const fixture = submissionFixture(); + mutate(fixture.payload); + const text = JSON.stringify(fixture.payload); + writeFileSync(fixture.payloadPath, text); + writeFileSync(fixture.envelopePath, JSON.stringify(createBootstrapPayloadEnvelope(text))); + const result = fixture.submit(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Squad bootstrap validation failed'); + expect(existsSync(fixture.capture)).toBe(false); } + const fixture = submissionFixture(); + writeFileSync(fixture.payloadPath, `${fixture.text}\n`); + expect(fixture.submit().stderr).toContain('does not match the generated payload bytes'); + expect(existsSync(fixture.capture)).toBe(false); + }); + + it('fails explicitly without retries when the mounted tool fails or is unavailable', () => { + const fixture = submissionFixture(); + const failed = fixture.submit({ PROXY_FAIL: '1' }); + expect(failed.status).not.toBe(0); + expect(failed.stderr).toContain('proxy rejected request'); + expect(failed.stderr).toContain('Do not retry'); + expect(readFileSync(fixture.capture, 'utf8').trim().split('\n')).toHaveLength(1); + rmSync(fixture.proxy); + const unavailable = fixture.submit({ PATH: join(fixture.root, 'bin') }); + expect(unavailable.status).not.toBe(0); + expect(unavailable.stderr).toContain('ENOENT'); + expect(unavailable.stdout).toBe(''); }); it('enforces encoder total-size and fixed chunk-count bounds', () => { @@ -738,6 +848,10 @@ describe('automatic Squad bootstrap workflow', () => { expect(lock).toContain('"payload_sha256"'); expect(lock).not.toMatch(/"materialize-bootstrap":\{"inputs":\{"payload":/); expect(lock).toContain('reconstructBootstrapPayload(items[0])'); + expect(lock).toContain('mcp-cli'); + expect(lock).toContain('--submit-envelope'); + expect(parse(lock).jobs.agent.env.DEFAULT_BRANCH).toBe('${{ github.event.repository.default_branch }}'); + expect(parse(WORKFLOW.split('---')[1]).tools['cli-proxy']).toBe(true); expect(lock).toContain('SQUAD_BOOTSTRAP_INSTALL_SHA: ${{ github.sha }}'); expect(lock).toContain('SQUAD_BOOTSTRAP_REPOSITORY: ${{ github.repository }}'); expect(lock).toContain('SQUAD_BOOTSTRAP_RUN_ID: ${{ github.run_id }}'); @@ -762,6 +876,16 @@ describe('automatic Squad bootstrap workflow', () => { ); }, 180000); + it('detects a compiled mutation that removes the file-backed safe-output CLI', () => { + const assertMounted = (lock: string) => { + const agent = parse(lock).jobs.agent; + expect(agent.steps.some((step: { id?: string }) => step.id === 'mount-mcp-clis')).toBe(true); + expect(lock).toContain('GH_AW_MCP_CLI_SERVERS=\'["safeoutputs"]\''); + }; + assertMounted(compileWorkflow()); + expect(() => assertMounted(compileWorkflow(WORKFLOW.replace(' cli-proxy: true\n', '')))).toThrow(); + }, 180000); + it('declares deterministic output bounds and paginated recovery checks', () => { expect(WORKFLOW).toContain('max: 1'); expect(WORKFLOW).toContain("state: 'all'"); diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index 3b14cabb4..3fdf6b27a 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -30,6 +30,7 @@ network: allowed: - defaults tools: + cli-proxy: true edit: null bash: true github: @@ -153,17 +154,18 @@ pre-agent-steps: # BEGIN GENERATED RESOURCE DIGESTS check_hash "$install_verifier" "514e210ebeae3b355ff73ed81bfd17f37207c9a3576ca44eaf8af7bfa21fc173" check_hash "$cast_validator" "0988e04aeef316f4d7a0107c902bbbcf6538b8899b9fffba5f62150717967685" - check_hash "$bootstrap_validator" "d449b9204f7fad133ff7133c1a30c9381c87e3c0c9d481352819ca93ea1a1dad" + check_hash "$bootstrap_validator" "4fc1bcc79b887bb4f2f78e7eef8693d5c1eaf817d8715ce3c92169934aa225b7" # END GENERATED RESOURCE DIGESTS node "$bootstrap_validator" \ + --encode-payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-payload.json" \ + > "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-envelope.json" + node "$bootstrap_validator" \ + --submit-envelope "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-envelope.json" \ --root "$PWD" \ --payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-payload.json" \ --repository "${GITHUB_REPOSITORY:?}" \ - --default-branch "${SQUAD_BOOTSTRAP_DEFAULT_BRANCH:?}" \ + --default-branch "${DEFAULT_BRANCH:?}" \ --link-mode placeholder - node "$bootstrap_validator" \ - --encode-payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-payload.json" \ - > "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-envelope.json" SQUAD_BOOTSTRAP_VALIDATOR chmod 500 "$runner" safe-outputs: @@ -737,33 +739,25 @@ exemplar's long audit format. ## Validation and output -Run exactly: +After generating the payload, run this command exactly once: ```bash "${GITHUB_WORKSPACE:?}/.github/workflows/run-squad-bootstrap-validator" ``` -Only exit status zero with stdout exactly -`Squad bootstrap validation passed.` authorizes reading -`.github/workflows/squad-bootstrap-envelope.json`. That file is the only -transport source for one `materialize_bootstrap` call. - -The envelope contains: - -- `payload_encoding`: exactly `base64` -- `payload_byte_length`: canonical decimal UTF-8 byte length, maximum 96,000 -- `payload_sha256`: lowercase SHA-256 of the complete payload bytes -- `payload_chunk_count`: canonical decimal from 1 through 16 -- `payload_chunk_00` through `payload_chunk_15`: only the populated fixed slots - -Each populated chunk is `NN:` followed by canonical Base64 for at most 6,000 -payload bytes, so every string is at most 8,003 bytes and stays conservatively -below gh-aw's 10,240-byte per-string input limit. Pass every property from the -envelope byte-for-byte to the typed safe-output call. Do not reserialize the -payload, recompute metadata, rename slots, add unused slots, or split semantic -generation into separate Cast and research outputs. The writer reconstructs the -one shared payload and verifies order, count, bounds, UTF-8, total byte length, -and SHA-256 before parsing any JSON. - -Any validation or envelope error is terminal: emit no materialization output, -report the exact validator stderr, and stop. +The authenticated command deterministically encodes the payload, reconstructs +and checks all chunk ordinals, count, bounds, UTF-8, byte length and SHA-256, +validates the payload schema and Cast tree, then invokes the existing typed +`materialize_bootstrap` safe-output tool through its mounted CLI with JSON on +stdin. Its tool invocation is bounded to 60 seconds. GitHub writes still occur +only in the safe-output job, which independently revalidates the payload. + +Exit status zero with stdout exactly +`Squad bootstrap validation passed; materialize_bootstrap submitted.` +means the one output has already been submitted. Stop immediately. +Never read, print, extract, or transcribe `squad-bootstrap-envelope.json` or its +chunks. Never invoke `materialize_bootstrap` separately, retry the command, or +split Cast and research into separate outputs. + +Any validation, envelope, or submission error is terminal: report the exact +stderr and stop without another materialization attempt. diff --git a/workflows/shared/squad-bootstrap-validator.mjs b/workflows/shared/squad-bootstrap-validator.mjs index b82e72a9a..6f04b8d27 100644 --- a/workflows/shared/squad-bootstrap-validator.mjs +++ b/workflows/shared/squad-bootstrap-validator.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; @@ -97,6 +98,12 @@ export function reconstructBootstrapPayload(envelope) { if (envelope.payload_encoding !== 'base64') { throw new Error('payload_encoding must be base64.'); } + for (const field of Object.keys(envelope)) { + if (field.startsWith('payload_chunk_') && field !== 'payload_chunk_count' + && !Array.from({ length: PAYLOAD_MAX_CHUNKS }, (_, index) => chunkField(index)).includes(field)) { + throw new Error(`${field} is not a valid fixed chunk slot.`); + } + } const chunkCount = parseBoundedDecimal(envelope.payload_chunk_count, 'payload_chunk_count', { minimum: 1, @@ -529,11 +536,12 @@ export function validateBootstrapPayload({ repository, defaultBranch, linkMode, + payloadText, }) { const errors = []; let payload; try { - payload = JSON.parse(readFileSync(payloadPath, 'utf8')); + payload = JSON.parse(payloadText ?? readFileSync(payloadPath, 'utf8')); } catch (error) { return [`payload: invalid JSON (${error.message})`]; } @@ -656,8 +664,46 @@ export function validateBootstrapPayload({ return [...new Set(errors)].sort(); } +export function submitBootstrapEnvelope(envelope, options) { + const payloadText = reconstructBootstrapPayload(envelope); + if (payloadText !== readFileSync(options.payloadPath, 'utf8')) { + throw new Error('Bootstrap envelope does not match the generated payload bytes.'); + } + const errors = validateBootstrapPayload({ ...options, payloadText }); + if (errors.length > 0) { + throw new Error(`Squad bootstrap validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}`); + } + // The mounted CLI invokes the same typed safe-output tool without model transcription. + const result = spawnSync('safeoutputs', ['materialize_bootstrap', '.'], { + input: JSON.stringify(envelope), + encoding: 'utf8', + timeout: 60_000, + maxBuffer: 1024 * 1024, + }); + if (result.error || result.status !== 0) { + throw new Error(`Bootstrap safe-output submission failed: ${ + result.error?.message || result.stderr || `exit ${result.status}, signal ${result.signal}` + }. Do not retry: submission may already have been recorded.`); + } +} + function main() { const cliArgs = process.argv.slice(2); + if (cliArgs[0] === '--submit-envelope') { + try { + const envelope = JSON.parse(readFileSync(resolve(cliArgs[1]), 'utf8')); + const options = parseArgs(cliArgs.slice(2)); + if (options.linkMode !== 'placeholder') { + throw new Error('Bootstrap submission requires --link-mode placeholder.'); + } + submitBootstrapEnvelope(envelope, options); + console.log('Squad bootstrap validation passed; materialize_bootstrap submitted.'); + } catch (error) { + console.error(`Squad bootstrap submission failed:\n- ${error.message}`); + process.exitCode = 1; + } + return; + } if (cliArgs.length === 2 && cliArgs[0] === '--encode-payload') { try { const payloadText = readFileSync(resolve(cliArgs[1]), 'utf8'); diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index 260a8052c..abc69c98a 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -38,6 +38,7 @@ resources: - shared/builtins/rai-charter.md - shared/builtins/fact-checker-charter.md tools: + cli-proxy: true edit: bash: true github: @@ -160,17 +161,18 @@ pre-agent-steps: # BEGIN GENERATED RESOURCE DIGESTS check_hash "$install_verifier" "514e210ebeae3b355ff73ed81bfd17f37207c9a3576ca44eaf8af7bfa21fc173" check_hash "$cast_validator" "0988e04aeef316f4d7a0107c902bbbcf6538b8899b9fffba5f62150717967685" - check_hash "$bootstrap_validator" "d449b9204f7fad133ff7133c1a30c9381c87e3c0c9d481352819ca93ea1a1dad" + check_hash "$bootstrap_validator" "4fc1bcc79b887bb4f2f78e7eef8693d5c1eaf817d8715ce3c92169934aa225b7" # END GENERATED RESOURCE DIGESTS node "$bootstrap_validator" \ + --encode-payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-payload.json" \ + > "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-envelope.json" + node "$bootstrap_validator" \ + --submit-envelope "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-envelope.json" \ --root "$PWD" \ --payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-payload.json" \ --repository "${GITHUB_REPOSITORY:?}" \ - --default-branch "${SQUAD_BOOTSTRAP_DEFAULT_BRANCH:?}" \ + --default-branch "${DEFAULT_BRANCH:?}" \ --link-mode placeholder - node "$bootstrap_validator" \ - --encode-payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-payload.json" \ - > "${GITHUB_WORKSPACE:?}/.github/workflows/squad-bootstrap-envelope.json" SQUAD_BOOTSTRAP_VALIDATOR chmod 500 "$runner" safe-outputs: @@ -727,33 +729,25 @@ exemplar's long audit format. ## Validation and output -Run exactly: +After generating the payload, run this command exactly once: ```bash "${GITHUB_WORKSPACE:?}/.github/workflows/run-squad-bootstrap-validator" ``` -Only exit status zero with stdout exactly -`Squad bootstrap validation passed.` authorizes reading -`.github/workflows/squad-bootstrap-envelope.json`. That file is the only -transport source for one `materialize_bootstrap` call. - -The envelope contains: - -- `payload_encoding`: exactly `base64` -- `payload_byte_length`: canonical decimal UTF-8 byte length, maximum 96,000 -- `payload_sha256`: lowercase SHA-256 of the complete payload bytes -- `payload_chunk_count`: canonical decimal from 1 through 16 -- `payload_chunk_00` through `payload_chunk_15`: only the populated fixed slots - -Each populated chunk is `NN:` followed by canonical Base64 for at most 6,000 -payload bytes, so every string is at most 8,003 bytes and stays conservatively -below gh-aw's 10,240-byte per-string input limit. Pass every property from the -envelope byte-for-byte to the typed safe-output call. Do not reserialize the -payload, recompute metadata, rename slots, add unused slots, or split semantic -generation into separate Cast and research outputs. The writer reconstructs the -one shared payload and verifies order, count, bounds, UTF-8, total byte length, -and SHA-256 before parsing any JSON. - -Any validation or envelope error is terminal: emit no materialization output, -report the exact validator stderr, and stop. +The authenticated command deterministically encodes the payload, reconstructs +and checks all chunk ordinals, count, bounds, UTF-8, byte length and SHA-256, +validates the payload schema and Cast tree, then invokes the existing typed +`materialize_bootstrap` safe-output tool through its mounted CLI with JSON on +stdin. Its tool invocation is bounded to 60 seconds. GitHub writes still occur +only in the safe-output job, which independently revalidates the payload. + +Exit status zero with stdout exactly +`Squad bootstrap validation passed; materialize_bootstrap submitted.` +means the one output has already been submitted. Stop immediately. +Never read, print, extract, or transcribe `squad-bootstrap-envelope.json` or its +chunks. Never invoke `materialize_bootstrap` separately, retry the command, or +split Cast and research into separate outputs. + +Any validation, envelope, or submission error is terminal: report the exact +stderr and stop without another materialization attempt. diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index 78f9e6968..62a4c2b7a 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -68,9 +68,9 @@ "source": "workflows/package/squad-bootstrap.md", "destination": ".github/workflows/squad-bootstrap.md", "lock": ".github/workflows/squad-bootstrap.lock.yml", - "source_sha256": "30a9aac8798128490f5ad2a484376b40d7847940af6e5176923a22cb7982df3a", - "lock_sha256": "7a5036ac6f39206dfc248789883a0925235736d5abf6fad69d97ac01a9aac5b0", - "package_lock_sha256": "72a96a6515c5e39e0031cb311e7baa2b2f3b6bfe072122ab5ac197cd66c5ed7b" + "source_sha256": "288a551bceb8f396c2653ef2cd2a24fec30172500f0f271cddec29115563b5af", + "lock_sha256": "54e72227f9071157259893efe561378b8476b9e6cec0b67536639f0ddf804923", + "package_lock_sha256": "5c32a151e97ece6e2cfeae7672497b1344a41defc81b5dff475a00cf6d0dfdd8" }, { "name": "squad-command-router", @@ -121,7 +121,7 @@ "package_destination": ".github/workflows/shared/squad-bootstrap-validator.mjs", "destination": ".github/workflows/shared/squad-bootstrap-validator.mjs", "ownership": "manifest", - "sha256": "d449b9204f7fad133ff7133c1a30c9381c87e3c0c9d481352819ca93ea1a1dad" + "sha256": "4fc1bcc79b887bb4f2f78e7eef8693d5c1eaf817d8715ce3c92169934aa225b7" }, { "path": "shared/squad-improvement-gate.mjs", From 7b5f3ef0576e6da4aca754cdc48413b1a2e75fb0 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 29 Sep 2026 15:58:53 -0700 Subject: [PATCH 2/2] fix(ci): seed immutable actions in native workflow consumer Closes #2112 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/squad-ci.yml | 4 +++ test/gh-aw-quality.test.ts | 51 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index a26578d1b..c395bbc05 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -791,6 +791,10 @@ jobs: test "$(find .github/workflows -maxdepth 1 -name 'squad*.md' | wc -l | tr -d ' ')" = 8 test "$(find .github/workflows/shared -type f ! -name 'squad-bootstrap-trigger-probe.json' | wc -l | tr -d ' ')" = 17 test "$(find .github/skills/gh-aw-enlistment -type f | wc -l | tr -d ' ')" = 1 + node --input-type=module -e ' + import { compileWithPinnedActions } from "./.github/workflows/shared/squad-install-verifier.mjs"; + compileWithPinnedActions(process.cwd()); + ' gh aw compile --strict --approve --no-check-update for workflow in squad squad-implement-worker squad-review squad-deps-worker squad-retro squad-improvement-worker squad-bootstrap squad-command-router; do gh aw compile "$workflow" --strict --no-check-update diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index dfef25f82..28c4be0e6 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -3992,6 +3992,57 @@ describe('gh-aw: canonical package integrity contract', () => { 120_000, ); + it('seeds native-consumer CI pins without usable authentication and rejects tampering', () => { + const workflow = parseDocument(readText(join(process.cwd(), '.github/workflows/squad-ci.yml'))).toJS(); + const step = workflow.jobs['gh-aw-compile'].steps.find( + (candidate: { name?: string }) => candidate.name === 'Verify a clean native package consumer', + ); + const seed = step.run.match(/node --input-type=module -e '([\s\S]*?)'/)?.[1]; + expect(seed, 'native consumer must execute the version-bound pin helper').toBeDefined(); + expect(step.run.indexOf('compileWithPinnedActions(process.cwd())')) + .toBeLessThan(step.run.indexOf('gh aw compile --strict --approve')); + expect(step.run).toContain('--verify-install'); + expect(step.run).toContain('--strict-compile'); + + const root = makeConsumer(revisionA, true, 'package'); + execFileSync('git', ['init', '--quiet'], { cwd: root }); + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/example/squad-consumer.git'], { cwd: root }); + // A deliberately invalid test credential prevents fallback to local keychain authentication. + const env = { ...process.env, GH_TOKEN: 'invalid-test-token', GITHUB_TOKEN: '' }; + const compile = () => execFileSync('gh', ['aw', 'compile', '--strict', '--no-check-update'], { + cwd: root, env, stdio: 'pipe', timeout: 120_000, + }); + const lockPath = join(root, '.github/workflows/squad.lock.yml'); + compile(); + const unpinned = readText(lockPath); + expect(createHash('sha256').update(normalizeCompiledLock(unpinned, revisionA)).digest('hex')) + .toBe('bf54175720e6e9ab3fe5d5e3054c28f3a69595c95cdf50e56f9b30988158270f'); + expect(verifyInstall(root).failures.join('\n')).toContain('Installed digest mismatch'); + + const seedPins = () => spawnSync(process.execPath, ['--input-type=module', '-e', seed!], { + cwd: root, env, encoding: 'utf8', timeout: 120_000, + }); + const seeded = seedPins(); + expect(seeded.status, seeded.stderr).toBe(0); + compile(); + expect(verifyInstall(root, { expectedRevision: revisionA, strictCompile: true }).failures).toEqual([]); + const contract = JSON.parse(readText(join(root, CONTRACT_DESTINATION))); + for (const entry of contract.workflows) { + const lock = readText(join(root, entry.lock)); + expect(() => validateCompilerActionPins(lock)).not.toThrow(); + expect(createHash('sha256').update(normalizeCompiledLock(lock, revisionA)).digest('hex')) + .toBe(entry.package_lock_sha256); + } + + const pinPath = join(root, '.github/aw/actions-lock.json'); + const pins = JSON.parse(readText(pinPath)); + pins.entries[`github/gh-aw-actions/setup@${MIN_GH_AW_VERSION}`].sha = '0'.repeat(40); + writeFileSync(pinPath, JSON.stringify(pins)); + const tampered = seedPins(); + expect(tampered.status).not.toBe(0); + expect(tampered.stderr).toContain('missing or noncanonical pin'); + }, 120_000); + function updateOwnedDigest(root: string, destination: string): void { const path = join(root, '.github/aw/packages/bradygaster-squad-workflows-test.json'); const record = JSON.parse(readFileSync(path, 'utf8'));