Files
gstack/test/setup-gbrain-remote-caller.test.ts
Garry Tanandcapy-ai[bot] 8018ba52fe v1.91.243.0 fix(capy): source the launch environment from a 0600 file; no skill fence may print the environment (GSTA-104) (#3449)
* fix(capy): the launch environment is sourced from a 0600 file and no skill fence may print the environment (GSTA-104)

During GSTA-96's unattended /ship on #3425, the Step 14.5 docs-audit child
re-typed the GSTACK_LAUNCH assignments as `export $(...)`; the substitution
came back empty, the bare `export` printed the whole environment, and three
API key values landed in its tool output.

- bin/gstack-capy-install writes the assignments to <verdict>.env (mode 0600,
  idempotent PATH prepend) and prints GSTACK_LAUNCH_FILE: <path>.
- ship Step 14.5 prompt, document-release's spawned note, autoplan/DRIVER.md
  and gstack-skill-start's capy-asks block say: source the file; never
  export $(...), env, printenv or bare set in a tool fence.
- test/launch-env-echo-ratchet.test.ts greps every generated SKILL.md,
  sections, DRIVER.md, the Capy skill and bin/ shell helpers, with negative
  controls under test/fixtures/launch-env-echo/.
- lib/redact-engine.ts: env.value (HIGH) finds the value of any *_KEY,
  *_TOKEN, *_SECRET, *_PASSWORD variable in the writer's environment, so a
  leaked value cannot reach a PR body, receipt, report or Drive file.
- scripts/lib/shard-engine.ts scrubs the Capy launch markers from shard
  environments (GSTA-99's finding).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ci): DRIVER.md back to 159 lines; shard-engine recorded non-behavioral at HARNESS_VERSION 6; verdict-count test lists .json only

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: register test/fixtures/launch-env-echo as a free fixture

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(redact): env-value fixtures stay out of pregate's env.kv net

A credential-named assignment with an entropic literal on one line is what
the repo's own pregate secrets scan refuses in added lines, so the fixture
values move into plainly named constants and the scanned text is built from
them. The scan under test still sees the same values through processEnv.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(windows): the late-completion remote-caller case gives its second attempt a 2s budget (run 40263984171)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): env.value trims the value, keeps a prefix value's longer match, hides the preview and skips PUBLIC names

/ship Step 9.4 review fixes for the GSTA-104 env-value scan:

- a value whose environment copy ends in a newline (a key read from a file)
  is searched for trimmed, so its leaked form still matches (the length
  check already trimmed; the search used the raw value);
- when one credential value is a prefix of another, the dedup key carries the
  span length, so the longer value is redacted whole in either order instead
  of leaving its tail in the text and dropping its finding;
- the env.value preview is a fixed mask instead of the first characters of a
  live credential;
- framework-public names (NEXT_PUBLIC_*_KEY, *_PUBLIC_KEY) are not scanned,
  since those values are committed on purpose;
- the header and the processEnv docstring state the name rule (KEY, TOKEN,
  SECRET, PASSWORD, PASSWD, any case) and that process.env is the engine's
  one ambient input.

Step 7 coverage extensions and a regression test for each fix in
test/redact-env-value.test.ts; docs/capy-install.md names the full rule.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* refactor(capy-install): write_launch_file quotes through shq; header names the launch file

Review finding: _sq duplicated shq() from the same file. The launch file's
assignments now go through shq, and the header comment describes the
GSTACK_LAUNCH_PATH and GSTACK_LAUNCH_FILE lines beside GSTACK_LAUNCH.

Step 7 coverage: the verdict test sources the launch file and runs --check;
the single-quote path test sources the file too; a new case makes the
launch-file directory unwritable and checks the run says so, prints no
GSTACK_LAUNCH_FILE line, leaves no temp file and keeps its exit code and
trailer (linux, non-root).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(preload): strip the Capy launch markers so a direct bun test matches the shard sandbox

Red Team finding: the launch-marker scrub lived only in createShardSandbox,
so a direct `bun test <file>` run under an unattended Capy session inherited
GSTACK_SESSION_KIND, GSTACK_CAPY, GSTACK_EPHEMERAL and GSTACK_INSTALL_VERDICT
(GSTA-99's symptom). The preload now deletes the same four names, and
test/shard-engine.test.ts imports CAPY_LAUNCH_MARKERS instead of re-typing them.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(ratchet): the launch-env echo scan catches backtick and $VAR exports, typeset -p, env flags and a set in any segment

Review findings 11 and 12: export-subst also trips on `export \`...\`` and
an unquoted `export $VAR` (both splat a NAME=value file; `export "$name"` is
one word and stays allowed); the export rule adds `typeset -p`; env trips
with only flags (`env -0`) and as `/usr/bin/env`; set is judged per segment
and with redirections, allowed only when piped straight into an anchored
grep filter. Quoted strings are blanked offset-preserving so a `#` inside
quotes no longer hides the rest of the line. The negative control covers
each new form on both sides.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(ship): Step 14.5 names where the launch-file placeholder comes from

The /document-release prompt line read `. <file>` with no word on what fills
it. It now says the placeholder is the GSTACK_LAUNCH_FILE: path the parent's
own install check printed. Worded to stay under the ship skill's pinned
parity size (313,279 of 313,281 union bytes).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): env.value admits *_PASS, *_PAT, *_APIKEY and *_PWD, and skips path values and gstack's lock token

/ship Step 11 adversarial findings. The name rule also covers DB_PASS,
GITHUB_PAT, *_APIKEY and *_PWD (the shell's own PWD and OLDPWD stay out). A
credential-named variable whose value is a filesystem path (SSL_KEY=/etc/ssl/
private/x.key) names where the credential lives, so the path is no finding,
and GSTACK_BRAIN_LOCK_TOKEN (pid-random-epoch, written to the lock owner file)
is not a secret. Without these, an ordinary mention of that path or token in a
PR body, receipt or CSO report was a HIGH fail-closed blocker.

The adversarial pass also recommended an entropy floor so dev defaults such as
DB_PASSWORD=postgres stop matching prose; declined here on purpose, because the
scan's promise is to catch a leaked value whatever its shape, and a weak real
password is still a credential. Recorded as an open decision in the ship report.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy-install): a failed launch-file write removes only its own temp file

The failure path ran rm -f "$LAUNCH_FILE".tmp.*, which also deleted the temp
file a concurrent installer run (two threads on one machine, same verdict
path) was about to mv, so that run lost its launch file too. write_launch_file
now cleans up its own $tmp and returns 1; the caller only reports. The file's
comment no longer embeds the state-root path (a newline in GSTACK_STATE_ROOT
could have ended the comment).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: the launch markers come from one list, and the echo ratchet scans setup and scripts/*.sh

The preload imports CAPY_LAUNCH_MARKERS instead of re-typing the four names,
and the ratchet asserts the installer exports every name on that list, so a
fifth marker cannot be added to one place only. The shell scan also covers
./setup and the scripts/*.sh install scripts callers run first.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): the Unreleased entry states the review-fixed env.value rules, the ratchet forms and the one test-budget change

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): the env.value path exemption asks whether the file exists instead of matching a shape

Second review cycle: a path-shaped rule also matched a base64 secret that
starts with "/" and holds another "/" (about 1 in 150 AWS-shaped keys), which
then went unscanned. The exemption now fires only when the value names a file
that exists on the scanning machine (SSL_KEY=/etc/ssl/private/x.key). The
module header names that stat as the engine's one filesystem read.
NON_SECRET_ENV_NAMES is exported so the test can prove bin/gstack-brain-lock.sh
still exports every name in it; the PWD/OLDPWD fixtures hold plain words so only
the name rule keeps them out.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test: the installer's own-temp-file cleanup is exercised, and the ratchet coverage test names setup and scripts

An mv shim first on PATH refuses only the launch-file target, so the temp
file exists when the move fails: the run removes that file alone (a sibling
from a concurrent run survives), says so, prints no GSTACK_LAUNCH_FILE line
and keeps the earlier file, exit code and trailer. The ratchet coverage test
asserts setup, scripts/build.sh and scripts/sandbox-doctor.sh are scanned.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): env.value has no path exemption and matches the value in normalized form

Second adversarial pass: the existence check let the scanned agent exempt
any "/"- or "~/"-prefixed secret by creating a path of that name, a silent
bypass of the one guard this branch adds, so the exemption is gone; a
path-valued SSL_KEY that must appear in a text goes through the allowlist.
The engine is back to no I/O. The value is now normalized the way the text
is before indexOf, so a credential NFKC rewrites (fullwidth digits) still
finds its leaked ASCII form.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy-install): the launch file is written only on a passing run; the ratchet checks the marker list both ways

A gstack: fail run no longer writes or announces a sourceable file whose PATH
prepend points at an unverified Bun. The ratchet also asserts every variable
the launch file exports is a CAPY_LAUNCH_MARKERS entry or GSTACK_STATE_ROOT,
so a sixth export cannot reach fixture children unstripped. The Capy skill's
prose no longer offers re-typing the assignments as an alternative to sourcing.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy-install): the launch file is part of the ok verdict

Third adversarial pass: a passing run whose launch file could not be
written still said gstack: ok with exit 0 and printed only the GSTACK_LAUNCH
line, leaving a shell-evaluating caller with the re-typing path the branch
exists to remove. The file is now written before the verdict is recorded;
a write failure on a passing run turns the run into
"gstack: fail launch-file — could not write the launch file <path>; fix: …
(LAUNCH_FILE_UNWRITABLE)" with exit 1 and an ok:false verdict, and every
failing run removes the launch file an earlier passing run left beside the
verdict. The two failure tests assert the new contract, the doctor-FAIL
tests assert no launch file is announced or left, and the docs, Capy skill
and CHANGELOG say the same.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): the env.value allowlist and placeholder rules see the normalized value too

An allowlisted value in NFC did not suppress a match on its NFKC-composed
form, and a value that is a placeholder only after normalization was still
searched for. Fixture nits from the third review cycle: fixed directory
string instead of process.cwd(), the fullwidth assertion guards its own
vacuity, a zero-width-padded value under the floor is pinned, and the
ratchet comment names the one kept export and the PATH case line.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(capy-install): LAUNCH_FILE_UNWRITABLE is a registered result code; a kept profile verdict keeps its launch file

Fourth review cycle. The new trailer code now has its row in
lib/result-codes-capy.ts (the owner module is at its size cap) and its
docs/troubleshooting.md anchor, so the install-side code scan resolves it.
A failing profile check (--for review-voice) that keeps a fuller ok verdict
for this HEAD no longer deletes that verdict's launch file: the fail branch
removes the file only when the run rewrites the verdict, and setup_failed
removes it too. The docs say "a failing run that rewrites the verdict",
and that an unwritable directory keeps both the old file and the old
verdict. A new test runs a passing install, then a failing profile check,
and asserts the file and the ok verdict both survive.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(redact): the normalized allowlist and placeholder rules each have an assertion that fails without them

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(capy-install): a setup failure after a passing install removes the earlier launch file

Fifth review cycle: setup_failed's cleanup had no assertion. The setup
failure test now installs once (launch file present), commits a new HEAD so
setup runs again, fails that setup, and asserts the launch file is gone and
the verdict says ok:false before the next run sets up again and ends ok.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: state-root, contributor reference and capy-install follow the launch-file contract (Step 14.5 audit)

The /document-release audit (docs-3449-1792992068) corrected three authored
docs: docs/state-root.md shows the GSTACK_LAUNCH_FILE line and says to apply
it with ". <that file>" instead of prefixing or exporting the assignments;
docs/CONTRIBUTOR_REFERENCE.md names scan()'s one ambient input instead of
calling it pure; docs/capy-install.md's bun-shim bullet and exit table name
the launch file and the fail launch-file trailer.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 12:00:20 +00:00

302 lines
21 KiB
TypeScript

import { test, expect } from 'bun:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import http from 'node:http';
import net from 'node:net';
import { randomUUID } from 'node:crypto';
import { runAgentSdkTest, toSkillTestResult, passThroughNonAskUserQuestion } from './helpers/agent-sdk-runner';
import { runRecordedOfficeHoursAttempt, OFFICE_HOURS_BUN_GRACE_MS } from './helpers/office-hours-attempt';
import { publicEvents, redactPublicValue, setupGbrainRemoteAnswer } from './helpers/setup-gbrain-sandbox';
import { buildSetupGbrainFixture } from './helpers/setup-gbrain-fixture';
import { resolveEvalModel } from '../lib/eval-model';
const source = fs.readFileSync(path.join(import.meta.dir, 'skill-e2e-setup-gbrain-remote.test.ts'), 'utf8');
type Mode = 'success' | 'max-turns' | 'missing-registration' | 'missing-mode' | 'leaked-token' | 'public-auth-error'
| 'returned-api-error' | 'returned-execution-error' | 'returned-budget-error'
| 'final-retain-error' | 'failed-retain-error' | 'cleanup-error' | 'close-error'
| 'sdk-error' | 'deadline' | 'slow-setup' | 'rate-limit' | 'late' | 'owned-http';
// Captured Step 5a question (census 36641820398, slice 20): the old actor matched
// "skip" in the decline label and declined the registration the case asserts.
const REGISTER_MCP_QUESTION = { header: 'Register MCP', multiSelect: false,
question: 'Give Claude Code a typed tool surface for gbrain? This registers `gbrain` as a user-scope HTTP MCP at http://127.0.0.1:1/mcp with the bearer token, replacing any prior `gbrain` MCP registration.',
options: [{ label: 'Yes (Recommended)', description: 'Run `claude mcp remove gbrain` then `claude mcp add --scope user --transport http gbrain <URL>`.' },
{ label: 'No, skip registration', description: "Leave Claude Code's MCP config untouched." }] };
test('Path 4 actor registers the MCP at Step 5a and declines every other gate', () => {
expect(setupGbrainRemoteAnswer(REGISTER_MCP_QUESTION)).toBe('Yes (Recommended)');
expect(setupGbrainRemoteAnswer({ header: 'MCP', question: 'Register gbrain as a Claude Code MCP server?',
options: [{ label: 'Skip for now' }, { label: 'Register' }] })).toBe('Register');
expect(setupGbrainRemoteAnswer({ question: 'Remote setup privacy gate', options: [{ label: 'Proceed (Recommended)' }, { label: 'Decline' }] })).toBe('Decline');
expect(setupGbrainRemoteAnswer({ header: 'Artifacts', question: 'Provision a private artifacts repo for gbrain?',
options: [{ label: 'Yes (Recommended)' }, { label: 'No thanks' }] })).toBe('No thanks');
expect(setupGbrainRemoteAnswer({ header: 'Repo policy', question: 'How should gbrain treat this remote when importing via MCP?',
options: [{ label: 'read-write (Recommended)' }, { label: 'read-only' }, { label: 'skip-for-now' }] })).toBe('skip-for-now');
expect(setupGbrainRemoteAnswer({ question: 'Pick a mode', options: [{ label: 'A' }, { label: 'B' }] })).toBe('B');
});
async function fixture(modes: Mode[], budget = 300_000, pathApi = path) {
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'remote-caller-evidence-'));
const callbacks: Array<() => Promise<void>> = [], finalizers: Array<() => Promise<void>> = [];
const rows: any[] = [], calls: any[] = [], publicTrace: string[] = [], homes: string[] = [], observedInputs: any[] = [];
const sockets: net.Socket[] = [];
let callIndex = 0, queryIndex = 0, releaseLate: (() => void) | undefined;
let lateDecision: Promise<unknown> | undefined, serverCloses = 0;
const privateBlock = { type: 'thinking', get thinking(): never { throw new Error('private thinking read'); }, get signature(): never { throw new Error('private signature read'); } };
const fakeQuery = (input: any) => {
const mode = modes[callIndex]!, ordinal = ++queryIndex;
observedInputs.push(input);
publicTrace.push(`query:${callIndex}:${ordinal}`);
let rejectPending: ((error: unknown) => void) | undefined, closed = false;
const source = (async function* () {
yield { type: 'system', subtype: 'init', session_id: `public-session-${callIndex}-${ordinal}`, cwd: input.options.cwd,
model: 'synthetic', tools: input.options.tools, claude_code_version: 'synthetic',
get apiKeySource(): never { throw new Error('private init credential field read'); } };
if ((mode === 'rate-limit' || mode === 'close-error') && ordinal === 1) throw Object.assign(new Error('429 synthetic rate limit'), { status: 429 });
yield { type: 'assistant', session_id: 'synthetic', message: { id: 'message', role: 'assistant', content: [privateBlock,
{ type: 'text', text: `Public stage ${input.options.env.GBRAIN_MCP_TOKEN}` }] } };
if (['sdk-error', 'failed-retain-error', 'cleanup-error', 'close-error'].includes(mode)) throw new Error(`Claude Code process exited with code 143 ${input.options.env.GBRAIN_MCP_TOKEN}`);
if (mode === 'deadline') await new Promise<void>((_resolve, reject) => { rejectPending = reject; });
if (mode === 'late') {
await new Promise<void>(resolve => { releaseLate = resolve; });
lateDecision = input.options.canUseTool('Write', { file_path: path.join(input.options.cwd, 'CLAUDE.md') }, {});
await lateDecision.catch(() => { publicTrace.push('late-permission-rejected'); });
yield { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'late first attempt text' }] } };
return;
}
const question = 'Remote setup privacy gate';
const decision = await input.options.canUseTool('AskUserQuestion', { questions: [{ question,
options: [{ label: 'Proceed' }, { label: 'Decline' }] }] }, {});
expect(decision.updatedInput.answers[question]).toBe('Decline');
const register = REGISTER_MCP_QUESTION.question;
const registration = await input.options.canUseTool('AskUserQuestion', { questions: [REGISTER_MCP_QUESTION] }, {});
expect(registration.updatedInput.answers[register]).toBe('Yes (Recommended)');
expect(await input.options.canUseTool('Read', { file_path: 'fixture' }, {})).toEqual({ behavior: 'allow', updatedInput: { file_path: 'fixture' } });
const url = /Use this MCP URL: (http:\/\/[^ ]+)\./.exec(input.prompt)![1]!;
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"jsonrpc":"2.0","id":1,"method":"initialize"}' });
expect(response.status).toBe(200); await response.text();
if (mode === 'owned-http') {
const socket = net.createConnection({ host: '127.0.0.1', port: Number(new URL(url).port) });
sockets.push(socket); socket.on('error', () => {});
await new Promise<void>(resolve => socket.once('connect', resolve));
}
const bin = input.options.env.PATH.split(pathApi.delimiter)[0];
if (mode !== 'missing-registration') fs.writeFileSync(path.join(bin, 'claude-calls.log'), 'claude mcp add --transport http gbrain\n');
fs.writeFileSync(path.join(input.options.cwd, 'CLAUDE.md'), mode === 'leaked-token' ? input.options.env.GBRAIN_MCP_TOKEN : mode === 'missing-mode' ? '# Test project\n' : '# Test project\nMode: remote-http\n');
yield { type: 'assistant', session_id: 'synthetic', message: { id: 'final', role: 'assistant', content: [privateBlock,
...(mode === 'public-auth-error' ? [{ type: 'tool_use', id: 'public-error', name: 'Read', input: { error_class: 'AUTH' } }] : [{ type: 'text', text: 'Remote setup complete.' }])] } };
const subtype = mode === 'max-turns' ? 'error_max_turns' : mode === 'returned-api-error' ? 'error_api'
: mode === 'returned-execution-error' ? 'error_during_execution' : mode === 'returned-budget-error' ? 'error_max_budget_usd' : 'success';
yield { type: 'result', subtype, num_turns: 2, total_cost_usd: 0.12,
...(mode === 'returned-execution-error' ? { errors: [`Claude Code process exited with code 143 ${input.options.env.GBRAIN_MCP_TOKEN}`] } : {}) };
})();
return Object.assign(source, { close() {
if (closed) return;
if (mode === 'close-error' && ordinal === 1) { publicTrace.push(`close-error:${callIndex}:${ordinal}`); throw new Error('first owned SDK close failed'); }
closed = true;
publicTrace.push(`close:${callIndex}:${ordinal}`);
rejectPending?.(new Error('owned query closed'));
} });
};
const httpAdapter = { ...http, createServer: (...args: any[]) => {
const server = (http.createServer as any)(...args) as http.Server;
const close = server.closeAllConnections.bind(server);
server.closeAllConnections = () => { serverCloses++; publicTrace.push(`http-close:${callIndex}`); close();
if (modes[callIndex] === 'cleanup-error') throw new Error('owned HTTP cleanup failed after closing'); };
return server;
} };
function executable(text: string) {
for (const declaration of text.matchAll(/^import[\s\S]*?;\n/gm)) text = text.replace(declaration[0], '');
return new Bun.Transpiler({ loader: 'ts', target: 'bun' }).transformSync(text.replaceAll('import.meta.dir', JSON.stringify(import.meta.dir)));
}
const fsAdapter = { ...fs, renameSync: (from: string, to: string) => {
if (path.basename(to) === 'evidence.json') {
const stage = JSON.parse(fs.readFileSync(from, 'utf8')).stage;
if (modes[callIndex] === 'final-retain-error' && stage === 'passed') throw new Error('final successful evidence persistence failed');
if (modes[callIndex] === 'failed-retain-error' && stage === 'failed') throw new Error('failed evidence persistence failed');
}
return fs.renameSync(from, to);
} };
const bindings = {
test: (name: string, callback: () => Promise<void>, timeout: number) => {
expect(name).toBe('verifies, registers HTTP MCP, never writes token to CLAUDE.md');
expect(timeout).toBe(budget + OFFICE_HOURS_BUN_GRACE_MS); callbacks.push(callback);
}, expect, afterAll: (cb: () => Promise<void>) => finalizers.push(cb), query: fakeQuery, randomUUID,
CAPTURE_MS: budget, describeE2ETier: () => (_name: string, cb: () => void) => cb(), e2eTierEnabled: () => true,
fs: fsAdapter, os, path: pathApi, http: httpAdapter, runAgentSdkTest: (opts: any) => {
calls.push(opts); homes.push(opts.workingDirectory);
expect(opts.maxTurns).toBe(25); expect(opts.maxRetries).toBe(3); expect(opts.signal).toBeInstanceOf(AbortSignal);
expect(opts.allowedTools).toEqual(['Read', 'Grep', 'Glob', 'Bash', 'Write', 'Edit']);
expect(opts.userPrompt).toContain('Walk through Steps 4a, 4b, 4c, 5a, 8, 10 ONLY.');
return runAgentSdkTest(opts);
}, toSkillTestResult, passThroughNonAskUserQuestion, resolveClaudeBinary: () => '/not-executed/injected-query',
runRecordedOfficeHoursAttempt, OFFICE_HOURS_BUN_GRACE_MS, publicEvents, redactPublicValue, setupGbrainRemoteAnswer, resolveEvalModel,
EvalCollector: class { addTest(row: any) { rows.push({ ...row, attempt: rows.length + 1 }); } async finalize() {} },
// Inject only the three environment inputs read by the extracted paid callback.
process: { env: { GSTACK_EVAL_DIR: evidenceRoot, PATH: process.env.PATH, EVALS_MODEL: process.env.EVALS_MODEL } },
buildSetupGbrainFixture: (sections: string[]) => {
if (modes[callIndex] === 'slow-setup') Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, budget + 20);
return buildSetupGbrainFixture(sections);
},
console: { error: (message: string) => { expect(message).not.toContain('gbrain_TEST_TOKEN_THAT_MUST_NEVER_LEAK_84613'); } },
};
new Function(...Object.keys(bindings), executable(source))(...Object.values(bindings));
expect(callbacks).toHaveLength(1);
const errors: unknown[] = [], evidence: any[] = [];
try {
for (callIndex = 0; callIndex < modes.length; callIndex++) {
try { await callbacks[0]!(); errors.push(undefined); } catch (error) { errors.push(error); }
const dirs = fs.readdirSync(evidenceRoot).sort();
const all = dirs.map(dir => ({ dir, data: JSON.parse(fs.readFileSync(path.join(evidenceRoot, dir, 'evidence.json'), 'utf8')) }));
const current = all.find(x => !evidence.some(e => e.attemptId === x.data.attemptId))!;
evidence.push(current.data);
expect(current.data.stage).toBe(modes[callIndex] === 'failed-retain-error' ? 'cleanup-complete' : errors.at(-1) ? 'failed' : 'passed');
expect(current.data.stages.some((s: any) => s.stage === 'cleanup-complete')).toBe(true);
expect(current.data.sdkCancelled).toBe(true);
if (current.data.fixture.gstackHome) expect(fs.existsSync(current.data.fixture.gstackHome)).toBe(false);
if (current.data.fixture.fakeBinDir) expect(fs.existsSync(current.data.fixture.fakeBinDir)).toBe(false);
const serialized = JSON.stringify(current.data);
expect(serialized).not.toContain('gbrain_TEST_TOKEN_THAT_MUST_NEVER_LEAK_84613');
expect(serialized).not.toContain('thinking'); expect(serialized).not.toContain('apiKeySource');
expect(serialized).not.toContain('signature');
}
if (releaseLate) {
const before = fs.readdirSync(evidenceRoot).map(dir => fs.readFileSync(path.join(evidenceRoot, dir, 'evidence.json'), 'utf8'));
releaseLate(); await new Promise(resolve => setTimeout(resolve, 20));
expect(publicTrace).toContain('late-permission-rejected');
expect(fs.readdirSync(evidenceRoot).map(dir => fs.readFileSync(path.join(evidenceRoot, dir, 'evidence.json'), 'utf8'))).toEqual(before);
}
for (const cb of finalizers) await cb();
expect(rows).toHaveLength(modes.length);
expect(JSON.stringify(rows)).not.toContain('gbrain_TEST_TOKEN_THAT_MUST_NEVER_LEAK_84613');
expect(JSON.stringify(rows)).not.toContain('thinking');
return { rows, errors, evidence, calls, homes, publicTrace, observedInputs, serverCloses, sockets };
} finally {
releaseLate?.();
sockets.forEach(socket => socket.destroy());
fs.rmSync(evidenceRoot, { recursive: true, force: true });
}
}
test('remote caller preserves successful and max-turns-with-artifacts outcomes and public-only evidence', async () => {
const result = await fixture(['success', 'max-turns']);
expect(result.errors).toEqual([undefined, undefined]);
expect(result.rows.map(r => [r.passed, r.attempt, r.cost_usd])).toEqual([[true, 1, 0.12], [true, 2, 0.12]]);
expect(result.rows[1].exit_reason).toBe('error_max_turns');
expect(result.homes[0]).not.toBe(result.homes[1]);
expect(result.evidence.every(e => e.fixture.claudeCalls.includes('mcp add'))).toBe(true);
});
test('remote caller builds a Windows-delimited PATH that reaches its owned registration log', async () => {
const result = await fixture(['success'], 300_000, { ...path, delimiter: ';' });
expect(result.errors).toEqual([undefined]);
expect(result.rows[0].passed).toBe(true);
expect(result.evidence[0].fixture.claudeCalls).toContain('mcp add');
const entries = result.observedInputs[0].options.env.PATH.split(';');
expect(entries[0]).toContain('setup-gbrain-remote-bin-');
expect(entries[1]).toBe(path.join(path.resolve(import.meta.dir, '..'), 'bin'));
});
test('all original remote registration, token, persistence and public error assertions remain required', async () => {
const result = await fixture(['missing-registration', 'missing-mode', 'leaked-token', 'public-auth-error']);
expect(result.errors.every(Boolean)).toBe(true);
expect(result.rows.every(r => !r.passed)).toBe(true);
});
test('SDK exception retains public progress and exact failure without invented usage', async () => {
const result = await fixture(['sdk-error']);
expect(result.rows[0].passed).toBe(false); expect(result.rows[0].error).toContain('143');
expect(result.rows[0].error).toContain('usage unavailable');
expect(result.evidence[0].events.some((event: any) => event.type === 'system')).toBe(true);
expect(result.evidence[0].events.some((event: any) => event.type === 'assistant')).toBe(true);
});
test('returned SDK failures cannot pass from completed artifacts and keep their source exit reasons', async () => {
const result = await fixture(['returned-api-error', 'returned-execution-error', 'returned-budget-error']);
const reasons = ['error_api', 'error_during_execution', 'error_max_budget_usd'];
for (const [index, reason] of reasons.entries()) {
expect(result.errors[index]).toBeDefined();
expect(result.rows[index].passed).toBe(false);
expect(result.rows[index].exit_reason).toBe(reason);
expect(result.rows[index].error).toContain(reason);
expect(result.rows[index].cost_usd).toBe(0.12);
expect(result.evidence[index].result.exitReason).toBe(reason);
expect(result.evidence[index].fixture.claudeCalls).toContain('mcp add --transport http');
expect(result.evidence[index].fixture.claudeMd).toContain('Mode: remote-http');
}
expect(result.rows[1].error).toContain('Claude Code process exited with code 143');
expect(result.evidence[1].failure).toContain('Claude Code process exited with code 143');
});
test('deadline closes the owned SDK query before force-closing fixture HTTP', async () => {
const result = await fixture(['deadline'], 30);
expect(result.rows[0].passed).toBe(false); expect(result.rows[0].exit_reason).toBe('timeout');
expect(result.publicTrace.indexOf('close:0:1')).toBeLessThan(result.publicTrace.indexOf('http-close:0'));
expect(result.evidence[0].queryCount).toBe(1); expect(result.evidence[0].querySettled).toBe(1);
});
test('synchronous fixture setup consumes the existing work deadline before SDK launch', async () => {
const result = await fixture(['slow-setup'], 20);
expect(result.rows[0].passed).toBe(false); expect(result.rows[0].exit_reason).toBe('timeout');
expect(result.calls).toHaveLength(0); expect(result.evidence[0].queryCount).toBe(0);
});
test('existing SDK429 retry policy stays three and closes the prior query before retry', async () => {
const result = await fixture(['rate-limit']);
expect(result.errors).toEqual([undefined]); expect(result.calls[0].maxRetries).toBe(3);
expect(result.evidence[0].queryCount).toBe(2); expect(result.evidence[0].querySettled).toBe(2);
expect(result.publicTrace.indexOf('close:0:1')).toBeLessThan(result.publicTrace.indexOf('query:0:2'));
});
test('late first completion cannot approve tools or overwrite evidence after a fresh second invocation', async () => {
// The budget bounds the second, successful attempt too (stub server, two mkdtemps, the fake SDK
// run with its HTTP fetch). At 250ms it failed 4 of 4 times on windows-latest once the shard
// pack moved this file next to heavier neighbours (#3449, run 40263984171); 2s keeps the first
// attempt late and leaves the second one room on a loaded runner.
const result = await fixture(['late', 'success'], 2_000);
expect(result.rows.map(r => [r.passed, r.attempt])).toEqual([[false, 1], [true, 2]]);
expect(result.evidence[0].querySettled).toBe(0);
expect(result.evidence[0].attemptId).not.toBe(result.evidence[1].attemptId);
expect(result.homes[0]).not.toBe(result.homes[1]);
}, 20_000);
test('HTTP finalization closes only connections belonging to the owned fixture server', async () => {
const foreign = http.createServer((_req, res) => res.end('foreign'));
await new Promise<void>(resolve => foreign.listen(0, '127.0.0.1', resolve));
try {
const result = await fixture(['owned-http']);
expect(result.errors).toEqual([undefined]); expect(result.serverCloses).toBe(1);
expect(foreign.listening).toBe(true);
const address = foreign.address() as net.AddressInfo;
expect(await (await fetch(`http://127.0.0.1:${address.port}`)).text()).toBe('foreign');
} finally { await new Promise<void>(resolve => { foreign.close(() => resolve()); foreign.closeAllConnections(); }); }
});
test('final successful evidence persistence fails the matching record before PASS acceptance', async () => {
const result = await fixture(['final-retain-error']);
expect(result.errors[0]).toBeDefined(); expect(result.rows[0].passed).toBe(false);
expect(result.rows[0].error).toContain('final successful evidence persistence failed');
expect(result.evidence[0].stage).toBe('failed');
});
test('failure diagnostics and HTTP cleanup cannot replace the original SDK failure', async () => {
const result = await fixture(['failed-retain-error', 'cleanup-error']);
for (let i = 0; i < 2; i++) {
expect(result.rows[i].passed).toBe(false); expect(result.rows[i].error).toContain('143');
expect(String(result.errors[i])).toContain('143');
expect(result.evidence[i].failure).toContain('143');
}
expect(String(result.errors[0])).toContain('failed evidence persistence failed');
expect(String(result.errors[1])).toContain('owned HTTP cleanup failed');
expect(result.serverCloses).toBe(2);
});
test('throwing SDK close cannot skip the other query or owned HTTP cleanup', async () => {
const result = await fixture(['close-error']);
expect(result.rows[0].passed).toBe(false); expect(result.rows[0].error).toContain('143');
expect(result.publicTrace).toContain('close-error:0:1');
expect(result.publicTrace).toContain('close:0:2'); expect(result.publicTrace).toContain('http-close:0');
expect(result.evidence[0].cleanupErrors.join(' ')).toContain('first owned SDK close failed');
expect(result.evidence[0].queryCount).toBe(2); expect(result.serverCloses).toBe(1);
});