diff --git a/bin/gstack-ios-qa-daemon b/bin/gstack-ios-qa-daemon index d644a54a8..38c5e9b9c 100755 --- a/bin/gstack-ios-qa-daemon +++ b/bin/gstack-ios-qa-daemon @@ -21,6 +21,8 @@ # session tokens are outstanding. set -euo pipefail +# The --help heredoc is over 512 bytes; keep it off a pipe (test/heredoc-pipe-deadlock.test.ts). +BASH_COMPAT=50 case "${1:-}" in -h|--help) diff --git a/browse/src/chromium-profiles.ts b/browse/src/chromium-profiles.ts index b3cfe6666..d0fd32eae 100644 --- a/browse/src/chromium-profiles.ts +++ b/browse/src/chromium-profiles.ts @@ -103,6 +103,10 @@ function writeRegistry(list: string[]): void { */ export function ensureProjectProfile(profileDir: string, log: (msg: string) => void = console.log): void { if (fs.existsSync(profileDir)) return; + // An explicit CHROMIUM_PROFILE belongs to its caller (gbrowser's per-workspace + // gbd): never seed it from the shared profile or register it for pruning. + const explicit = process.env.CHROMIUM_PROFILE; + if (explicit && path.resolve(explicit) === path.resolve(profileDir)) return; const legacy = legacyProfileDir(); if (path.resolve(profileDir) === path.resolve(legacy)) return; writeRegistry([...readRegistry(), path.resolve(profileDir)]); diff --git a/browse/test/chromium-profile-isolation.test.ts b/browse/test/chromium-profile-isolation.test.ts index 57fdcc036..d7c16f232 100644 --- a/browse/test/chromium-profile-isolation.test.ts +++ b/browse/test/chromium-profile-isolation.test.ts @@ -390,3 +390,29 @@ describe.skipIf(process.platform === 'win32')('Chromium profile isolation (#2817 }, 60_000); } }); + +describe('explicit CHROMIUM_PROFILE (D5)', () => { + test('an explicit profile is neither seeded from the shared profile nor registered for pruning', async () => { + const { ensureProjectProfile } = await import('../src/chromium-profiles'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-explicit-profile-')); + const saved = { CHROMIUM_PROFILE: process.env.CHROMIUM_PROFILE, GSTACK_HOME: process.env.GSTACK_HOME }; + try { + process.env.GSTACK_HOME = path.join(root, 'state'); + fs.mkdirSync(path.join(process.env.GSTACK_HOME, 'chromium-profile'), { recursive: true }); + fs.writeFileSync(path.join(process.env.GSTACK_HOME, 'chromium-profile', 'Cookies'), 'shared logins'); + const explicit = path.join(root, 'workspace-profile'); + process.env.CHROMIUM_PROFILE = explicit; + const logs: string[] = []; + ensureProjectProfile(explicit, msg => logs.push(msg)); + expect(fs.existsSync(explicit)).toBe(false); + expect(fs.existsSync(path.join(process.env.GSTACK_HOME, 'browse-profiles.json'))).toBe(false); + expect(logs).toEqual([]); + } finally { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/browse/test/cli-loopback-no-proxy.test.ts b/browse/test/cli-loopback-no-proxy.test.ts index d34979607..57a21bb7a 100644 --- a/browse/test/cli-loopback-no-proxy.test.ts +++ b/browse/test/cli-loopback-no-proxy.test.ts @@ -49,7 +49,7 @@ describe('CLI → daemon behind a proxy that fails every request', () => { fs.writeFileSync(process.env.BROWSE_STATE_FILE!, JSON.stringify({ pid: process.pid, port: server.port, token: 'no-proxy-test', mode: 'launched', noProxy: process.env.NO_PROXY, })); - setTimeout(() => process.exit(0), 5000); + setTimeout(() => server.stop(true), 5000); `); const env: Record = {}; for (const [key, value] of Object.entries(process.env)) { diff --git a/browse/test/dotenv-isolation.test.ts b/browse/test/dotenv-isolation.test.ts index ec6cdf098..c1bb710c9 100644 --- a/browse/test/dotenv-isolation.test.ts +++ b/browse/test/dotenv-isolation.test.ts @@ -92,7 +92,7 @@ describe('D0: project .env and bunfig.toml are ignored', () => { pid: process.pid, port: server.port, token: 'dotenv-test', mode: 'launched', sandbox: process.env.GSTACK_CHROMIUM_NO_SANDBOX ?? null, })); - setTimeout(() => process.exit(0), 5000); + setTimeout(() => server.stop(true), 5000); `); const env: Record = {}; for (const [key, value] of Object.entries(process.env)) { diff --git a/browse/test/headless-launch-options.test.ts b/browse/test/headless-launch-options.test.ts index dbe40b26b..586bdcd52 100644 --- a/browse/test/headless-launch-options.test.ts +++ b/browse/test/headless-launch-options.test.ts @@ -38,12 +38,15 @@ describe.skipIf(process.platform !== 'linux')('headless launch options (Linux, r test('GSTACK_CHROMIUM_PATH is the binary the headless launch runs', async () => { const custom = chromium.executablePath(); process.env.GSTACK_CHROMIUM_PATH = custom; + // Only browsers this launch started count: an earlier file in the same + // shard process may still be reaping its own headless_shell child. + const children = () => new Set(fs.readdirSync('/proc').filter(d => /^\d+$/.test(d)).filter(d => { + try { return fs.readFileSync(`/proc/${d}/stat`, 'utf-8').split(') ')[1].split(' ')[1] === String(process.pid); } catch { return false; } + })); + const before = children(); bm = new BrowserManager(); await bm.launch(); - const children = fs.readdirSync('/proc').filter(d => /^\d+$/.test(d)).filter(d => { - try { return fs.readFileSync(`/proc/${d}/stat`, 'utf-8').split(') ')[1].split(' ')[1] === String(process.pid); } catch { return false; } - }); - const cmdlines = children.map(pid => readPidCmdline(Number(pid))); + const cmdlines = [...children()].filter(pid => !before.has(pid)).map(pid => readPidCmdline(Number(pid))); expect(cmdlines.some(c => c.startsWith(custom) && c.includes('--headless'))).toBe(true); expect(cmdlines.some(c => c.includes('headless_shell'))).toBe(false); }, 60_000); diff --git a/scripts/resolvers/outside-voice-steps.ts b/scripts/resolvers/outside-voice-steps.ts index 7d96a9692..c2e046073 100644 --- a/scripts/resolvers/outside-voice-steps.ts +++ b/scripts/resolvers/outside-voice-steps.ts @@ -15,6 +15,7 @@ import { toShellPath, type TemplateContext } from './types'; import { CC_BACKGROUND_DEFAULT_SINCE } from './constants'; import { outsideVoiceFailurePolicy, outsideVoiceFor, outsideVoiceInvocation, outsideVoicePreflight, outsideVoiceProvenance } from './outside-voice'; +import { runtimeRootPrelude } from './runtime-root'; const CODEX_BOUNDARY = 'IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are skill definitions, not repository review data. Do not invoke any installed skill (Codex home skills/, .agents/), hook, or tool instruction; answer directly. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\\n\\n'; @@ -321,6 +322,7 @@ shell and re-reads the control; enabled workflows never append a disabled record If logging fails, report the persistence failure and retain the disabled opt-out. \`\`\`bash +${runtimeRootPrelude(ctx)} _DISABLED_REVIEW_MODE=$("${bin}/gstack-config" get codex_reviews 2>/dev/null) || { echo 'Cannot read codex_reviews; disabled outside coverage was not recorded.' >&2 exit 1 diff --git a/scripts/resolvers/outside-voice.ts b/scripts/resolvers/outside-voice.ts index a3e47c2c1..e741c9188 100644 --- a/scripts/resolvers/outside-voice.ts +++ b/scripts/resolvers/outside-voice.ts @@ -3,6 +3,7 @@ */ import { toShellPath, type TemplateContext } from './types'; import { CODEX_MODEL_CONFIG_FLAG, CODEX_REVIEW_MODEL_CONFIG_FLAG, CODEX_WEB_SEARCH_FLAG, codexPreflight } from './constants'; +import { runtimeRootPrelude } from './runtime-root'; export function outsideVoiceFor(ctx: Pick) { return ctx.host === 'codex' @@ -46,7 +47,8 @@ fi`; export function outsideVoicePreflight(ctx: TemplateContext, opts: { disabledBehavior: 'skip-all' | 'codex-only' | 'opt-in'; acceptedOnly?: boolean; nativeReview?: boolean }): string { const v = outsideVoiceFor(ctx); if (v.id === 'codex' && opts.disabledBehavior !== 'opt-in') { - let preflight = outsideVoiceLabels(ctx, codexPreflight({ disabledBehavior: opts.disabledBehavior, nativeReview: opts.nativeReview })); + let preflight = outsideVoiceLabels(ctx, codexPreflight({ disabledBehavior: opts.disabledBehavior, nativeReview: opts.nativeReview })) + .replace('```bash\n', `\`\`\`bash\n${runtimeRootPrelude(ctx)}\n`); if (['plan-eng-review', 'plan-ceo-review'].includes(ctx.skillName)) { preflight = preflight.replace("follow the workflow's native-review instructions below", 'construct the prompt below, then follow **Native fallback**'); @@ -69,6 +71,7 @@ else echo 'CODEX_MODE: under_current_harness' fi`; return `\`\`\`bash +${runtimeRootPrelude(ctx)} ${opts.acceptedOnly ? '' : `${config} if [ "$_OUTSIDE_CFG" = disabled ]; then echo 'CODEX_MODE: disabled' @@ -120,6 +123,7 @@ if [ "$_OUTSIDE_EXIT" -eq 0 ]; then bun -e 'const r=await Bun.file(process.argv[1]).json(); if(r.status!=="completed" || typeof r.result!=="string" || !r.result.trim()) process.exit(1); await Bun.write(process.argv[2],r.result)' "$_OUTSIDE_TMP/result.json" "$_OUTSIDE_TMP/text" || _OUTSIDE_EXIT=1 fi`; return `${outsideVoiceGuard(ctx)} +${runtimeRootPrelude(ctx)} _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo 'ERROR: not in a git repo' >&2; exit 1; } _OUTSIDE_TMP=$(mktemp -d "\${TMPDIR:-/tmp}/gstack-outside.XXXXXXXX") || exit 1 trap 'rm -rf "$_OUTSIDE_TMP"' EXIT diff --git a/test/autoplan-methodology-names.test.ts b/test/autoplan-methodology-names.test.ts index 722742198..90f1ab21f 100644 --- a/test/autoplan-methodology-names.test.ts +++ b/test/autoplan-methodology-names.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; -import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { createSnapshot, prepareMethodology } from '../bin/gstack-autoplan-snapshot'; @@ -26,7 +26,9 @@ function fixture(phase: string, layout: string) { const target = join(dir, skill); mkdirSync(target); cpSync(join(source, 'SKILL.md'), join(target, 'SKILL.md')); - if (layout !== 'inlineCodex') cpSync(join(source, 'sections'), join(target, 'sections'), { recursive: true }); + // C4: Codex carves plan-ceo-review too, so an install that has sections/ copies it. + const carved = existsSync(join(source, 'sections')); + if (carved) cpSync(join(source, 'sections'), join(target, 'sections'), { recursive: true }); if (layout === 'patchedClaude') { const result = spawnSync('bash', [join(ROOT, 'bin/gstack-patch-names'), dir, 'true'], { encoding: 'utf8', timeout: 10_000 }); expect(result.status, result.stderr).toBe(0); @@ -36,7 +38,7 @@ function fixture(phase: string, layout: string) { const restore = join(dir, 'restore.md'); writeFileSync(active, '## Implementation plan\nBuild the widget.\n## Review record\n'); writeFileSync(restore, 'Restore point.\n'); - return { dir, file: join(target, 'SKILL.md'), active, restore, skill }; + return { dir, file: join(target, 'SKILL.md'), active, restore, skill, carved }; } describe('installed methodology identities', () => { @@ -44,7 +46,8 @@ describe('installed methodology identities', () => { test(`${phase}: ${layout} prepares and consumes exact installed bytes`, () => { const f = fixture(phase, layout); const bundle = prepareMethodology(phase, f.file, f.restore); - expect(bundle.sources).toHaveLength(layout === 'inlineCodex' ? 1 : 2); + expect(bundle.sources).toHaveLength(f.carved ? 2 : 1); + if (layout !== 'inlineCodex') expect(f.carved).toBe(true); const bytes = readFileSync(bundle.methodologyPath); for (const source of bundle.sources) expect(bytes.subarray(source.startByte, source.endByte)).toEqual(readFileSync(source.path)); expect(readFileSync(createSnapshot(phase, f.active, f.restore, bundle.methodologyPath).snapshotPath, 'utf8')).toBe('Build the widget.\n'); diff --git a/test/autoplan-review-discovery.test.ts b/test/autoplan-review-discovery.test.ts index 7f5dcfe1a..1df7cd604 100644 --- a/test/autoplan-review-discovery.test.ts +++ b/test/autoplan-review-discovery.test.ts @@ -99,7 +99,10 @@ describe('autoplan reads installed host methodology', () => { const loaded = fs.readFileSync(path.resolve(path.dirname(entry), reference), 'utf8'); expect(loaded).toBe(fs.readFileSync(generatedReview, 'utf8')); expect(loaded).not.toContain('STALE FOREIGN HARNESS SKILL'); - if (host.name === 'codex') expect(loaded).toContain('"outside_provider":"claude-code"'); + // C4: a carved review (every host's plan-ceo-review; Claude's four) keeps its body in sections/. + const sectionSource = path.join(generatedRoot, reviewName, 'sections/review-sections.md'); + const carved = fs.existsSync(sectionSource); + if (host.name === 'codex') expect(loaded + (carved ? fs.readFileSync(sectionSource, 'utf8') : '')).toContain('"outside_provider":"claude-code"'); const phase = review === 'plan-devex-review' ? 'dx' : review.split('-')[1]!; const phaseBody = host.name === 'claude' ? fs.readFileSync(path.join(generatedRoot, 'autoplan', 'sections', `${phase}-phase.md`), 'utf8') @@ -117,8 +120,9 @@ describe('autoplan reads installed host methodology', () => { expect(directive).toContain(`methodology ${phase} `); expect(directive).toContain('`methodologyPath` from'); expect(directive).toContain('""'); + } + if (carved) { const relative = 'sections/review-sections.md'; - const sectionSource = path.join(generatedRoot, review, relative); const sectionInstalled = path.resolve(path.dirname(entry), reference, '..', relative); installFile(sectionSource, sectionInstalled, mode); const section = fs.readFileSync(sectionInstalled, 'utf8'); diff --git a/test/helpers/manual-judge-review-fixture.ts b/test/helpers/manual-judge-review-fixture.ts index 148c357df..d62cc8156 100644 --- a/test/helpers/manual-judge-review-fixture.ts +++ b/test/helpers/manual-judge-review-fixture.ts @@ -24,13 +24,19 @@ const LATER_PREAMBLE_EDITS: Array<[string, string]> = [ ['~/.claude/skills/gstack/bin/gstack-skill-start --skill "setup-browser-cookies" --model "claude"\n', '_SS="$HOME/.claude/skills/gstack/bin/gstack-skill-start"\n[ -x "$_SS" ] || _SS=".claude/skills/gstack/bin/gstack-skill-start"\n"$_SS" --skill "setup-browser-cookies" --model "claude" --parent-pid "$PPID" \\\n || echo "SKILL_START: unavailable — stale install; run ./setup or /gstack-upgrade (preamble degraded, continue the user\'s task)"\n'], ]; +// The v1.91.19.0 generated-bash lint (INV-3) gave the shared Bun-install block a +// ${TMPDIR:-/tmp} mktemp template; same line, different bytes. +const LATER_WAVE_EDITS: Array<[string, string]> = [ + ['tmpfile=$(mktemp "${TMPDIR:-/tmp}/bun-install.XXXXXX")', 'tmpfile=$(mktemp)'], +]; + export function approvedCookieWorkflowSource(source: string): string { let removedLines = 0; let historical = source .replace('sha256sum < "$tmpfile" | awk \'{print $(1)}\'', 'sha256sum "$tmpfile" | awk \'{print $1}\'') .replace('shasum -a 256 < "$tmpfile" | awk \'{print $(1)}\'', 'shasum -a 256 "$tmpfile" | awk \'{print $1}\'') .replace(LATER_BROWSER_BLOCK, block => { removedLines = block.split('\n').length - 1; return ''; }); - for (const [later, earlier] of [...LATER_BROWSER_EDITS, ...LATER_PREAMBLE_EDITS]) historical = historical.replace(later, earlier); + for (const [later, earlier] of [...LATER_BROWSER_EDITS, ...LATER_PREAMBLE_EDITS, ...LATER_WAVE_EDITS]) historical = historical.replace(later, earlier); return historical .replace(/(--- BEGIN FILE "BROWSER\.md" \(lines \d+-)(\d+)(; section\) ---)/, (_, head, end, tail) => `${head}${Number(end) - removedLines}${tail}`) .replace(/(--- BEGIN FILE "setup-browser-cookies\/SKILL\.md" \(lines )(\d+)-(\d+)(; entrypoint\) ---)/, (_, head, start, end, tail) => `${head}${APPROVED_ENTRYPOINT_START}-${APPROVED_ENTRYPOINT_START + Number(end) - Number(start)}${tail}`); diff --git a/test/outside-voice-preflight.test.ts b/test/outside-voice-preflight.test.ts index 200d2626e..169e1802c 100644 --- a/test/outside-voice-preflight.test.ts +++ b/test/outside-voice-preflight.test.ts @@ -195,7 +195,8 @@ describe('outside reviewer runtime discovery in fresh shells', () => { f.install(f.local); const explicit = f.install(path.join(f.home, 'explicit runtime')); expect(f.preflight({ GSTACK_ROOT: explicit }).stdout).toContain(`RESOLVED_ROOT: ${explicit}`); - expect(f.preflight({ GSTACK_BIN: path.join(explicit, 'bin') }).stdout).toContain(`RESOLVED_ROOT: ${explicit}`); + // C1: only an exported GSTACK_ROOT (with bin/ and lib/) is honored; a lone GSTACK_BIN falls back to the repo-local install. + expect(f.preflight({ GSTACK_BIN: path.join(explicit, 'bin') }).stdout).toContain(`RESOLVED_ROOT: ${f.local}`); const result = f.preflight({ GSTACK_ROOT: '/missing/gstack', GSTACK_BIN: '/missing/gstack/bin' }); expect(result.stdout).toContain('CODEX_MODE: ready'); expect(result.stdout).toContain(`RESOLVED_ROOT: ${f.local}`); diff --git a/test/plan-review-cases.test.ts b/test/plan-review-cases.test.ts index 580e1aff9..0055388f8 100644 --- a/test/plan-review-cases.test.ts +++ b/test/plan-review-cases.test.ts @@ -269,9 +269,15 @@ describe('plan report persistence precedes completion logging', () => { try { const generated = await runGeneration({ host: 'all', outputRoot, contentLinkRoot: null, log: () => {} }); expect(generated.exitCode, JSON.stringify(generated.diagnostics)).toBe(0); - const carriers = generated.artifacts.filter(artifact => artifact.host === 'claude' - ? artifact.kind === 'section' && plans.some(skill => artifact.relativePath === `${skill}/sections/review-sections.md`) - : artifact.kind === 'skill' && plans.some(skill => artifact.relativePath.endsWith(`/gstack-${skill}/SKILL.md`))); + // A carved skill carries the report in sections/review-sections.md (every + // host since C4 for plan-ceo-review); an inlined one in its SKILL.md. + const isSection = (artifact: typeof generated.artifacts[number]) => artifact.kind === 'section' + && plans.some(skill => artifact.relativePath === `${skill}/sections/review-sections.md` + || artifact.relativePath.endsWith(`/gstack-${skill}/sections/review-sections.md`)); + const carved = new Set(generated.artifacts.filter(isSection).map(a => `${a.host}:${a.relativePath.replace(/\/sections\/review-sections\.md$/, '')}`)); + const carriers = generated.artifacts.filter(artifact => isSection(artifact) + || (artifact.kind === 'skill' && plans.some(skill => artifact.relativePath.endsWith(`/gstack-${skill}/SKILL.md`)) + && !carved.has(`${artifact.host}:${artifact.relativePath.replace(/\/SKILL\.md$/, '')}`))); expect(carriers).toHaveLength(plans.length * ALL_HOST_CONFIGS.length); for (const carrier of carriers) { const content = readFileSync(join(outputRoot, carrier.relativePath), 'utf8'); @@ -660,7 +666,9 @@ describe('outside-voice commitment queue', () => { const eng = generateCodexPlanReview({ host: host.name, paths: HOST_PATHS[host.name]!, skillName: 'plan-eng-review' } as TemplateContext); const provider = host.name === 'codex' ? 'Claude Code' : 'Codex'; const mismatch = host.name === 'codex' ? 'under_current_harness' : 'under_codex'; - expect(eng).toContain(`**If \`CODEX_MODE: ready\` — run ${provider}:**`); + // B1: the heading also admits `unverified`. + expect(eng).toContain('**If `CODEX_MODE: ready`'); + expect(eng).toContain(`— run ${provider}:**`); const fallback = compactProse(eng.slice(eng.indexOf('**Native fallback —'), eng.indexOf('**Bounded outside-voice wait'))); const routing = eng.slice(eng.indexOf('**Outcome routing:**'), eng.indexOf('**Disabled is a terminal branch')); const preflight = eng.match(/```bash\n([\s\S]*?)\n```/)![1]; diff --git a/test/run-in-background-guidance.test.ts b/test/run-in-background-guidance.test.ts index e6868e06b..e6b8065aa 100644 --- a/test/run-in-background-guidance.test.ts +++ b/test/run-in-background-guidance.test.ts @@ -32,7 +32,8 @@ describe('generated Codex plan-review shell invocation', () => { const rendered = generateCodexPlanReview({ ...reviewContext('claude'), paths: { ...HOST_PATHS.claude, binDir: path.join(ROOT, 'bin'), skillRoot: ROOT }, }); - const ready = rendered.slice(rendered.indexOf('**If `CODEX_MODE: ready` — run Codex:**'), + // B1: the heading also admits `unverified`; locate it structurally. + const ready = rendered.slice(rendered.search(/\*\*If `CODEX_MODE: ready`[^\n]*— run Codex:\*\*/), rendered.indexOf('Present the full output verbatim:')); const blocks = [...ready.matchAll(/```bash\n([\s\S]*?)\n```/g)].map(match => match[1]!); const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'"; @@ -44,6 +45,10 @@ describe('generated Codex plan-review shell invocation', () => { const prompt = path.join(dir, 'review-prompt.txt'); fs.writeFileSync(prompt, 'Review the current plan without edits.'); const created = path.join(dir, 'created'); + // Q2's one-time notice has its own tests; mark it shown in a private state root. + const state = path.join(dir, 'state'); + fs.mkdirSync(state); + fs.writeFileSync(path.join(state, '.codex-review-notice-shown'), ''); const calls = path.join(dir, 'calls'); const stale = path.join(dir, 'codex-out-foreign'); const staleError = path.join(dir, 'codex-planreview-foreign'); @@ -57,10 +62,15 @@ p=$(${quote(Bun.which('mktemp')!)} "$@") || exit 1 printf '%s\\n' "$p" >> "$FAKE_CREATED" printf '%s\\n' "$p" `); + // B1/E5: the free sandbox preflight runs first; exec reads the prompt on stdin + // and writes its final message to -o (stdout carries --json events). writeBin('codex', ` +[ "$1" = sandbox ] && exit 0 printf '%s\\n' "$FAKE_REVIEW_ID" >> "$FAKE_CALLS" -printf '%s\\n' "$FAKE_REVIEW_ID: current findings" -printf '%s\\n' "Recommendation: fix $FAKE_REVIEW_ID because this is the current finding." +out=; prev=; for a in "$@"; do [ "$prev" = -o ] && out=$a; prev=$a; done +cat > /dev/null +printf '%s\\n' "$FAKE_REVIEW_ID: current findings" "Recommendation: fix $FAKE_REVIEW_ID because this is the current finding." > "$out" +printf '%s\\n' '{"type":"turn.completed"}' printf '%s\\n' "$FAKE_REVIEW_ID: current stderr" >&2 exit "$FAKE_CODEX_STATUS" `); @@ -76,6 +86,7 @@ exec ${quote(Bun.which('cat')!)} "$@" FAKE_CREATED: created, FAKE_CALLS: calls, FAKE_REVIEW_ID: id, FAKE_CODEX_STATUS: String(code), FAKE_MKTEMP_FAIL: mktempFailure ? '1' : '0', FAKE_CAT_FAIL: catFailure ? '1' : '0', TMPDIR: dir, CODEX_HOME: dir, GSTACK_CODEX_MODEL: '', + GSTACK_HOME: state, GSTACK_STATE_ROOT: '', CODEX_THREAD_ID: '', CODEX_SANDBOX: '', CLAUDECODE: '1', GSTACK_ACTIVE_HOST: 'claude' }; // Each displayed block gets a fresh shell, as separate Bash tool calls do. return blocks.map(block => spawnSync('bash', ['-c', (errexit ? 'set -e\n' : '') + block.replace("''", quote(prompt))], { @@ -90,7 +101,9 @@ exec ${quote(Bun.which('cat')!)} "$@" } const findings = (id: string) => `${id}: current findings\nRecommendation: fix ${id} because this is the current finding.\n`; - const completed = (id: string) => `${findings(id)}OUTSIDE_STATUS: completed provider=codex host=claude\n`; + // INV-1: the verdict-form validator prints its verdict before the completed status. + const unavailable = (id: string) => `${findings(id)}VERDICT: unavailable\nFINDINGS: none\nREASON: execution_failed\n`; + const completed = (id: string) => `${findings(id)}VERDICT: clean\nFINDINGS: none\nOUTSIDE_STATUS: completed provider=codex host=claude\n`; test('fresh shells retain the current stderr and clean its exact temporary file', () => { const f = fixture(); @@ -108,7 +121,7 @@ exec ${quote(Bun.which('cat')!)} "$@" try { const results = f.run('failed', 23, errexit); expect(results.map(result => result.status)).toEqual([23]); - expect(results[0]!.stdout).toBe(findings('failed')); + expect(results[0]!.stdout).toBe(unavailable('failed')); expect(results[0]!.stderr).toContain('failed: current stderr\n'); expect(f.created()).toHaveLength(1); expect(f.created().every(file => !fs.existsSync(file))).toBe(true); @@ -120,7 +133,7 @@ exec ${quote(Bun.which('cat')!)} "$@" try { const results = f.run('display-failed', code, errexit, false, true); expect(results.map(result => result.status)).toEqual([code || 1]); - expect(results[0]!.stdout).toBe(findings('display-failed')); + expect(results[0]!.stdout).toBe(unavailable('display-failed')); expect(results[0]!.stderr).toContain('cat: simulated current-file read failure\n'); expect(f.created()).toHaveLength(1); expect(f.created().every(file => !fs.existsSync(file))).toBe(true); @@ -247,7 +260,8 @@ describe('outside-voice dispatch contract', () => { const apply = rendered.indexOf('**4. Apply the answered row.**'); expect(answer).toBeGreaterThan(0); expect(apply).toBeGreaterThan(answer); - expect(rendered).toContain(`-s read-only ${CODEX_MODEL_CONFIG_FLAG} -c 'model_reasoning_effort="high"'`); + // B1: the sandbox comes from _gstack_codex_select_model (read-only unless GSTACK_CODEX_NO_SANDBOX=1). + expect(rendered).toContain(`-s "\${_GSTACK_CODEX_SANDBOX:?}" ${CODEX_MODEL_CONFIG_FLAG} -c 'model_reasoning_effort="high"'`); }); test('the generated-carrier exception rejects missing wait, cancellation or result guards', () => { diff --git a/test/spec-quality-gate-secret-sink.test.ts b/test/spec-quality-gate-secret-sink.test.ts index f828a163c..60f6d418d 100644 --- a/test/spec-quality-gate-secret-sink.test.ts +++ b/test/spec-quality-gate-secret-sink.test.ts @@ -29,7 +29,8 @@ function runScan(host: 'claude' | 'codex', body: string, scanner: 'real' | 'brok const temps = join(scratch, 'tmp'); const systemTemps = join(scratch, 'system-tmp'); const shims = join(scratch, 'shims'); - for (const dir of [bin, sinks, temps, systemTemps, shims]) mkdirSync(dir, { recursive: true }); + // C1: the env-var-host prelude honors an exported GSTACK_ROOT only when it has bin/ and lib/. + for (const dir of [bin, join(runtime, 'lib'), sinks, temps, systemTemps, shims]) mkdirSync(dir, { recursive: true }); const realMktemp = Bun.which('mktemp'); if (!realMktemp) throw new Error('mktemp is required for the spec redaction fixture'); writeFileSync(join(shims, 'mktemp'), `#!/usr/bin/env bash diff --git a/test/tracker-guard-wiring.test.ts b/test/tracker-guard-wiring.test.ts index 79b2875dd..156fb0a31 100644 --- a/test/tracker-guard-wiring.test.ts +++ b/test/tracker-guard-wiring.test.ts @@ -41,6 +41,12 @@ const READ_PATTERNS: { name: string; re: RegExp }[] = [ // (file, pattern-name) exemptions with reasons. Keep every entry REASONED. const SCANNER_EXEMPT: { file: string; pattern: string; reason: string }[] = [ + { + file: 'scripts/resolvers/plan-gates.ts', + pattern: 'gh pr body read', + reason: + 'F1 plan binding: only the first `Plan: ` token survives (one whitespace-free path, CR and backticks stripped); the body never reaches model context, and the path is then read as a file the parent confirms', + }, { file: 'review/greptile-triage.md', pattern: 'gh comment-body api read', diff --git a/test/workflow-judge-input.test.ts b/test/workflow-judge-input.test.ts index 4b47d50da..04fbf9ee5 100644 --- a/test/workflow-judge-input.test.ts +++ b/test/workflow-judge-input.test.ts @@ -324,8 +324,9 @@ describe('workflow judge file bundle', () => { expect(entrypoint.content).toContain('## Scope gate'); expect(entrypoint.content.indexOf('## Scope gate')).toBeLessThan(entrypoint.content.indexOf('### Step 0: Scope Challenge')); expect(entrypoint.content).toContain('## Web research runs in Aside'); - expect(entrypoint.content).toContain('echo "READY: aside'); - expect(input.text.indexOf('echo "READY: aside')).toBeLessThan(input.text.indexOf('- **Search check:**')); + // E7: the readiness probe prints the resolved Aside path (READY: $_A). + expect(entrypoint.content).toContain('echo "READY: $_A'); + expect(input.text.indexOf('echo "READY: $_A')).toBeLessThan(input.text.indexOf('- **Search check:**')); expect(entrypoint.content).not.toContain('- **Search check:**'); expect(occurrences(input.text, '- **Search check:**')).toBe(1); expect(occurrences(input.text, '## Scope gate')).toBe(1);