From f344421effcd0ee035056a19cde494e5c8b408a5 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 1 Aug 2026 01:54:19 -0600 Subject: [PATCH 1/2] refactor(harness): replace harness-name branches with capability rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LocalHarness` was a private three-member vocabulary that spelled one harness differently from the rest of the stack. It is now a narrowing of the shared `HarnessType`, so `'claude'` becomes `'claude-code'` and `materializerHarness()` — the alias that translated between the two — is deleted. `claude` stays the executable name, in the table's `command` field, reachable via the new `localHarnessExecutable()`. Eleven name branches become rows on a table: - Reasoning effort: `runWorktreeHarness` hard-refused any profile with `model.reasoningEffort` unless the harness was codex, and `harnessInvocation` silently dropped it. `HARNESS_INVOCATIONS` now carries a `reasoning` row per harness (`--effort` for claude-code, `--variant` for opencode, unchanged for codex), and the admission check reads the same rows so a refusal lands before any worktree exists. - Permission bypass: `dangerouslySkipPermissions` was tested against `'claude'` in four places; three were caller-side duplication of the fourth, which dropped the flag for every other harness with no error. Each harness declares its own bypass argv; codex gets `--dangerously-bypass-approvals-and-sandbox` where it previously got nothing. Reproducible Codex argv is byte-identical — its controlled config already pins `approval_policy="never"` with the sandbox intact, so the blanket flag is suppressed rather than layered on top. - System prompt: the four-arm `switch (plan.harness)` and its conflicting-argument guard collapse into one `HARNESS_SYSTEM_PROMPTS` row per harness. The guard's fail-open default for an unlisted harness is gone. - `harness === 'cli-base'` was re-derived at three call sites; it is now `harnessRunsAgent` / `agentHarness` in `src/runtime/harness-role.ts`. Deliberately kept, with the reason now written at each site: the `codexReproducible && harness !== 'codex'` guards (a codex-specific public option asserting caller self-consistency) and every `ExecutorConfig.backend` switch (a union tag naming the materialization contract, not a harness name). --- CHANGELOG.md | 33 ++++ bench/gen3-config.json | 2 +- bench/gen4-config.json | 2 +- bench/gen5-config.json | 2 +- bench/src/quant-arena/quant-loop.mts | 6 +- .../backfill-swe-arena.test.mts | 4 +- bench/src/swe-arena/activation.test.mts | 8 +- bench/src/swe-arena/gepa-seat.test.mts | 8 +- bench/src/swe-arena/outer-loop.mts | 16 +- bench/src/swe-arena/proposer-fanout.mts | 4 +- bench/src/swe-arena/proposer-fanout.test.mts | 54 +++--- bench/src/swe-arena/proposer-provenance.mts | 10 +- .../swe-arena/proposer-provenance.test.mts | 6 +- bench/src/swe-code-improve.mts | 4 +- docs/api/index.md | 6 +- docs/api/mcp.md | 76 +++++++- docs/api/primitive-catalog.md | 8 +- docs/api/runtime.md | 18 +- src/candidate-execution/system-prompt.ts | 169 +++++++++-------- src/improvement/agentic-generator.ts | 15 +- src/improvement/driver-loop-generator.test.ts | 2 +- src/improvement/driver-loop-generator.ts | 6 +- src/improvement/improve-types.ts | 2 +- src/mcp/bin-helpers.ts | 8 +- src/mcp/delegate-supervisor-provisioning.ts | 2 + src/mcp/detached-coder.ts | 3 + src/mcp/in-process-executor.ts | 6 +- src/mcp/index.ts | 4 + src/mcp/local-harness.ts | 171 ++++++++++++++---- src/mcp/worktree-harness.ts | 22 +-- src/runtime/harness-role.test.ts | 26 +++ src/runtime/harness-role.ts | 39 ++++ src/runtime/supervise/budget-floor.ts | 2 + src/runtime/supervise/runtime.ts | 3 +- src/runtime/supervise/supervise.ts | 11 +- src/runtime/supervise/supervisor-agent.ts | 8 +- .../supervise/worktree-cli-executor.ts | 2 + src/runtime/supervise/worktree-fanout.ts | 3 +- tests/candidate-execution-prepare.test.ts | 43 +++++ tests/kernel/supervise-convenience.test.ts | 2 +- tests/kernel/worktree-loop.test.ts | 6 +- tests/mcp/in-process-detect.test.ts | 8 +- tests/mcp/in-process-executor.test.ts | 21 ++- tests/mcp/local-harness.test.ts | 90 ++++++--- tests/mcp/worktree-harness.test.ts | 14 +- tests/runtime/worktree-cli-executor.test.ts | 24 +-- 46 files changed, 704 insertions(+), 275 deletions(-) create mode 100644 src/runtime/harness-role.test.ts create mode 100644 src/runtime/harness-role.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index db1b6eac..2b4442ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## Unreleased + +### No harness is special: eleven name branches become table rows + +BREAKING. `LocalHarness` was a private three-member vocabulary (`'claude' | 'codex' | 'opencode'`) that spelled one harness differently from every other layer in the stack. It is now a narrowing of the shared `HarnessType`: **`'claude'` is renamed to `'claude-code'`**. `claude` remains the EXECUTABLE name and lives only in the harness table's `command` field. + +Callers to update: `runLocalHarness({ harness })`, `harnessInvocation(harness, …)`, `runWorktreeHarness({ harness })`, `agenticGenerator({ harness })`, `driverLoopGenerator({ harness })`, `createInProcessExecutor({ harnesses })`, `AuthoredHarness.harness`, and the `AGENT_RUNTIME_LOCAL_HARNESSES` env list. Anything that passed `'claude'` passes `'claude-code'`; `codex` and `opencode` are unchanged. + +Deleting the alias removed `materializerHarness()` outright — a `LocalHarness` is now handed straight to the profile materializer with no translation. + +**Reasoning effort now reaches claude-code and opencode.** `runWorktreeHarness` used to hard-REFUSE any profile carrying `model.reasoningEffort` unless the harness was codex, and `harnessInvocation` silently dropped it. Both read one capability table now: + +- `claude-code` → `--effort `; canonical `ultracode` is native `max`. +- `opencode` → `--variant `; canonical `ultracode` is `max`. +- `codex` → `-c model_reasoning_effort="…"`, unchanged. + +A level a harness genuinely cannot express is still refused, and the refusal now lands in the pre-flight admission check (before any worktree exists) because the guard and the argv builder read the same rows. claude-code refuses `none` and `minimal` (its `--effort` has no such level); opencode refuses `none` (thinking-off is the absence of the flag). + +**Permission bypass is a property of the workspace, not of one CLI.** `dangerouslySkipPermissions` was tested against `'claude'` in four places; three were caller-side duplication of the fourth, which dropped the flag for every other harness with no error. Each harness now declares its own bypass argv: + +- `claude-code` → `--dangerously-skip-permissions` (unchanged). +- `codex` → `--dangerously-bypass-approvals-and-sandbox`. NEW: a codex worker in a disposable worktree previously had its bypass request silently dropped and could stall on an approval gate. +- `opencode` → nothing; `opencode run` has no approval gate. +- Reproducible Codex is unchanged: its controlled config already pins `approval_policy="never"` with the sandbox intact, so the blanket bypass flag is suppressed rather than layered on top. Reproducible argv is byte-identical to 0.118.0. + +**Other name branches replaced by rows, with no behaviour change:** + +- `projectCandidateSystemPrompt`'s four-arm `switch (plan.harness)` and its conflicting-argument guard are now one `HARNESS_SYSTEM_PROMPTS` row per harness (executable + projection + conflict predicate). The guard's fail-OPEN default for an unlisted harness is gone: no row means the projection is refused. +- `harness === 'cli-base'` was re-derived at three call sites; it is now `harnessRunsAgent` / `agentHarness` in `src/runtime/harness-role.ts`. +- New exports on `@tangle-network/agent-runtime/mcp`: `DEFAULT_LOCAL_HARNESS`, `LOCAL_HARNESSES`, `localHarnessExecutable`, `harnessSupportsReasoningEffort`. + +Deliberately KEPT: the `codexReproducible && harness !== 'codex'` guards (a codex-specific public option asserting caller self-consistency, not behaviour varying by name), and every `ExecutorConfig.backend` switch (a discriminated-union tag naming the materialization contract, not a harness name). Both now say so at the site. + ## 0.118.0 ### pi runs through the bridge, like every other harness diff --git a/bench/gen3-config.json b/bench/gen3-config.json index 65185eea..a627d840 100644 --- a/bench/gen3-config.json +++ b/bench/gen3-config.json @@ -41,7 +41,7 @@ "repsPerInstance": 2, "premeasuredBaselinePath": "/tmp/claude-1000/-home-drew-code-supervisor-lab/f06fd156-042a-4ef9-bd88-f2ec7f52b90c/scratchpad/hh/gen3/premeasured-baseline.json", "maxShots": 3, - "proposerHarness": "claude", + "proposerHarness": "claude-code", "proposerTimeoutMs": 2400000, "analystModels": [ "glm-5.2", diff --git a/bench/gen4-config.json b/bench/gen4-config.json index d52c99aa..718d8e45 100644 --- a/bench/gen4-config.json +++ b/bench/gen4-config.json @@ -41,7 +41,7 @@ "repsPerInstance": 2, "premeasuredBaselinePath": "/tmp/claude-1000/-home-drew-code-supervisor-lab/f06fd156-042a-4ef9-bd88-f2ec7f52b90c/scratchpad/hh/gen4/premeasured-baseline.json", "maxShots": 3, - "proposerHarness": "claude", + "proposerHarness": "claude-code", "proposerTimeoutMs": 2400000, "analystModels": [ "glm-5.2", diff --git a/bench/gen5-config.json b/bench/gen5-config.json index 2f4ebfdd..2e4ca876 100644 --- a/bench/gen5-config.json +++ b/bench/gen5-config.json @@ -41,7 +41,7 @@ "repsPerInstance": 2, "premeasuredBaselinePath": "/tmp/claude-1000/-home-drew-code-supervisor-lab/f06fd156-042a-4ef9-bd88-f2ec7f52b90c/scratchpad/hh/gen5/premeasured-baseline.json", "maxShots": 3, - "proposerHarness": "claude", + "proposerHarness": "claude-code", "proposerTimeoutMs": 2400000, "analystModels": [ "glm-5.2", diff --git a/bench/src/quant-arena/quant-loop.mts b/bench/src/quant-arena/quant-loop.mts index 27990c8b..e7f2518b 100644 --- a/bench/src/quant-arena/quant-loop.mts +++ b/bench/src/quant-arena/quant-loop.mts @@ -88,11 +88,11 @@ export const PINNED_BASELINES: Record = { /** The two demo author seats: the plain author and the quant lens. */ export function defaultQuantProposers(): ProposerSpec[] { return [ - { name: 'default-author', profile: 'default-author.profile.json', harness: 'claude' }, + { name: 'default-author', profile: 'default-author.profile.json', harness: 'claude-code' }, { name: 'quant-researcher', profile: join(QUANT_PROFILES_DIR, 'quant-researcher.profile.json'), - harness: 'claude', + harness: 'claude-code', lens: 'Favor ONE economically-motivated effect (trend, mean reversion, vol targeting) with few parameters. ' + 'State the regime in which it should work and keep turnover low enough that 15bps a side cannot eat the edge.', @@ -218,7 +218,7 @@ async function claudeShot(opts: { const res = await run('claude', argv, { stdin: opts.prompt, cwd: opts.cwd, - env: proposerShotEnv('claude'), + env: proposerShotEnv('claude-code'), timeoutMs: opts.timeoutMs, }) if (res.code !== 0) { diff --git a/bench/src/rollout-ledger/backfill-swe-arena.test.mts b/bench/src/rollout-ledger/backfill-swe-arena.test.mts index 2e78339f..e98d646c 100644 --- a/bench/src/rollout-ledger/backfill-swe-arena.test.mts +++ b/bench/src/rollout-ledger/backfill-swe-arena.test.mts @@ -158,7 +158,7 @@ async function buildFixtureTree(): Promise { candidateIndex: 0, shot: 1, maxShots: 3, - harness: 'claude', + harness: 'claude-code', model: null, promptSha256: 'sha256:abc', startedAt: '2026-07-22T19:27:18.350Z', @@ -256,7 +256,7 @@ describe('backfillSweArena', () => { expect(proposer?.messages).toHaveLength(2) expect(proposer?.outcome.reward).toBe(1) expect(proposer?.outcome.reward_source).toBe('swe-arena-official-judge/candidate-resolved-fraction') - expect(proposer?.policy).toMatchObject({ harness: 'claude', model: 'claude-fable-5' }) + expect(proposer?.policy).toMatchObject({ harness: 'claude-code', model: 'claude-fable-5' }) expect(proposer?.cost.tokens_out).toBe(462) expect(proposer?.task).toMatchObject({ suite: 'swe-arena-proposer', rep: 1 }) diff --git a/bench/src/swe-arena/activation.test.mts b/bench/src/swe-arena/activation.test.mts index 3c6ffa3f..33e182ed 100644 --- a/bench/src/swe-arena/activation.test.mts +++ b/bench/src/swe-arena/activation.test.mts @@ -245,9 +245,9 @@ describe('activation-predicate prefilter', () => { it('kills a candidate without .improve/activation.json (stage activation-predicate) and passes one WITH it', async () => { const proposers: ProposerSpec[] = [ - { name: 'with-predicate', harness: 'claude' }, - { name: 'without-predicate', harness: 'claude' }, - { name: 'invalid-predicate', harness: 'claude' }, + { name: 'with-predicate', harness: 'claude-code' }, + { name: 'without-predicate', harness: 'claude-code' }, + { name: 'invalid-predicate', harness: 'claude-code' }, ] const gen = fanOutLoopsGenerator(config(proposers), { author: async (proposer, args) => { @@ -284,7 +284,7 @@ describe('activation-predicate prefilter', () => { }) it('does not require a predicate when the gate is off (gen-4 behavior unchanged)', async () => { - const cfg = config([{ name: 'legacy', harness: 'claude' }]) + const cfg = config([{ name: 'legacy', harness: 'claude-code' }]) cfg.activationGate = false const gen = fanOutLoopsGenerator(cfg, { author: async (_p, args) => { diff --git a/bench/src/swe-arena/gepa-seat.test.mts b/bench/src/swe-arena/gepa-seat.test.mts index bb0545c5..7e8ea872 100644 --- a/bench/src/swe-arena/gepa-seat.test.mts +++ b/bench/src/swe-arena/gepa-seat.test.mts @@ -58,7 +58,7 @@ describe('validateGepaSeat', () => { expect(() => validateGepaSeat(seat())).not.toThrow() expect(() => validateGepaSeat(seat({ engine: 'omni', maxMetricCalls: 8 }))).not.toThrow() expect(isGepaSeat(seat())).toBe(true) - expect(isGepaSeat({ name: 'x', harness: 'claude' })).toBe(false) + expect(isGepaSeat({ name: 'x', harness: 'claude-code' })).toBe(false) }) it('requires a surface inside the declared change-space', () => { @@ -68,7 +68,7 @@ describe('validateGepaSeat', () => { }) it('rejects harness-seat fields on an engine seat instead of silently ignoring them', () => { - expect(() => validateGepaSeat(seat({ harness: 'claude' }))).toThrow(/'harness' belongs to harness-authored/) + expect(() => validateGepaSeat(seat({ harness: 'claude-code' }))).toThrow(/'harness' belongs to harness-authored/) expect(() => validateGepaSeat(seat({ merge: true }))).toThrow(/'merge'/) expect(() => validateGepaSeat(seat({ model: 'x' }))).toThrow(/'model'/) expect(() => validateGepaSeat(seat({ profile: 'p.json' }))).toThrow(/'profile'/) @@ -234,7 +234,7 @@ describe('captureProposerProvenance with a gepa seat', () => { return { code: 0, stdout: 'source', stderr: '' } } it('records engine, surface, gepa version, bridge module, and the python runtime as harnessVersion', async () => { - const record = await captureProposerProvenance([{ name: 'claude-author', harness: 'claude' }, seat()], { + const record = await captureProposerProvenance([{ name: 'claude-author', harness: 'claude-code' }, seat()], { exec: okExec, readSettingsModel: () => 'settings-model', }) @@ -251,7 +251,7 @@ describe('captureProposerProvenance with a gepa seat', () => { expect(gepa.harness).toBeUndefined() // The claude seat is untouched by the gepa capture path. expect(record.proposers.find((p) => p.name === 'claude-author')).toMatchObject({ - harness: 'claude', + harness: 'claude-code', settingsModel: 'settings-model', }) }) diff --git a/bench/src/swe-arena/outer-loop.mts b/bench/src/swe-arena/outer-loop.mts index 50f8649e..b7ebfe5f 100644 --- a/bench/src/swe-arena/outer-loop.mts +++ b/bench/src/swe-arena/outer-loop.mts @@ -646,7 +646,7 @@ export interface OuterLoopConfig { * budget.maxImprovementShots; the LIB owns the dial (capabilities.mts * fails loud on a substrate that would drop it). */ maxShots: number - proposerHarness: 'claude' | 'codex' | 'opencode' + proposerHarness: 'claude-code' | 'codex' | 'opencode' proposerTimeoutMs: number /** GEN-3 proposer fan-out: N proposers author candidates CONCURRENTLY, each * an AgentProfile-pinned harness invocation (see proposer-fanout.mts). @@ -759,7 +759,7 @@ export function defaultRound4Config( // bootstrap run writes it; the lib validates it on every consumption. premeasuredBaselinePath: join(hh, 'r4', 'premeasured-baseline.json'), maxShots: 3, - proposerHarness: 'claude', + proposerHarness: 'claude-code', // Per author SHOT (agenticGenerator timeoutMs). 20 min timed out 3× under // degraded capacity in gen-1 ("author shot timed out") — doubled to 40 min. proposerTimeoutMs: 2_400_000, @@ -858,18 +858,18 @@ export function defaultGen3Config( const base = defaultRound4Config(hh, opts) const outDirName = opts.outDirName ?? 'gen3' const proposers: ProposerSpec[] = [ - { name: 'default-author', profile: 'default-author.profile.json', harness: 'claude' }, + { name: 'default-author', profile: 'default-author.profile.json', harness: 'claude-code' }, { name: 'mechanics-author', profile: 'default-author.profile.json', - harness: 'claude', + harness: 'claude-code', diagnosisSlice: 'mechanics', lens: 'Focus on MECHANICS: worker lifecycle, sandbox/clone contracts, settlement and delivery paths. Prefer code-path fixes over prompt wording.', }, { name: 'prompts-author', profile: 'default-author.profile.json', - harness: 'claude', + harness: 'claude-code', diagnosisSlice: 'prompts', lens: 'Focus on PROMPTS: worker/brain instruction wording, placement guidance, self-check discipline. Prefer prompt/instruction changes over code-path rewrites.', }, @@ -945,10 +945,10 @@ export function defaultGen4Config( ): OuterLoopConfig { const base = defaultGen3Config(hh, { outDirName: opts.outDirName ?? 'gen4' }) const proposers: ProposerSpec[] = [ - { name: 'claude-author', profile: 'default-author.profile.json', harness: 'claude' }, + { name: 'claude-author', profile: 'default-author.profile.json', harness: 'claude-code' }, { name: 'glm-author', harness: 'opencode', model: 'zai-coding-plan/glm-5.2' }, ...(opts.includeCodex === false ? [] : [{ name: 'codex-author', harness: 'codex' } satisfies ProposerSpec]), - { name: 'merge-author', profile: 'default-author.profile.json', harness: 'claude', merge: true }, + { name: 'merge-author', profile: 'default-author.profile.json', harness: 'claude-code', merge: true }, ] return { ...base, @@ -1263,7 +1263,7 @@ const CODEX_AMBIENT_AUTH_VARS = ['OPENAI_API_KEY', 'OPENAI_BASE_URL'] as const export function proposerShotEnv(harness: OuterLoopConfig['proposerHarness']): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env } - if (harness === 'claude') { + if (harness === 'claude-code') { for (const name of CLAUDE_AMBIENT_AUTH_VARS) delete env[name] } if (harness === 'codex') { diff --git a/bench/src/swe-arena/proposer-fanout.mts b/bench/src/swe-arena/proposer-fanout.mts index 74367403..d1f77f2f 100644 --- a/bench/src/swe-arena/proposer-fanout.mts +++ b/bench/src/swe-arena/proposer-fanout.mts @@ -91,7 +91,7 @@ export interface ProposerSpec { profile?: string /** Required for harness-authored seats. Absent on an engine seat * (`engine` set) — enforced both ways at generator construction. */ - harness?: 'claude' | 'codex' | 'opencode' + harness?: 'claude-code' | 'codex' | 'opencode' /** GEN-4 pinned model id, threaded to the harness CLI as `-m ` via * the author profile's `model.default` (harnessInvocation maps it for all * three harnesses). Unset = the CLI's own resolved model (its login/settings @@ -201,7 +201,7 @@ export function loadAuthorProfile(spec: ProposerSpec): AgentProfile | undefined /** The gen-2 author, codified: one bare-profile claude proposer. */ export function defaultProposers(): ProposerSpec[] { - return [{ name: 'default-author', profile: 'default-author.profile.json', harness: 'claude' }] + return [{ name: 'default-author', profile: 'default-author.profile.json', harness: 'claude-code' }] } /** The profile the author shot actually runs: the loaded profile (if any) with diff --git a/bench/src/swe-arena/proposer-fanout.test.mts b/bench/src/swe-arena/proposer-fanout.test.mts index 71a6e0e0..33cc8b80 100644 --- a/bench/src/swe-arena/proposer-fanout.test.mts +++ b/bench/src/swe-arena/proposer-fanout.test.mts @@ -73,7 +73,7 @@ describe('sliceFindings', () => { describe('proposerBuildPrompt', () => { it('appends the lens AFTER the shared protocol prompt, leaving the change-space text intact', () => { - const spec: ProposerSpec = { name: 'x', harness: 'claude', lens: 'Prefer code-path fixes.' } + const spec: ProposerSpec = { name: 'x', harness: 'claude-code', lens: 'Prefer code-path fixes.' } const prompt = proposerBuildPrompt({ findings: [] }, spec) expect(prompt).toContain('DECLARED CHANGE-SPACE') expect(prompt.indexOf('DECLARED CHANGE-SPACE')).toBeLessThan(prompt.indexOf('YOUR AUTHORING LENS (x)')) @@ -81,7 +81,7 @@ describe('proposerBuildPrompt', () => { }) it('is the bare round prompt without a lens', () => { - const spec: ProposerSpec = { name: 'x', harness: 'claude' } + const spec: ProposerSpec = { name: 'x', harness: 'claude-code' } expect(proposerBuildPrompt({ findings: [] }, spec)).not.toContain('AUTHORING LENS') }) }) @@ -111,7 +111,7 @@ describe('resolveAuthorProfile (pinned models)', () => { const spec: ProposerSpec = { name: 'x', profile: 'default-author.profile.json', - harness: 'claude', + harness: 'claude-code', model: 'claude-fable-5', } const profile = resolveAuthorProfile(spec) @@ -126,15 +126,15 @@ describe('resolveAuthorProfile (pinned models)', () => { }) it('is byte-identical to loadAuthorProfile without a pin (gen-3 seats unchanged)', () => { - const spec: ProposerSpec = { name: 'x', profile: 'default-author.profile.json', harness: 'claude' } + const spec: ProposerSpec = { name: 'x', profile: 'default-author.profile.json', harness: 'claude-code' } expect(resolveAuthorProfile(spec)).toEqual(loadAuthorProfile(spec)) - expect(resolveAuthorProfile({ name: 'bare', harness: 'claude' })).toBeUndefined() + expect(resolveAuthorProfile({ name: 'bare', harness: 'claude-code' })).toBeUndefined() }) }) describe('proposerBuildPrompt with pareto parents', () => { it('appends the parents section (evidence + diffs) after the protocol prompt and lens', () => { - const spec: ProposerSpec = { name: 'x', harness: 'claude', lens: 'Prefer code-path fixes.' } + const spec: ProposerSpec = { name: 'x', harness: 'claude-code', lens: 'Prefer code-path fixes.' } const prompt = proposerBuildPrompt({ findings: [] }, spec, PARENTS) expect(prompt).toContain('DECLARED CHANGE-SPACE') expect(prompt).toContain('PARETO PARENTS') @@ -146,14 +146,14 @@ describe('proposerBuildPrompt with pareto parents', () => { }) it('leaves the prompt untouched when no parents are seeded (gen-3 behavior)', () => { - const spec: ProposerSpec = { name: 'x', harness: 'claude' } + const spec: ProposerSpec = { name: 'x', harness: 'claude-code' } expect(proposerBuildPrompt({ findings: [] }, spec)).not.toContain('PARETO PARENTS') expect(parentsPromptSection(PARENTS)).toContain('measured evidence') }) }) describe('mergeAuthorPrompt', () => { - const spec: ProposerSpec = { name: 'merge-author', harness: 'claude', merge: true } + const spec: ProposerSpec = { name: 'merge-author', harness: 'claude-code', merge: true } it('keeps the change-space contract and presents BOTH parent diffs with the coherent-union task', () => { const prompt = proposerBuildPrompt({ findings: [] }, spec, PARENTS) @@ -182,11 +182,11 @@ describe('defaultGen4Config', () => { expect(config.proposers).toHaveLength(4) expect(config.populationSize).toBe(4) const byName = Object.fromEntries(config.proposers!.map((p) => [p.name, p])) - expect(byName['claude-author']).toMatchObject({ harness: 'claude', profile: 'default-author.profile.json' }) + expect(byName['claude-author']).toMatchObject({ harness: 'claude-code', profile: 'default-author.profile.json' }) expect(byName['claude-author']!.model).toBeUndefined() expect(byName['glm-author']).toMatchObject({ harness: 'opencode', model: 'zai-coding-plan/glm-5.2' }) expect(byName['codex-author']).toMatchObject({ harness: 'codex' }) - expect(byName['merge-author']).toMatchObject({ harness: 'claude', merge: true }) + expect(byName['merge-author']).toMatchObject({ harness: 'claude-code', merge: true }) }) it('drops the codex seat (and shrinks the population) when includeCodex is false', () => { @@ -216,7 +216,7 @@ describe('defaultGen4Config', () => { describe('loadAuthorProfile', () => { it('loads the committed default-author profile (bare: no prompt, no model)', () => { - const profile = loadAuthorProfile({ name: 'a', profile: 'default-author.profile.json', harness: 'claude' }) + const profile = loadAuthorProfile({ name: 'a', profile: 'default-author.profile.json', harness: 'claude-code' }) expect(profile?.name).toBe('swe-arena-default-author') expect(profile?.prompt).toBeUndefined() expect(profile?.model).toBeUndefined() @@ -224,7 +224,7 @@ describe('loadAuthorProfile', () => { }) it('returns undefined without a profile path', () => { - expect(loadAuthorProfile({ name: 'a', harness: 'claude' })).toBeUndefined() + expect(loadAuthorProfile({ name: 'a', harness: 'claude-code' })).toBeUndefined() }) it('fails loud when a non-codex proposer declares profile resources (they would be dropped)', async () => { @@ -232,7 +232,7 @@ describe('loadAuthorProfile', () => { try { const path = join(dir, 'with-resources.json') await writeFile(path, JSON.stringify({ name: 'r', resources: { files: [] } })) - expect(() => loadAuthorProfile({ name: 'a', profile: path, harness: 'claude' })).toThrow(/silently drop/) + expect(() => loadAuthorProfile({ name: 'a', profile: path, harness: 'claude-code' })).toThrow(/silently drop/) } finally { await rm(dir, { recursive: true, force: true }) } @@ -302,7 +302,7 @@ describe('defaultGen3Config', () => { it('defaultProposers codifies the gen-2 author: one bare-profile claude entry', () => { expect(defaultProposers()).toEqual([ - { name: 'default-author', profile: 'default-author.profile.json', harness: 'claude' }, + { name: 'default-author', profile: 'default-author.profile.json', harness: 'claude-code' }, ]) }) }) @@ -357,8 +357,8 @@ describe('fanOutLoopsGenerator', () => { it('authors ALL proposers concurrently in separate worktrees and applies each patch to its candidate slot', async () => { const proposers: ProposerSpec[] = [ - { name: 'alpha', harness: 'claude' }, - { name: 'beta', harness: 'claude' }, + { name: 'alpha', harness: 'claude-code' }, + { name: 'beta', harness: 'claude-code' }, ] let inFlight = 0 let maxInFlight = 0 @@ -401,8 +401,8 @@ describe('fanOutLoopsGenerator', () => { it('kills a candidate at the smoke pre-filter: applied=false, no patch applied, kill recorded with reason', async () => { const proposers: ProposerSpec[] = [ - { name: 'good', harness: 'claude' }, - { name: 'bad', harness: 'claude' }, + { name: 'good', harness: 'claude-code' }, + { name: 'bad', harness: 'claude-code' }, ] const config = baseConfig(proposers) config.prefilter = { enabled: true, smokeInstance: 'cheapest-of-set' } @@ -455,7 +455,7 @@ describe('fanOutLoopsGenerator', () => { }) it('kills an out-of-space diff at the change-space pre-filter before any smoke spend', async () => { - const proposers: ProposerSpec[] = [{ name: 'rogue', harness: 'claude' }] + const proposers: ProposerSpec[] = [{ name: 'rogue', harness: 'claude-code' }] const config = baseConfig(proposers) config.prefilter = { enabled: true, smokeInstance: 'cheapest-of-set' } let smokeRan = false @@ -482,7 +482,7 @@ describe('fanOutLoopsGenerator', () => { }) it('returns applied:false without a kill when a proposer authors nothing', async () => { - const gen = fanOutLoopsGenerator(baseConfig([{ name: 'idle', harness: 'claude' }]), { + const gen = fanOutLoopsGenerator(baseConfig([{ name: 'idle', harness: 'claude-code' }]), { author: async () => ({ applied: false, summary: '' }), }) expect((await gen.generate(generatorArgs(0))).applied).toBe(false) @@ -490,7 +490,7 @@ describe('fanOutLoopsGenerator', () => { }) it('fails loud when candidateIndex exceeds the proposer list (populationSize drift)', async () => { - const gen = fanOutLoopsGenerator(baseConfig([{ name: 'only', harness: 'claude' }]), { + const gen = fanOutLoopsGenerator(baseConfig([{ name: 'only', harness: 'claude-code' }]), { author: async () => ({ applied: false, summary: '' }), }) await expect(gen.generate(generatorArgs(1))).rejects.toThrow(/populationSize must equal/) @@ -514,7 +514,7 @@ describe('fanOutLoopsGenerator', () => { }) it('refuses a merge seat without >=2 materialized parents', () => { - const config = baseConfig([{ name: 'merge-author', harness: 'claude', merge: true }]) + const config = baseConfig([{ name: 'merge-author', harness: 'claude-code', merge: true }]) expect(() => fanOutLoopsGenerator(config, { author: async () => ({ applied: false, summary: '' }) })).toThrow( /merge proposer/, ) @@ -533,8 +533,8 @@ describe('fanOutLoopsGenerator', () => { it('rejects duplicate proposer names and empty proposer lists', () => { expect(() => fanOutLoopsGenerator(baseConfig([ - { name: 'dup', harness: 'claude' }, - { name: 'dup', harness: 'claude' }, + { name: 'dup', harness: 'claude-code' }, + { name: 'dup', harness: 'claude-code' }, ])), ).toThrow(/duplicate/) expect(() => fanOutLoopsGenerator({ ...defaultRound4Config(), loopsRepo, outDir })).toThrow(/empty/) @@ -585,7 +585,7 @@ describe('gen-5 prompt sections', () => { } it('appends EVIDENCE MAP + briefing + activation contract after the protocol prompt', () => { - const spec: ProposerSpec = { name: 'x', harness: 'claude' } + const spec: ProposerSpec = { name: 'x', harness: 'claude-code' } const prompt = proposerBuildPrompt({ findings: [] }, spec, [], { briefing, activationGate: true, @@ -598,7 +598,7 @@ describe('gen-5 prompt sections', () => { }) it('the merge seat gets the gen-5 sections too', () => { - const spec: ProposerSpec = { name: 'merge-author', harness: 'claude', merge: true } + const spec: ProposerSpec = { name: 'merge-author', harness: 'claude-code', merge: true } const prompt = proposerBuildPrompt({ findings: [] }, spec, PARENTS, { briefing, activationGate: true, @@ -609,7 +609,7 @@ describe('gen-5 prompt sections', () => { }) it('leaves gen-3/gen-4 prompts byte-identical when no extras are passed', () => { - const spec: ProposerSpec = { name: 'x', harness: 'claude' } + const spec: ProposerSpec = { name: 'x', harness: 'claude-code' } const legacy = proposerBuildPrompt({ findings: [] }, spec, PARENTS) expect(legacy).not.toContain('EVIDENCE MAP') expect(legacy).not.toContain('ACTIVATION PREDICATE') diff --git a/bench/src/swe-arena/proposer-provenance.mts b/bench/src/swe-arena/proposer-provenance.mts index 94f20d45..0a2bc94f 100644 --- a/bench/src/swe-arena/proposer-provenance.mts +++ b/bench/src/swe-arena/proposer-provenance.mts @@ -24,6 +24,7 @@ import { readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' +import { localHarnessExecutable } from '@tangle-network/agent-runtime/mcp' import { DEFAULT_GEPA_PYTHON, isGepaSeat, @@ -117,10 +118,13 @@ export async function captureProposerProvenance( (h): h is NonNullable => h !== undefined, ) for (const harness of harnesses) { - const res = await exec(harness, ['--version']) + // The harness id is not the binary name (`claude-code` runs `claude`); read the executable + // from the runtime's harness table rather than spawning the id. + const executable = localHarnessExecutable(harness) + const res = await exec(executable, ['--version']) if (res.code !== 0) { throw new Error( - `proposer provenance: '${harness} --version' failed (rc=${res.code}) — the ${harness} seat cannot author. ` + + `proposer provenance: '${executable} --version' failed (rc=${res.code}) — the ${harness} seat cannot author. ` + `stderr: ${res.stderr.slice(0, 300)}`, ) } @@ -163,7 +167,7 @@ export async function captureProposerProvenance( harness: spec.harness, pinnedModel: spec.model ?? null, harnessVersion: versionByHarness.get(spec.harness!)!, - settingsModel: spec.harness === 'claude' && !spec.model ? readSettingsModel() : null, + settingsModel: spec.harness === 'claude-code' && !spec.model ? readSettingsModel() : null, authStatus: (spec.harness !== undefined ? authByHarness.get(spec.harness) : undefined) ?? null, merge: spec.merge === true, } diff --git a/bench/src/swe-arena/proposer-provenance.test.mts b/bench/src/swe-arena/proposer-provenance.test.mts index 6049b14c..8b8dab63 100644 --- a/bench/src/swe-arena/proposer-provenance.test.mts +++ b/bench/src/swe-arena/proposer-provenance.test.mts @@ -9,10 +9,10 @@ import type { ProposerSpec } from './proposer-fanout.mts' const ok = (stdout: string) => ({ code: 0, stdout, stderr: '' }) const gen4ish: ProposerSpec[] = [ - { name: 'claude-author', profile: 'default-author.profile.json', harness: 'claude' }, + { name: 'claude-author', profile: 'default-author.profile.json', harness: 'claude-code' }, { name: 'glm-author', harness: 'opencode', model: 'zai-coding-plan/glm-5.2' }, { name: 'codex-author', harness: 'codex' }, - { name: 'merge-author', profile: 'default-author.profile.json', harness: 'claude', merge: true }, + { name: 'merge-author', profile: 'default-author.profile.json', harness: 'claude-code', merge: true }, ] describe('captureProposerProvenance', () => { @@ -31,7 +31,7 @@ describe('captureProposerProvenance', () => { const byName = Object.fromEntries(record.proposers.map((p) => [p.name, p])) // Claude seat: no pin — the CLI's resolved settings model is the record. expect(byName['claude-author']).toMatchObject({ - harness: 'claude', + harness: 'claude-code', pinnedModel: null, settingsModel: 'claude-fable-5', harnessVersion: 'claude-version 9.9.9', diff --git a/bench/src/swe-code-improve.mts b/bench/src/swe-code-improve.mts index dd92d4a4..1a7cef96 100644 --- a/bench/src/swe-code-improve.mts +++ b/bench/src/swe-code-improve.mts @@ -10,7 +10,7 @@ * Wiring (all verified in this worktree): * - improve()/codeProposerFor + rawTraceContext come from the LOCAL agent-runtime build, linked into * this bench's node_modules (bench/node_modules/@tangle-network/agent-runtime -> /home/drew/code/agent-runtime). - * - The candidate proposer is agenticGenerator(harness:'claude'), BUT the shipped runLocalHarness + * - The candidate proposer is agenticGenerator(harness:'claude-code'), BUT the shipped runLocalHarness * spawns `claude --headless -p` and --headless is an unknown option on the current CLI (exit 1, no * edits ever). We pass code.generator with a corrected runHarness that spawns * `claude -p --dangerously-skip-permissions` so the coding agent can actually edit the @@ -286,7 +286,7 @@ async function main(): Promise { } const generator = agenticGenerator({ - harness: 'claude', + harness: 'claude-code', verify, timeoutMs: harnessTimeoutMs, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/docs/api/index.md b/docs/api/index.md index 83e65240..a72924b5 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -4593,7 +4593,7 @@ git worktree through a pluggable `CandidateGenerator`. > `optional` **harness?**: [`LocalHarness`](mcp.md#localharness) -Local coding harness to run in the worktree. Default `claude`. +Local coding harness to run in the worktree. Default `claude-code`. ##### profile? @@ -4788,7 +4788,7 @@ The driver-LLM seam — ONE inference turn over the conversation + tool specs (t > `optional` **harness?**: [`LocalHarness`](mcp.md#localharness) -Local coding harness the driver's worker sessions run in the worktree. Default `claude`. +Local coding harness the driver's worker sessions run in the worktree. Default `claude-code`. ##### timeoutMs? @@ -5083,7 +5083,7 @@ still requires normal Git worktree and commit semantics. > `optional` **harness?**: [`LocalHarness`](mcp.md#localharness) -Coding harness the agentic generator runs in each worktree. Default `claude`. +Coding harness the agentic generator runs in each worktree. Default `claude-code`. ##### verify? diff --git a/docs/api/mcp.md b/docs/api/mcp.md index f4d04072..e09f9b9f 100644 --- a/docs/api/mcp.md +++ b/docs/api/mcp.md @@ -1988,7 +1988,7 @@ Absolute path to the git repo (the workspace). Worktrees go under `/.a **`Experimental`** -Harnesses to round-robin across `create()` calls. One entry = no fanout. Default `['claude']`. +Harnesses to round-robin across `create()` calls. One entry = no fanout. Default `['claude-code']`. ##### testCmd? @@ -2390,8 +2390,8 @@ is used unchanged. **`Experimental`** -Allow autonomous Claude edits without an interactive permission prompt. - Use only when `cwd` is an isolated candidate worktree. +Allow autonomous edits without an interactive approval gate, using whichever bypass argv the + harness declares. Use only when `cwd` is an isolated candidate worktree. ##### codexReproducible? @@ -5713,9 +5713,13 @@ Use `McpToolDescriptor`; both names are the same protocol contract. ### LocalHarness -> **LocalHarness** = `"claude"` \| `"codex"` \| `"opencode"` +> **LocalHarness** = `Extract`\<`HarnessType`, `"claude-code"` \| `"codex"` \| `"opencode"`\> -Local coding harness available inside the sandbox. +Local coding harness available inside the sandbox — a narrowing of the shared `HarnessType` +vocabulary, NOT a private spelling of it. The harness id is `claude-code`; `claude` is the +EXECUTABLE name and lives only in the `command` field below. Keeping one vocabulary is what +lets a `LocalHarness` be handed straight to the profile materializer and the capability table +with no translation step. *** @@ -5875,6 +5879,24 @@ Default cap on the serialized trace payload per record, in bytes. *** +### LOCAL\_HARNESSES + +> `const` **LOCAL\_HARNESSES**: readonly [`LocalHarness`](#localharness)[] + +Every local harness, in table order — the one list `AGENT_RUNTIME_LOCAL_HARNESSES` and any + other harness enumeration reads, so adding a row above is the only edit a new harness needs. + +*** + +### DEFAULT\_LOCAL\_HARNESS + +> `const` **DEFAULT\_LOCAL\_HARNESS**: [`LocalHarness`](#localharness) = `'claude-code'` + +The harness a caller gets when it expresses no preference. A composition-root default, not a + capability claim: one constant so the several entry points cannot drift apart. + +*** + ### MEMORY\_FILE\_ENV > `const` **MEMORY\_FILE\_ENV**: `"AGENT_MEMORY_FILE"` = `'AGENT_MEMORY_FILE'` @@ -7059,6 +7081,50 @@ then any consumer judges, returning on the first veto. *** +### localHarnessExecutable() + +> **localHarnessExecutable**(`harness`): `string` + +The CLI binary a harness id runs. The two are NOT the same string (`claude-code` runs `claude`), + so anything spawning a harness — a version probe, a login check — reads it from here rather than + passing the harness id as a command. + +#### Parameters + +##### harness + +[`LocalHarness`](#localharness) + +#### Returns + +`string` + +*** + +### harnessSupportsReasoningEffort() + +> **harnessSupportsReasoningEffort**(`harness`, `reasoningEffort`): `boolean` + +Whether the harness's native control can express this reasoning effort. Admission checks read +this so a profile the invocation would later refuse is rejected BEFORE any workspace state is +created, against the same table that emits the argv. + +#### Parameters + +##### harness + +[`LocalHarness`](#localharness) + +##### reasoningEffort + +`"medium"` \| `"none"` \| `"high"` \| `"low"` \| `"minimal"` \| `"xhigh"` \| `"ultracode"` + +#### Returns + +`boolean` + +*** + ### runLocalHarness() > **runLocalHarness**(`options`): `Promise`\<[`LocalHarnessResult`](#localharnessresult)\> diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index 15bed704..221f9867 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -1279,7 +1279,7 @@ Import from `@tangle-network/agent-runtime/testing` — 4 exports. ### MCP servers — delegate / coordination / detached-session -Import from `@tangle-network/agent-runtime/mcp` — 207 exports. +Import from `@tangle-network/agent-runtime/mcp` — 211 exports. | Symbol | Kind | Summary | |---|---|---| @@ -1311,7 +1311,9 @@ Import from `@tangle-network/agent-runtime/mcp` — 207 exports. | `detectExecutor` | function | Pick the right executor for an MCP server invocation based on env vars. | | `eventToSnapshot` | function | Project a `FeedbackEvent` down to the snapshot shape carried on | | `formatDetachedSessionRef` | function | Encode ref parts into the JSON-safe string stored on the record: | +| `harnessSupportsReasoningEffort` | function | Whether the harness's native control can express this reasoning effort. Admission checks read | | `hashIdempotencyInput` | function | Best-effort stable hash for use as `idempotencyKey`. Not cryptographic; | +| `localHarnessExecutable` | function | The CLI binary a harness id runs. The two are NOT the same string (`claude-code` runs `claude`), | | `mcpToolsForRuntimeMcp` | function | Returns the queue-bound delegation tools projected into OpenAI Chat | | `mcpToolsForRuntimeMcpSubset` | function | Subset filter — return only the projected tools whose `function.name` | | `parseCodexTokenUsage` | function | Parse and validate the one terminal usage event emitted by `codex exec --json`. | @@ -1331,6 +1333,7 @@ Import from `@tangle-network/agent-runtime/mcp` — 207 exports. | `validateDelegationHistoryArgs` | function | Parse and validate raw MCP tool input into typed `DelegationHistoryArgs`; throws `TypeError` on bad input. | | `validateDelegationStatusArgs` | function | Parse and validate raw MCP tool input into typed `DelegationStatusArgs`; throws `TypeError` on bad input. | | `DEFAULT_AWAIT_EVENT_TIMEOUT_MS` | const | Default ceiling for a single `await_event` block (ms). Chosen well under any reasonable remote | +| `DEFAULT_LOCAL_HARNESS` | const | The harness a caller gets when it expresses no preference. A composition-root default, not a | | `DELEGATE_DESCRIPTION` | const | Human-readable description of the `delegate` MCP tool, injected into the tool manifest. | | `DELEGATE_FEEDBACK_DESCRIPTION` | const | Human-readable description of the `delegate_feedback` MCP tool, injected into the tool manifest. | | `DELEGATE_FEEDBACK_INPUT_SCHEMA` | const | JSON Schema for `delegate_feedback` tool arguments (`refersTo`, `rating`, `by`, optional fields). | @@ -1348,6 +1351,7 @@ Import from `@tangle-network/agent-runtime/mcp` — 207 exports. | `DELEGATION_STATUS_TOOL_NAME` | const | MCP tool name for the `delegation_status` synchronous-poll tool. | | `DELEGATION_TRACE_MAX_BYTES` | const | Default cap on the serialized trace payload per record, in bytes. | | `DELEGATION_TRACE_MAX_SPANS` | const | Default cap on spans retained per delegation record. | +| `LOCAL_HARNESSES` | const | Every local harness, in table order — the one list `AGENT_RUNTIME_LOCAL_HARNESSES` and any | | `MEMORY_FILE_ENV` | const | Env var naming the durable row store file the memory bin loads (the | | `MEMORY_ITEMS_ENV` | const | Env var carrying inline JSON `MemoryItem` rows (win over file rows on id). | | `MEMORY_LOG_ENV` | const | Env var naming the JSONL retrieval log (one row per `memory_search`). | @@ -1403,7 +1407,7 @@ Import from `@tangle-network/agent-runtime/mcp` — 207 exports. | `DownMessageDeliveryOutcome` | type | The exact result of one parent→child delivery attempt. | | `DriveTurnTick` | type | Structural mirror of the sandbox SDK's `TurnDriveResult` (>= 0.6). | | `GitRunner` | type | Pluggable git runner (sync) — replaceable in tests. | -| `LocalHarness` | type | Local coding harness available inside the sandbox. | +| `LocalHarness` | type | Local coding harness available inside the sandbox — a narrowing of the shared `HarnessType` | | `UiAuditorDelegate` | type | UI-auditor delegate — fully consumer-injected. agent-runtime ships no | **Undocumented supporting types** (add a TSDoc line at the declaration to earn a table row): `AnalystRegistry`, `CappedDelegationTrace`, `CoderOutput`, `CoderReview`, `CoordinationToolsOptions`, `CreateKbGateOptions`, `CreateMemoryToolServerOptions`, `CreateWorktreeOptions`, `DelegateCodeArgs`, `DelegateCodeResult`, `DelegateFeedbackArgs`, `DelegateFeedbackHandlerOptions`, `DelegateFeedbackResult`, `DelegateHandlerOptions`, `DelegateResearchArgs`, `DelegateResearchConfig`, `DelegateResearchResult`, `DelegateRunCtx`, `DelegateUiAuditArgs`, `DelegateUiAuditConfig`, `DelegateUiAuditHandlerOptions`, `DelegateUiAuditResult`, `DelegationError`, `DelegationExecutor`, `DelegationFeedbackSnapshot`, `DelegationHistoryArgs`, `DelegationHistoryEntry`, `DelegationHistoryHandlerOptions`, `DelegationHistoryResult`, `DelegationProgress`, `DelegationResumeContext`, `DelegationRunContext`, `DelegationStatusArgs`, `DelegationStatusHandlerOptions`, `DelegationStatusResult`, `DelegationStore`, `DelegationTaskQueueOptions`, `DelegationTraceCaps`, `DetachedSessionDelegateOptions`, `DetachedTurn`, `DetachedTurnResumeDriverOptions`, `DetectExecutorArgs`, `DiffOptions`, `DiffResult`, `FactCandidate`, `FactJudge`, `FactJudgeVerdict`, `FeedbackEvent`, `FeedbackRating`, `FeedbackRefersTo`, `FeedbackStore`, `FileDelegationStoreOptions`, `FleetWorkspaceExecutorOptions`, `InProcessExecutorDescribePlacement`, `InProcessExecutorOptions`, `KbGateResult`, `LocalHarnessResult`, `McpServer`, `McpServerOptions`, `Question`, `QuestionOption`, `QuestionRecord`, `RemoveWorktreeOptions`, `RunDetachedTurnOptions`, `RunLocalHarnessOptions`, `SettleDetachedCoderTurnOptions`, `SiblingSandboxExecutorOptions`, `StdioToolServer`, `StdioToolServerOptions`, `SubmitInput`, `SubmitOutput`, `TraceContext`, `WorktreeHandle`, `CoderDelegate`, `DelegationProfile`, `DelegationStatus`, `DetachedWinnerSelection`, `MakeWorkerAgent`, `QuestionDecision`, `QuestionLevel`, `QuestionPolicy`, `QuestionUrgency`, `ResearchSource`, `StdioToolDescriptor`, `UiAuditLensFilter`. diff --git a/docs/api/runtime.md b/docs/api/runtime.md index 2580eff3..76a96d46 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -11716,6 +11716,12 @@ Stable, caller-owned cli-bridge session id for harness-side resume. Defaults Per-resume-turn inference cap before the worker settles on its last output. Mirrors `routerToolsInlineExecutor.maxTurns`; default 200 (runaway backstop). +##### activityWindow? + +> `optional` **activityWindow?**: `number` + +Newest-last activity window `progress()` reports. Default 12 (matches `PiSeam`). + *** ### ProviderSeam @@ -13896,6 +13902,16 @@ False when the call was observed but its original arguments were unavailable. > `readonly` `optional` **status?**: `"error"` \| `"ok"` +##### statusCaptured? + +> `readonly` `optional` **statusCaptured?**: `boolean` + +False when the source observed the call being MADE but never observed it finishing — so no +outcome is knowable, not even by default. Some wires (cli-bridge's OpenAI-shaped `tool_calls` +deltas) report the model's DECISION to call a tool and never report the call's result at all. +Without this marker such a call would project as `status: 'ok'` and be counted as a success in +every downstream error-rate read. Set it and the span carries NO status, which is the truth. + ##### result? > `readonly` `optional` **result?**: `unknown` @@ -15591,7 +15607,7 @@ The supervisor-authored `AgentProfile` (systemPrompt + model reach the harness v ##### harness -> **harness**: `"opencode"` \| `"codex"` \| `"claude"` +> **harness**: [`LocalHarness`](mcp.md#localharness) **`Experimental`** diff --git a/src/candidate-execution/system-prompt.ts b/src/candidate-execution/system-prompt.ts index 8a3a6b17..4648f9ec 100644 --- a/src/candidate-execution/system-prompt.ts +++ b/src/candidate-execution/system-prompt.ts @@ -10,15 +10,86 @@ import type { PlanFile, } from '@tangle-network/agent-profile-materialize' -const NATIVE_EXECUTABLES = { - 'claude-code': 'claude', - codex: 'codex', - opencode: 'opencode', - pi: 'pi', -} as const satisfies Partial> - const SYSTEM_PROMPT_FILE = '.tangle/system-prompt.md' +/** + * How ONE harness expresses a replacement system prompt natively. Every difference between + * harnesses lives in a row: the executable that must be on the command line, the projection onto + * that harness's own control, and the launch arguments that would silently shadow the projection. + * + * The differences here are REAL — codex takes a TOML config override, claude-code and pi take + * prompt-file flags with different spellings, opencode has no flag at all and needs a mutated + * `opencode.json`. Adding harness N+1 is one entry; a harness with NO entry is refused by + * {@link projectCandidateSystemPrompt} rather than launched with an unprojected prompt, so the + * conflict check inherits the same fail-closed default instead of returning early. + */ +interface HarnessSystemPrompt { + /** The native binary the launch must run for this projection to be provable. */ + readonly executable: string + /** Apply the prompt to the harness's own control, returning the projected plan. */ + readonly project: ( + plan: AgentCandidateWorkspacePlan, + systemPrompt: string, + systemPromptFilePath: string, + ) => AgentCandidateWorkspacePlan + /** Does the caller's argv already set a system prompt this projection would silently shadow? */ + readonly conflictsWithArgs: (values: readonly string[]) => boolean +} + +const SYSTEM_PROMPT_FLAGS = ['--system-prompt', '--system-prompt-file'] as const + +/** Shared by the harnesses whose native control IS a `--system-prompt*` flag. */ +function argsSetSystemPromptFlag(values: readonly string[]): boolean { + return values.some((value) => + SYSTEM_PROMPT_FLAGS.some((flag) => value === flag || value.startsWith(`${flag}=`)), + ) +} + +function argsSetCodexDeveloperInstructions(values: readonly string[]): boolean { + for (let index = 0; index < values.length; index++) { + const value = values[index]! + const config = + value === '-c' || value === '--config' + ? values[index + 1] + : value.startsWith('--config=') + ? value.slice('--config='.length) + : undefined + if (config?.trimStart().startsWith('developer_instructions=')) return true + } + return false +} + +const HARNESS_SYSTEM_PROMPTS = { + 'claude-code': { + executable: 'claude', + project: (plan, systemPrompt, path) => + appendFlags(addSystemPromptFile(plan, systemPrompt), '--system-prompt-file', path), + conflictsWithArgs: argsSetSystemPromptFlag, + }, + codex: { + executable: 'codex', + project: (plan, systemPrompt) => + appendFlags(plan, '-c', `developer_instructions=${tomlString(systemPrompt)}`), + conflictsWithArgs: argsSetCodexDeveloperInstructions, + }, + opencode: { + executable: 'opencode', + project: (plan, systemPrompt) => ({ + ...plan, + files: projectOpenCodeSystemPrompt(plan.files, systemPrompt), + }), + // `opencode run` takes no system-prompt flag; the prompt lives in `opencode.json`, whose + // conflicts `projectOpenCodeSystemPrompt` rejects at the file level. + conflictsWithArgs: () => false, + }, + pi: { + executable: 'pi', + project: (plan, systemPrompt, path) => + appendFlags(addSystemPromptFile(plan, systemPrompt), '--system-prompt', path), + conflictsWithArgs: argsSetSystemPromptFlag, + }, +} as const satisfies Partial> + /** Project a replacement system prompt onto the exact native process control. */ export function projectCandidateSystemPrompt( plan: AgentCandidateWorkspacePlan, @@ -28,10 +99,13 @@ export function projectCandidateSystemPrompt( const systemPrompt = plan.systemPrompt if (systemPrompt === undefined) return plan - const expectedExecutable = NATIVE_EXECUTABLES[plan.harness as keyof typeof NATIVE_EXECUTABLES] - if (!expectedExecutable) { + const projection = HARNESS_SYSTEM_PROMPTS[plan.harness as keyof typeof HARNESS_SYSTEM_PROMPTS] as + | HarnessSystemPrompt + | undefined + if (!projection) { throw new Error(`candidate system prompt has no native launch projection for ${plan.harness}`) } + const expectedExecutable = projection.executable if (launch.kind !== 'container-command') { throw new Error( `candidate-entrypoint launch cannot prove ${plan.harness} system-prompt replacement`, @@ -49,38 +123,18 @@ export function projectCandidateSystemPrompt( ) } - assertNoSystemPromptOverride(plan.harness, launch.args ?? []) + if (projection.conflictsWithArgs((launch.args ?? []).map((value) => value.value))) { + throw new Error( + `${plan.harness} launch arguments conflict with the candidate profile system prompt`, + ) + } // The source-profile digest already binds the authored value. Sign only the // native projection here so an inert systemPrompt field cannot look active. - const projectedPlan = omitUnappliedSystemPrompt(plan) - - switch (plan.harness) { - case 'codex': - return appendFlags( - projectedPlan, - '-c', - `developer_instructions=${tomlString(systemPrompt.value)}`, - ) - case 'claude-code': - return appendFlags( - addSystemPromptFile(projectedPlan, systemPrompt.value), - '--system-prompt-file', - systemPromptFilePath, - ) - case 'opencode': - return { - ...projectedPlan, - files: projectOpenCodeSystemPrompt(projectedPlan.files, systemPrompt.value), - } - case 'pi': - return appendFlags( - addSystemPromptFile(projectedPlan, systemPrompt.value), - '--system-prompt', - systemPromptFilePath, - ) - default: - throw new Error(`candidate system prompt has no native launch projection for ${plan.harness}`) - } + return projection.project( + omitUnappliedSystemPrompt(plan), + systemPrompt.value, + systemPromptFilePath, + ) } function omitUnappliedSystemPrompt(plan: AgentCandidateWorkspacePlan): AgentCandidateWorkspacePlan { @@ -124,43 +178,6 @@ function addSystemPromptFile( } } -function assertNoSystemPromptOverride( - harness: HarnessId, - args: readonly AgentCandidateConfigValue[], -): void { - const values = args.map((value) => value.value) - if (harness === 'claude-code' || harness === 'pi') { - if ( - values.some( - (value) => - value === '--system-prompt' || - value.startsWith('--system-prompt=') || - value === '--system-prompt-file' || - value.startsWith('--system-prompt-file='), - ) - ) { - throw new Error( - `${harness} launch arguments conflict with the candidate profile system prompt`, - ) - } - return - } - if (harness !== 'codex') return - - for (let index = 0; index < values.length; index++) { - const value = values[index]! - const config = - value === '-c' || value === '--config' - ? values[index + 1] - : value.startsWith('--config=') - ? value.slice('--config='.length) - : undefined - if (config?.trimStart().startsWith('developer_instructions=')) { - throw new Error('codex launch arguments conflict with the candidate profile system prompt') - } - } -} - function projectOpenCodeSystemPrompt(files: readonly PlanFile[], prompt: string): PlanFile[] { const configIndex = files.findIndex((file) => file.relPath === 'opencode.json') if (configIndex === -1) { diff --git a/src/improvement/agentic-generator.ts b/src/improvement/agentic-generator.ts index afb5a0e6..a4857850 100644 --- a/src/improvement/agentic-generator.ts +++ b/src/improvement/agentic-generator.ts @@ -52,6 +52,7 @@ import { import { type CodexExecutionEvidence, type CodexTokenUsage, + DEFAULT_LOCAL_HARNESS, harnessInvocation, type LocalHarness, type LocalHarnessResult, @@ -162,7 +163,7 @@ export type AgenticGeneratorShotDisposition = } export interface AgenticGeneratorOptions { - /** Local coding harness to run in the worktree. Default `claude`. */ + /** Local coding harness to run in the worktree. Default `claude-code`. */ harness?: LocalHarness /** Author profile rendered through the canonical harness mapper. Required * for reproducible Codex so model and reasoning settings are explicit. */ @@ -210,7 +211,9 @@ export interface AgenticGeneratorOptions { /** Full-agentic `CandidateGenerator` (the `shots=N, sandbox=on` setting): run a real coding harness inside the candidate worktree so the agent makes the change in place. */ export function agenticGenerator(opts: AgenticGeneratorOptions = {}): CandidateGenerator { - const harness = opts.harness ?? 'claude' + const harness = opts.harness ?? DEFAULT_LOCAL_HARNESS + // KEPT harness-name test: `codexReproducible` is a codex-SPECIFIC public option, so this + // asserts caller self-consistency and throws loudly instead of varying behavior by name. if (opts.codexReproducible && harness !== 'codex') { throw new Error("agenticGenerator: codexReproducible requires harness 'codex'") } @@ -270,7 +273,9 @@ export function agenticGenerator(opts: AgenticGeneratorOptions = {}): CandidateG const taskPrompt = attemptNote ? `${basePrompt}\n\n${attemptNote}` : basePrompt const invocation = opts.profile ? harnessInvocation(harness, opts.profile, taskPrompt, { - dangerouslySkipPermissions: harness === 'claude', + // The candidate worktree is disposable; whether that needs argv, and which, is the + // harness capability row's answer, not a property of any one CLI's name. + dangerouslySkipPermissions: true, ...(opts.codexReproducible ? { codexReproducible: true } : {}), }) : undefined @@ -300,9 +305,9 @@ export function agenticGenerator(opts: AgenticGeneratorOptions = {}): CandidateG ? { invocation: { command: invocation.command, args: invocation.args } } : {}), // The candidate worktree is isolated and must be editable without an - // interactive permission prompt. Other runLocalHarness callers remain + // interactive approval gate. Other runLocalHarness callers remain // permission-safe by default. - dangerouslySkipPermissions: harness === 'claude', + dangerouslySkipPermissions: true, ...(opts.codexReproducible ? { codexReproducible: true } : {}), ...(readDeniedPaths ? { codexReadDeniedPaths: readDeniedPaths } : {}), timeoutMs: opts.timeoutMs, diff --git a/src/improvement/driver-loop-generator.test.ts b/src/improvement/driver-loop-generator.test.ts index 9ada4ccb..5052f3cd 100644 --- a/src/improvement/driver-loop-generator.test.ts +++ b/src/improvement/driver-loop-generator.test.ts @@ -137,7 +137,7 @@ describe('driverLoopGenerator — the driver→worker build atom', () => { changedPaths: () => (sessions > 0 ? ['src/validate.ts', 'src/validate.test.ts'] : []), readDiff: () => '+ export function validateJson()', }) - expect(generator.kind).toBe('driver-loop:claude') + expect(generator.kind).toBe('driver-loop:claude-code') const result = await generator.generate(generateArgs(findings, 3)) diff --git a/src/improvement/driver-loop-generator.ts b/src/improvement/driver-loop-generator.ts index 7c299844..10197d40 100644 --- a/src/improvement/driver-loop-generator.ts +++ b/src/improvement/driver-loop-generator.ts @@ -29,7 +29,7 @@ import { spawnSync } from 'node:child_process' import { readFileSync, statSync } from 'node:fs' import { resolve, sep } from 'node:path' import type { ProposalFinding } from '@tangle-network/agent-eval' -import { type LocalHarness, runLocalHarness } from '../mcp/local-harness' +import { DEFAULT_LOCAL_HARNESS, type LocalHarness, runLocalHarness } from '../mcp/local-harness' import { runBrainLoop, type ToolLoopChat } from '../runtime/tool-loop' import { defaultBuildPrompt, @@ -47,7 +47,7 @@ export interface DriverLoopGeneratorOptions { * `ToolLoopChat`, same seam as `driverAgent`): `routerBrain(cfg)` in production, a scripted * mock in tests. */ brain: ToolLoopChat - /** Local coding harness the driver's worker sessions run in the worktree. Default `claude`. */ + /** Local coding harness the driver's worker sessions run in the worktree. Default `claude-code`. */ harness?: LocalHarness /** Per-worker-session wall-clock timeout (ms). Default = `runLocalHarness` default (5m). */ timeoutMs?: number @@ -83,7 +83,7 @@ const researchResultMaxChars = 8_000 /** Driver→worker `CandidateGenerator`: an LLM driver on the canonical tool-loop authors, observes, rates, and steers coding-harness sessions in the worktree until the verifier passes or the session budget is spent. */ export function driverLoopGenerator(opts: DriverLoopGeneratorOptions): CandidateGenerator { - const harness = opts.harness ?? 'claude' + const harness = opts.harness ?? DEFAULT_LOCAL_HARNESS const buildPrompt = opts.buildPrompt ?? defaultBuildPrompt const run = opts.runHarness ?? runLocalHarness const changed = opts.changedPaths ?? worktreeChangedPaths diff --git a/src/improvement/improve-types.ts b/src/improvement/improve-types.ts index 070e6894..2e65d305 100644 --- a/src/improvement/improve-types.ts +++ b/src/improvement/improve-types.ts @@ -193,7 +193,7 @@ export interface ImproveCodeOptions { /** Git-compatible adapter override, primarily for tests. Candidate advancement * still requires normal Git worktree and commit semantics. */ worktree?: WorktreeAdapter - /** Coding harness the agentic generator runs in each worktree. Default `claude`. */ + /** Coding harness the agentic generator runs in each worktree. Default `claude-code`. */ harness?: LocalHarness /** Verify a candidate worktree before it becomes a measurable surface; failures * feed the next shot (see `agenticGenerator.verify` / `commandVerifier`). */ diff --git a/src/mcp/bin-helpers.ts b/src/mcp/bin-helpers.ts index 2b45019e..6ede3b96 100644 --- a/src/mcp/bin-helpers.ts +++ b/src/mcp/bin-helpers.ts @@ -15,7 +15,7 @@ import { type FleetHandle, } from './executor' import { createInProcessExecutor } from './in-process-executor' -import type { LocalHarness } from './local-harness' +import { LOCAL_HARNESSES, type LocalHarness } from './local-harness' /** @experimental */ export interface DetectExecutorArgs { @@ -79,8 +79,6 @@ export async function detectExecutor(args: DetectExecutorArgs): Promise = ['claude', 'codex', 'opencode'] - function parseHarnesses(raw: string | undefined): ReadonlyArray | undefined { if (!raw) return undefined const parts = raw @@ -89,9 +87,9 @@ function parseHarnesses(raw: string | undefined): ReadonlyArray | .filter(Boolean) if (parts.length === 0) return undefined for (const part of parts) { - if (!KNOWN_HARNESSES.includes(part as LocalHarness)) { + if (!LOCAL_HARNESSES.includes(part as LocalHarness)) { throw new Error( - `agent-runtime-mcp: AGENT_RUNTIME_LOCAL_HARNESSES contains unknown harness "${part}". Expected: ${KNOWN_HARNESSES.join(', ')}.`, + `agent-runtime-mcp: AGENT_RUNTIME_LOCAL_HARNESSES contains unknown harness "${part}". Expected: ${LOCAL_HARNESSES.join(', ')}.`, ) } } diff --git a/src/mcp/delegate-supervisor-provisioning.ts b/src/mcp/delegate-supervisor-provisioning.ts index efabfa64..7b5d2887 100644 --- a/src/mcp/delegate-supervisor-provisioning.ts +++ b/src/mcp/delegate-supervisor-provisioning.ts @@ -23,6 +23,8 @@ import type { RouterConfig } from '../runtime/router-client' import type { ExecutorConfig } from '../runtime/supervise/runtime' import type { DelegateHandlerOptions } from './tools/delegate' +// Composition-root default: a worker must run on SOME harness, and the router-backed one is the +// least vendor-locked choice. Not a capability claim — nothing here branches on the name. const DEFAULT_WORKER_HARNESS = 'opencode' function trimmed(value: string | undefined): string | undefined { diff --git a/src/mcp/detached-coder.ts b/src/mcp/detached-coder.ts index 766e113a..374d16c0 100644 --- a/src/mcp/detached-coder.ts +++ b/src/mcp/detached-coder.ts @@ -68,6 +68,9 @@ export interface CoderRunSpecOptions { /** Build the authored `AgentProfile` for one harness on the sandbox-session path: the caller's * profile (or the minimal model-only default), with the per-run harness/model/prompt overrides. */ function coderRunProfile(options: CoderRunSpecOptions): AgentProfile { + // A composition-root default on the SANDBOX-BACKEND axis (`HarnessType`), not the local-CLI + // axis that `DEFAULT_LOCAL_HARNESS` covers. Behavior varies on the profile's declared harness + // downstream, never on this name. const harness = options.harness ?? 'claude-code' const name = options.name ?? `coder-${harness}` const base = options.profile ?? minimalCoderProfile() diff --git a/src/mcp/in-process-executor.ts b/src/mcp/in-process-executor.ts index a1d86147..88971e20 100644 --- a/src/mcp/in-process-executor.ts +++ b/src/mcp/in-process-executor.ts @@ -22,7 +22,7 @@ import type { AgentProfile } from '@tangle-network/agent-interface' import type { CreateSandboxOptions, SandboxEvent, SandboxInstance } from '@tangle-network/sandbox' import type { LoopSandboxPlacement, SandboxClient } from '../runtime' import type { DelegationExecutor } from './executor' -import type { LocalHarness } from './local-harness' +import { DEFAULT_LOCAL_HARNESS, type LocalHarness } from './local-harness' import type { GitRunner, WorktreeHandle } from './worktree' import { runWorktreeHarness } from './worktree-harness' @@ -30,7 +30,7 @@ import { runWorktreeHarness } from './worktree-harness' export interface InProcessExecutorOptions { /** Absolute path to the git repo (the workspace). Worktrees go under `/.agent-worktrees/`. */ repoRoot: string - /** Harnesses to round-robin across `create()` calls. One entry = no fanout. Default `['claude']`. */ + /** Harnesses to round-robin across `create()` calls. One entry = no fanout. Default `['claude-code']`. */ harnesses?: ReadonlyArray /** Optional per-delegation test command run in the worktree after the harness exits. */ testCmd?: string @@ -85,7 +85,7 @@ export function createInProcessExecutor(options: InProcessExecutorOptions): Dele const harnesses = options.harnesses && options.harnesses.length > 0 ? [...options.harnesses] - : (['claude'] as const) + : [DEFAULT_LOCAL_HARNESS] const runPostCheck = options.runPostCheck ?? defaultRunPostCheck // The core speaks one `runCommand` seam ({exitCode, output}); adapt the post-check seam // ({exitCode, stdout, stderr}) onto it, folding a throw into a non-fatal failure signal so a diff --git a/src/mcp/index.ts b/src/mcp/index.ts index fc22fc9c..42031545 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -101,6 +101,10 @@ export type { } from './local-harness' export { CodexExecutionDiagnosticError, + DEFAULT_LOCAL_HARNESS, + harnessSupportsReasoningEffort, + LOCAL_HARNESSES, + localHarnessExecutable, parseCodexTokenUsage, runLocalHarness, } from './local-harness' diff --git a/src/mcp/local-harness.ts b/src/mcp/local-harness.ts index 7dcb9bcc..ebab1201 100644 --- a/src/mcp/local-harness.ts +++ b/src/mcp/local-harness.ts @@ -35,7 +35,7 @@ import { } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path' -import type { AgentProfile } from '@tangle-network/agent-interface' +import type { AgentProfile, HarnessType, ReasoningEffort } from '@tangle-network/agent-interface' import { codexSensitiveEnvironmentName, collectCodexDiagnosticRedactionValues, @@ -47,12 +47,25 @@ import { export type { CodexExecutionFailureDiagnostic } from './codex-diagnostics' export { CodexExecutionDiagnosticError } from './codex-diagnostics' -/** Local coding harness available inside the sandbox. */ -export type LocalHarness = 'claude' | 'codex' | 'opencode' +/** + * Local coding harness available inside the sandbox — a narrowing of the shared `HarnessType` + * vocabulary, NOT a private spelling of it. The harness id is `claude-code`; `claude` is the + * EXECUTABLE name and lives only in the `command` field below. Keeping one vocabulary is what + * lets a `LocalHarness` be handed straight to the profile materializer and the capability table + * with no translation step. + */ +export type LocalHarness = Extract -type ReasoningEffort = NonNullable['reasoningEffort']> +/** + * Canonical reasoning effort → the native level string a harness's own control accepts. + * PRESENCE of a key is the capability claim: a level with no entry has no native spelling on + * that harness and is refused rather than silently dropped. + */ +type NativeReasoningLevels = Partial> -const codexReasoningEffort: Record = { +/** `codex -c model_reasoning_effort=…`. `ultracode` has no distinct native level; it saturates + * at `xhigh`, which is also the ceiling `assertCodexReproducibleInvocation` admits. */ +const CODEX_REASONING_LEVELS: NativeReasoningLevels = { none: 'none', minimal: 'minimal', low: 'low', @@ -62,14 +75,45 @@ const codexReasoningEffort: Record = { ultracode: 'xhigh', } -function codexReasoningArgs(reasoningEffort: ReasoningEffort): string[] { - const mapped = codexReasoningEffort[reasoningEffort] - if (mapped === undefined) { - throw new Error( - `harnessInvocation: unsupported Codex reasoning effort ${String(reasoningEffort)}`, - ) +/** `claude --effort ` accepts `low, medium, high, xhigh, max` (verified against the + * installed CLI's `--help`); canonical `ultracode` is its `max`. It expresses no thinking-off + * or `minimal` level, so those two canonical levels have no entry. */ +const CLAUDE_CODE_REASONING_LEVELS: NativeReasoningLevels = { + low: 'low', + medium: 'medium', + high: 'high', + xhigh: 'xhigh', + ultracode: 'max', +} + +/** `opencode run --variant ` is documented as "provider-specific reasoning effort, + * e.g. high, max, minimal", so the canonical level passes through as the variant name and + * `ultracode` maps to `max`. Thinking-off is expressed by omitting the flag, not by a variant + * named `none`, so `none` has no entry. */ +const OPENCODE_REASONING_LEVELS: NativeReasoningLevels = { + minimal: 'minimal', + low: 'low', + medium: 'medium', + high: 'high', + xhigh: 'xhigh', + ultracode: 'max', +} + +interface HarnessInvocationSpec { + command: string + buildArgs: (taskPrompt: string) => string[] + /** Map a resolved model to the harness's model-selector flag. */ + modelArgs: (model: string) => string[] + /** Native reasoning-effort control, absent when the harness exposes none. */ + reasoning?: { + levels: NativeReasoningLevels + args: (nativeLevel: string) => string[] } - return ['-c', `model_reasoning_effort="${mapped}"`] + /** + * Argv that lets an unattended run edit its workspace without stopping on an interactive + * approval gate. Absent when the harness has no such gate on its non-interactive path. + */ + permissionBypassArgs?: () => string[] } /** @@ -79,38 +123,84 @@ function codexReasoningArgs(reasoningEffort: ReasoningEffort): string[] { * the harness's selector flag (every supported harness takes `-m `). The §1.5 * profile-aware mapper `harnessInvocation` composes these to thread the full * supervisor-authored profile (systemPrompt + model) into argv. + * + * Every per-harness difference is a FIELD on this row, never a test on the harness name at a + * call site: adding harness N+1 is one entry here, and a caller asking for a capability the + * row does not declare is refused up front instead of silently losing the request. */ -const HARNESS_INVOCATIONS: Record< - LocalHarness, - { - command: string - buildArgs: (taskPrompt: string) => string[] - /** Map a resolved model to the harness's model-selector flag. */ - modelArgs: (model: string) => string[] - /** Map portable reasoning effort when the harness exposes a native control. */ - reasoningArgs?: (reasoningEffort: ReasoningEffort) => string[] - } -> = { - claude: { +const HARNESS_INVOCATIONS: Record = { + 'claude-code': { command: 'claude', // `-p` IS headless/print mode; the old `--headless` flag was removed from the CLI. // Permission bypass is an explicit per-run opt-in below, never the public default. buildArgs: (taskPrompt) => ['-p', taskPrompt], modelArgs: (model) => ['-m', model], + reasoning: { + levels: CLAUDE_CODE_REASONING_LEVELS, + args: (level) => ['--effort', level], + }, + permissionBypassArgs: () => ['--dangerously-skip-permissions'], }, codex: { command: 'codex', buildArgs: (taskPrompt) => ['exec', taskPrompt], modelArgs: (model) => ['-m', model], - reasoningArgs: codexReasoningArgs, + reasoning: { + levels: CODEX_REASONING_LEVELS, + args: (level) => ['-c', `model_reasoning_effort="${level}"`], + }, + permissionBypassArgs: () => ['--dangerously-bypass-approvals-and-sandbox'], }, opencode: { command: 'opencode', buildArgs: (taskPrompt) => ['run', taskPrompt], modelArgs: (model) => ['-m', model], + reasoning: { + levels: OPENCODE_REASONING_LEVELS, + args: (level) => ['--variant', level], + }, + // `opencode run` is non-interactive and has no approval gate to bypass. }, } +/** Every local harness, in table order — the one list `AGENT_RUNTIME_LOCAL_HARNESSES` and any + * other harness enumeration reads, so adding a row above is the only edit a new harness needs. */ +export const LOCAL_HARNESSES = Object.keys(HARNESS_INVOCATIONS) as ReadonlyArray + +/** The harness a caller gets when it expresses no preference. A composition-root default, not a + * capability claim: one constant so the several entry points cannot drift apart. */ +export const DEFAULT_LOCAL_HARNESS: LocalHarness = 'claude-code' + +/** The CLI binary a harness id runs. The two are NOT the same string (`claude-code` runs `claude`), + * so anything spawning a harness — a version probe, a login check — reads it from here rather than + * passing the harness id as a command. */ +export function localHarnessExecutable(harness: LocalHarness): string { + return HARNESS_INVOCATIONS[harness].command +} + +/** + * Whether the harness's native control can express this reasoning effort. Admission checks read + * this so a profile the invocation would later refuse is rejected BEFORE any workspace state is + * created, against the same table that emits the argv. + */ +export function harnessSupportsReasoningEffort( + harness: LocalHarness, + reasoningEffort: ReasoningEffort, +): boolean { + return HARNESS_INVOCATIONS[harness].reasoning?.levels[reasoningEffort] !== undefined +} + +function harnessReasoningArgs(harness: LocalHarness, reasoningEffort: ReasoningEffort): string[] { + const reasoning = HARNESS_INVOCATIONS[harness].reasoning + const level = reasoning?.levels[reasoningEffort] + if (reasoning === undefined || level === undefined) { + throw new Error( + `harnessInvocation: ${harness} cannot express reasoning effort ${String(reasoningEffort)}`, + ) + } + return reasoning.args(level) +} + /** Result of mapping an `AgentProfile` + task prompt onto a harness invocation. */ export interface HarnessInvocation { command: string @@ -120,8 +210,10 @@ export interface HarnessInvocation { } export interface HarnessInvocationOptions { - /** Allow an unattended Claude process to edit its isolated candidate worktree. - * Ignored by harnesses that do not use Claude's permission prompt. */ + /** Let an unattended process edit its isolated candidate worktree without stopping on an + * interactive approval gate. A property of the WORKSPACE, not of any one CLI: each harness + * contributes its own `permissionBypassArgs`, and a harness with no approval gate on its + * non-interactive path contributes nothing. */ dangerouslySkipPermissions?: boolean /** Run Codex with benchmark-safe process controls and JSONL usage output. * Valid only for the Codex harness. */ @@ -158,11 +250,18 @@ function buildHarnessArgs( taskPrompt: string, options: HarnessInvocationOptions = {}, ): string[] { - const args = HARNESS_INVOCATIONS[harness].buildArgs(taskPrompt) - if (harness === 'claude' && options.dangerouslySkipPermissions) { - args.push('--dangerously-skip-permissions') + const invocation = HARNESS_INVOCATIONS[harness] + const args = invocation.buildArgs(taskPrompt) + // Reproducible mode pins `approval_policy="never"` inside its own controlled config, which is + // the same non-interactive guarantee with the sandbox left INTACT. Emitting a blanket bypass + // flag on top would tear that sandbox down and break the exact-argv reproducibility contract, + // so the reproducible arg set owns approvals whenever it is active. + if (options.dangerouslySkipPermissions && !options.codexReproducible) { + args.push(...(invocation.permissionBypassArgs?.() ?? [])) } if (options.codexReproducible) { + // KEPT harness-name test: same reason as `runLocalHarness` below — `codexReproducible` is a + // codex-SPECIFIC public option, so this asserts caller self-consistency and throws loudly. if (harness !== 'codex') { throw new Error('harnessInvocation: codexReproducible requires the Codex harness') } @@ -232,8 +331,8 @@ export function harnessInvocation( } const reasoningEffort = profile.model?.reasoningEffort - if (reasoningEffort !== undefined && invocation.reasoningArgs) { - args.push(...invocation.reasoningArgs(reasoningEffort)) + if (reasoningEffort !== undefined) { + args.push(...harnessReasoningArgs(harness, reasoningEffort)) } return { command: invocation.command, args, prompt: composedPrompt } @@ -254,8 +353,8 @@ export interface RunLocalHarnessOptions { * is used unchanged. */ invocation?: { command?: string; args: ReadonlyArray } - /** Allow autonomous Claude edits without an interactive permission prompt. - * Use only when `cwd` is an isolated candidate worktree. */ + /** Allow autonomous edits without an interactive approval gate, using whichever bypass argv the + * harness declares. Use only when `cwd` is an isolated candidate worktree. */ dangerouslySkipPermissions?: boolean /** Isolate Codex from ambient configuration/instructions and require JSONL token usage. * The invocation should come from `harnessInvocation(..., { codexReproducible: true })`. */ @@ -433,6 +532,10 @@ export async function runLocalHarness( options: RunLocalHarnessOptions, ): Promise { const { harness, cwd, taskPrompt } = options + // KEPT harness-name test: `codexReproducible` is a codex-SPECIFIC public option (its arg set, + // its permission profile, its JSONL usage event), so this guard asserts caller self-consistency + // and throws loudly rather than varying behavior by name. When a second harness gains a + // reproducible mode, rename the option and make the arg set a row on `HARNESS_INVOCATIONS`. if (options.codexReproducible && harness !== 'codex') { throw new Error('runLocalHarness: codexReproducible requires the Codex harness') } diff --git a/src/mcp/worktree-harness.ts b/src/mcp/worktree-harness.ts index b0df1731..0dfdc8a7 100644 --- a/src/mcp/worktree-harness.ts +++ b/src/mcp/worktree-harness.ts @@ -43,6 +43,7 @@ import { type CodexExecutionPolicy, type CodexTokenUsage, harnessInvocation, + harnessSupportsReasoningEffort, type LocalHarness, type LocalHarnessResult, runLocalHarness, @@ -211,10 +212,6 @@ export interface WorktreeHarnessRun { const defaultCheckOutputCap = 16_000 -function materializerHarness(harness: LocalHarness): HarnessId { - return harness === 'claude' ? 'claude-code' : harness -} - /** This harness runs public plans only — it has no secret provider, so any * templated argument or secret-ref env value is refused rather than leaked * or silently stringified. */ @@ -288,9 +285,10 @@ export async function runWorktreeHarness( // instructions; the workspace projection therefore omits both prompt sources. const invocationProfile = profileWithResourceInstructions(profile, resourceInstructions) const { command, args } = harnessInvocation(opts.harness, invocationProfile, opts.taskPrompt, { - // This helper created the candidate worktree above; autonomous Claude - // edits are permitted only inside that isolated checkout. - dangerouslySkipPermissions: opts.harness === 'claude', + // This helper created the candidate worktree above; autonomous edits are permitted only + // inside that isolated checkout. Which argv (if any) expresses that is the harness row's + // business — the workspace being disposable is what decides it here. + dangerouslySkipPermissions: true, ...(opts.codexReproducible ? { codexReproducible: true } : {}), }) const harnessResult: LocalHarnessResult = await runHarness({ @@ -400,7 +398,7 @@ function prepareWorktreeProfile( const workspaceProfile = materializationOnlyProfile(profile) assertSupportedWorktreeProfile(profile, harness) assertSafeProfileResourcePaths(profile) - const plan = materializeProfile(workspaceProfile, materializerHarness(harness)) + const plan = materializeProfile(workspaceProfile, harness) if (plan.unsupported.length > 0) { throw new Error( `runWorktreeHarness: profile cannot be materialized for ${harness}: ${plan.unsupported @@ -492,10 +490,12 @@ function assertSupportedWorktreeProfile(profile: AgentProfile, harness: LocalHar context: 'runWorktreeHarness', }) // `profile.harness` is only a preference and the explicit run option wins. The contract above - // rejects routing-only model hints and `resources.failOnError`; this harness-specific check - // handles values supported by only a subset of the three local CLIs. + // rejects routing-only model hints and `resources.failOnError`; this check refuses an axis the + // chosen harness's own capability row cannot express, so the refusal lands before any worktree + // exists and cannot drift from the argv the invocation would build. const unsupportedAxes: string[] = [] - if (profile.model?.reasoningEffort !== undefined && harness !== 'codex') { + const reasoningEffort = profile.model?.reasoningEffort + if (reasoningEffort !== undefined && !harnessSupportsReasoningEffort(harness, reasoningEffort)) { unsupportedAxes.push('model.reasoningEffort') } if (unsupportedAxes.length > 0) { diff --git a/src/runtime/harness-role.test.ts b/src/runtime/harness-role.test.ts new file mode 100644 index 00000000..9f132323 --- /dev/null +++ b/src/runtime/harness-role.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { agentHarness, harnessRunsAgent } from './harness-role' + +describe('harnessRunsAgent', () => { + it('treats only the router/no-agent mode and an absent preference as no agent', () => { + expect(harnessRunsAgent('cli-base')).toBe(false) + expect(harnessRunsAgent(undefined)).toBe(false) + expect(harnessRunsAgent(null)).toBe(false) + for (const harness of ['claude-code', 'codex', 'opencode', 'pi', 'kimi-code'] as const) { + expect(harnessRunsAgent(harness)).toBe(true) + } + }) + + it('keeps nanoclaw a full harness despite sharing cli-base reasoning clamp', () => { + expect(harnessRunsAgent('nanoclaw')).toBe(true) + }) +}) + +describe('agentHarness', () => { + it('drops the no-agent mode and passes a real harness through unchanged', () => { + expect(agentHarness('cli-base')).toBeUndefined() + expect(agentHarness(null)).toBeUndefined() + expect(agentHarness(undefined)).toBeUndefined() + expect(agentHarness('codex')).toBe('codex') + }) +}) diff --git a/src/runtime/harness-role.ts b/src/runtime/harness-role.ts new file mode 100644 index 00000000..b5ff61ad --- /dev/null +++ b/src/runtime/harness-role.ts @@ -0,0 +1,39 @@ +/** + * Which `HarnessType` values name a real coding-agent runtime, and which name a MODE that has no + * agent behind it. + * + * `cli-base` is the router-backed mode — a plain multi-turn router call with no coding-agent + * harness (`@tangle-network/agent-interface`'s `HarnessType` doc, and the same table's + * `harnessReasoningCeiling['cli-base'] = 'none'` commented "cli-base has no agent"). The + * distinction is REAL; what was accidental is that three call sites each spelled the test + * themselves, so a second no-agent mode would have to be remembered in three places. + * + * SHOULD UPSTREAM: this belongs next to `harnessHonorsEffort` in agent-interface's + * `harness-capabilities.ts`, which already encodes the fact but exports no predicate for it. + * Until it does, this is the one local copy — extend the set here, never re-test the name. + */ + +import type { HarnessType } from '@tangle-network/agent-interface' + +/** + * Harness ids that select a router/no-agent mode rather than a coding-agent runtime. + * + * `nanoclaw` is deliberately ABSENT: the shared capability table clamps its reasoning ceiling the + * same way it clamps `cli-base`, but nanoclaw does run an agent (a socket-bridge runner to the + * NanoClaw daemon) and every caller here treats it as a full harness. Add a row only with the + * behavior change stated, never to make the two clamps look symmetric. + */ +const NO_AGENT_HARNESSES: ReadonlySet = new Set(['cli-base' satisfies HarnessType]) + +/** Whether this profile harness selects a real coding-agent runtime (vs. the router/no-agent mode + * or no preference at all). Accepts the looser `string` some local profile shapes declare, and + * preserves the caller's own harness type when it is narrower. */ +export function harnessRunsAgent(harness: T | null | undefined): harness is T { + return harness != null && !NO_AGENT_HARNESSES.has(harness) +} + +/** The coding-agent harness this profile selects, or `undefined` when it selects none — the shape + * callers want when a no-agent mode must fall through to the router arm. */ +export function agentHarness(harness: T | null | undefined): T | undefined { + return harnessRunsAgent(harness) ? harness : undefined +} diff --git a/src/runtime/supervise/budget-floor.ts b/src/runtime/supervise/budget-floor.ts index d3cdd7ca..ef99f9b1 100644 --- a/src/runtime/supervise/budget-floor.ts +++ b/src/runtime/supervise/budget-floor.ts @@ -22,6 +22,8 @@ */ import type { BackendType } from '@tangle-network/sandbox' +// Already the target shape: one exhaustive row per harness, `satisfies` pinned so a new +// `BackendType` is a compile error rather than a silent gap. Add a harness by adding a row. export const WORKER_TOKEN_FLOOR = { // 31,211 was the lowest of six measured settlements; nothing rounds it up. pi: 31_211, diff --git a/src/runtime/supervise/runtime.ts b/src/runtime/supervise/runtime.ts index f4a1fee0..94b4dc90 100644 --- a/src/runtime/supervise/runtime.ts +++ b/src/runtime/supervise/runtime.ts @@ -58,6 +58,7 @@ import { providerAsSandboxClient, resolveAgentEnvironmentProvider, } from '../environment-provider' +import { agentHarness } from '../harness-role' import { routerChatWithUsage, type ToolSpec } from '../router-client' import type { RunAgentRoundsOptions } from '../run-loop' import { runAgentRounds } from '../run-loop' @@ -1154,7 +1155,7 @@ function bridgeCellModel( | { backend?: { type?: string; model?: { model?: string } } } | undefined const backend = create?.backend - const profileHarness = profile.harness === 'cli-base' ? undefined : profile.harness + const profileHarness = agentHarness(profile.harness) const harness = backend?.type ?? profileHarness const model = backend?.model?.model ?? profile.model?.default if (!harness && !model) return seamModel diff --git a/src/runtime/supervise/supervise.ts b/src/runtime/supervise/supervise.ts index 255144ab..d77a1d82 100644 --- a/src/runtime/supervise/supervise.ts +++ b/src/runtime/supervise/supervise.ts @@ -41,6 +41,7 @@ import type { WorkerWatchOptions, } from '../../mcp/tools/coordination' import { composeRuntimeHooks, type RuntimeHooks } from '../../runtime-hooks' +import { harnessRunsAgent } from '../harness-role' import type { RouterConfig } from '../router-client' import type { ToolLoopChat, ToolLoopCompactionOptions } from '../tool-loop' import { canonicalizeAuthoredProfile } from './authoring' @@ -174,6 +175,14 @@ function externalExecutionId(kind: string, identity: unknown): string { return `${kind}-${digest.slice('sha256:'.length)}` } +/** + * NOT a harness-name test — `ExecutorConfig.backend` is a discriminated-union TAG naming HOW a + * profile is materialized (bridge / sandbox / cli-worktree / router / cli / provider), which is a + * different axis from WHICH CLI runs. An exhaustive switch on a closed union tag is the correct + * shape and must stay: it is what makes a new executor kind a compile error here rather than a + * silently weaker materialization contract. Every other `backend.backend === …` in this file and + * in `runtime.ts` is the same tag; none of them are harness names. + */ function backendProfileMaterialization(backend: ExecutorConfig): ProfileMaterializationContract { switch (backend.backend) { case 'bridge': @@ -253,7 +262,7 @@ export const DEFAULT_AUTHORED_PROFILE_SECURITY_POLICY: AgentProfileSecurityPolic }) function isExternalSupervisor(profile: AgentProfile): boolean { - return profile.harness !== undefined && profile.harness !== 'cli-base' + return harnessRunsAgent(profile.harness) } function automaticDriverBackendSupported(backend: ExecutorConfig): boolean { diff --git a/src/runtime/supervise/supervisor-agent.ts b/src/runtime/supervise/supervisor-agent.ts index fd80d2f0..6abd0ee7 100644 --- a/src/runtime/supervise/supervisor-agent.ts +++ b/src/runtime/supervise/supervisor-agent.ts @@ -30,6 +30,7 @@ import type { WorkerWatchOptions, } from '../../mcp/tools/coordination' import { coordinationVerbNames } from '../../mcp/tools/coordination' +import { agentHarness } from '../harness-role' import { type RouterConfig, routerBrain } from '../router-client' import type { ToolLoopChat, ToolLoopCompactionOptions } from '../tool-loop' import type { DeliverableSpec } from './completion-gate' @@ -448,12 +449,7 @@ export function supervisorAgent( ) } const name = stableProfile.name ?? 'supervisor' - const harness = - stableProfile.harness === undefined || - stableProfile.harness === null || - stableProfile.harness === 'cli-base' - ? null - : stableProfile.harness + const harness = agentHarness(stableProfile.harness) ?? null // The prompt is consumed by BOTH arms, so it resolves here; the model id is router-arm-only and // resolves inside that arm, so a harness supervisor never touches a field it does not use. // No fallback at this site: the harness supplies its own standing prompt, and the router arm diff --git a/src/runtime/supervise/worktree-cli-executor.ts b/src/runtime/supervise/worktree-cli-executor.ts index 3f422358..e39b2526 100644 --- a/src/runtime/supervise/worktree-cli-executor.ts +++ b/src/runtime/supervise/worktree-cli-executor.ts @@ -124,6 +124,8 @@ export function createWorktreeCliExecutor( ) { throw new ValidationError('createWorktreeCliExecutor: taskPrompt required') } + // KEPT harness-name test: `codexReproducible` is a codex-SPECIFIC public option, so this + // asserts caller self-consistency and throws loudly instead of varying behavior by name. if (options.codexReproducible && options.harness !== 'codex') { throw new ValidationError( 'createWorktreeCliExecutor: codexReproducible requires harness "codex"', diff --git a/src/runtime/supervise/worktree-fanout.ts b/src/runtime/supervise/worktree-fanout.ts index 8f744dc5..dde2a467 100644 --- a/src/runtime/supervise/worktree-fanout.ts +++ b/src/runtime/supervise/worktree-fanout.ts @@ -15,6 +15,7 @@ */ import type { AgentProfile } from '@tangle-network/agent-interface' +import type { LocalHarness } from '../../mcp/local-harness' import { fanout, selectValidWinner } from '../personify/combinators' import type { CombinatorShape, WinnerStrategy } from '../personify/wave-types' import { type DeliverableSpec, gateOnDeliverable } from './completion-gate' @@ -34,7 +35,7 @@ export interface AuthoredHarness { /** The supervisor-authored `AgentProfile` (systemPrompt + model reach the harness via §1.5). */ profile: AgentProfile /** Which local harness CLI drives this leaf. */ - harness: 'claude' | 'codex' | 'opencode' + harness: LocalHarness /** Require measured usage from this leaf. Budgeted supervision refuses the default unmetered * local-CLI mode; set false only when the selected runner actually returns token usage. */ budgetExempt?: WorktreeCliExecutorOptions['budgetExempt'] diff --git a/tests/candidate-execution-prepare.test.ts b/tests/candidate-execution-prepare.test.ts index 8a86696c..7217b37b 100644 --- a/tests/candidate-execution-prepare.test.ts +++ b/tests/candidate-execution-prepare.test.ts @@ -119,6 +119,49 @@ describe('candidate execution preparation', () => { }, ) + it.each([ + { + harness: 'codex', + executable: 'codex', + args: ['-c', 'developer_instructions="already set"'], + }, + { harness: 'claude-code', executable: 'claude', args: ['--system-prompt-file', '/elsewhere'] }, + { harness: 'claude-code', executable: 'claude', args: ['--system-prompt=inline'] }, + { harness: 'pi', executable: 'pi', args: ['--system-prompt', '/elsewhere'] }, + ] as const)( + 'refuses $harness launch args that would shadow the profile system prompt', + async ({ harness, executable, args }) => { + const value = fixture() + value.bundle = redigestBundle(value.bundle, { + profile: { + ...value.bundle.profile, + harness, + prompt: { ...value.bundle.profile.prompt, systemPrompt: 'Must be active.' }, + }, + execution: { + ...value.bundle.execution, + harness, + launch: { + kind: 'container-command', + executable, + args: args.map((value) => ({ kind: 'public', value })), + }, + }, + }) + bindCandidateFixtureBundle(value) + + await expect( + prepareAgentCandidateExecution( + await verifyAgentCandidateBundle(value.bundle, value.ports), + value.task, + value.ports, + ), + ).rejects.toThrow( + new RegExp(`${harness} launch arguments conflict with the candidate profile system prompt`), + ) + }, + ) + it('rejects a system prompt when an arbitrary candidate entrypoint cannot apply it', async () => { const value = fixture(true) value.bundle = redigestBundle(value.bundle, { diff --git a/tests/kernel/supervise-convenience.test.ts b/tests/kernel/supervise-convenience.test.ts index abbd65aa..9c69df78 100644 --- a/tests/kernel/supervise-convenience.test.ts +++ b/tests/kernel/supervise-convenience.test.ts @@ -503,7 +503,7 @@ describe('supervise — the one-call convenience (defaults blobs/perWorker/journ const localWorktreeWorker = workerFromBackend({ backend: 'cli-worktree', repoRoot: '/workspace', - harness: 'claude', + harness: 'claude-code', }) expect(() => localWorktreeWorker({ diff --git a/tests/kernel/worktree-loop.test.ts b/tests/kernel/worktree-loop.test.ts index e82735c5..edcc128e 100644 --- a/tests/kernel/worktree-loop.test.ts +++ b/tests/kernel/worktree-loop.test.ts @@ -60,7 +60,7 @@ describe('worktreeLoopRunner — the migrated generic coder path', () => { { name: 'claude', profile: profile('claude'), - harness: 'claude', + harness: 'claude-code', budgetExempt: false, }, { @@ -96,7 +96,7 @@ describe('worktreeLoopRunner — the migrated generic coder path', () => { { name: 'claude', profile: profile('claude'), - harness: 'claude', + harness: 'claude-code', budgetExempt: false, }, ], @@ -122,7 +122,7 @@ describe('worktreeLoopRunner — the migrated generic coder path', () => { { name: 'claude', profile: profile('claude'), - harness: 'claude', + harness: 'claude-code', budgetExempt: false, }, ], diff --git a/tests/mcp/in-process-detect.test.ts b/tests/mcp/in-process-detect.test.ts index 547f400a..19e9fac5 100644 --- a/tests/mcp/in-process-detect.test.ts +++ b/tests/mcp/in-process-detect.test.ts @@ -11,7 +11,7 @@ describe('detectExecutor — in-process selection', () => { }) expect(exec.describe()).toMatch(/in-process/) expect(exec.describe()).toContain('/workspace') - expect(exec.describe()).toContain('harnesses=[claude]') + expect(exec.describe()).toContain('harnesses=[claude-code]') // In-process placement has no sandbox session — the bin keys detached // dispatch off this tag, so it must never read session-backed here. expect(exec.placement).toBe('in-process') @@ -37,10 +37,10 @@ describe('detectExecutor — in-process selection', () => { env: { AGENT_RUNTIME_IN_SANDBOX: '1', AGENT_RUNTIME_REPO_ROOT: '/wk', - AGENT_RUNTIME_LOCAL_HARNESSES: 'claude,codex,opencode', + AGENT_RUNTIME_LOCAL_HARNESSES: 'claude-code,codex,opencode', }, }) - expect(exec.describe()).toContain('harnesses=[claude,codex,opencode]') + expect(exec.describe()).toContain('harnesses=[claude-code,codex,opencode]') }) it('rejects unknown harness name', async () => { @@ -50,7 +50,7 @@ describe('detectExecutor — in-process selection', () => { env: { AGENT_RUNTIME_IN_SANDBOX: '1', AGENT_RUNTIME_REPO_ROOT: '/wk', - AGENT_RUNTIME_LOCAL_HARNESSES: 'claude,gemini', + AGENT_RUNTIME_LOCAL_HARNESSES: 'claude-code,gemini', }, }), ).rejects.toThrow(/unknown harness "gemini"/) diff --git a/tests/mcp/in-process-executor.test.ts b/tests/mcp/in-process-executor.test.ts index 0766e9a4..4b3391d4 100644 --- a/tests/mcp/in-process-executor.test.ts +++ b/tests/mcp/in-process-executor.test.ts @@ -44,7 +44,7 @@ describe('createInProcessExecutor', () => { } const exec = createInProcessExecutor({ repoRoot: '/workspace', - harnesses: ['claude'], + harnesses: ['claude-code'], runGit: makeFakeGit(state), runHarness: vi.fn(async () => ({ exitCode: 0, @@ -105,7 +105,7 @@ describe('createInProcessExecutor', () => { })) const exec = createInProcessExecutor({ repoRoot: '/w', - harnesses: ['claude', 'codex', 'opencode'], + harnesses: ['claude-code', 'codex', 'opencode'], runGit: makeFakeGit(state), runHarness, }) @@ -119,7 +119,14 @@ describe('createInProcessExecutor', () => { } } const harnesses = runHarness.mock.calls.map((c) => (c[0] as { harness: string }).harness) - expect(harnesses).toEqual(['claude', 'codex', 'opencode', 'claude', 'codex', 'opencode']) + expect(harnesses).toEqual([ + 'claude-code', + 'codex', + 'opencode', + 'claude-code', + 'codex', + 'opencode', + ]) }) it('runs testCmd + typecheckCmd against the worktree and folds results into the artifact checks', async () => { @@ -137,7 +144,7 @@ describe('createInProcessExecutor', () => { })) const exec = createInProcessExecutor({ repoRoot: '/w', - harnesses: ['claude'], + harnesses: ['claude-code'], testCmd: 'pnpm test', typecheckCmd: 'pnpm typecheck', runGit: makeFakeGit(state), @@ -205,7 +212,7 @@ describe('createInProcessExecutor', () => { const result = events.find((e) => e.type === 'result')!.data.result as { harness: { name: string; exitCode: number | null } } - expect(result.harness.name).toBe('claude') + expect(result.harness.name).toBe('claude-code') expect(result.harness.exitCode).toBe(2) }) @@ -293,14 +300,14 @@ describe('createInProcessExecutor', () => { })) const exec = createInProcessExecutor({ repoRoot: '/w', - harnesses: ['claude'], + harnesses: ['claude-code'], runGit: makeFakeGit(state), runHarness, }) // The authored worker profile rides in `backend.profile` (where `buildBackendOptions` puts it). const box = await exec.client.create({ backend: { - type: 'claude', + type: 'claude-code', profile: { name: 'w', prompt: { systemPrompt: 'BE RIGOROUS' }, diff --git a/tests/mcp/local-harness.test.ts b/tests/mcp/local-harness.test.ts index c4b75c81..35a5a40f 100644 --- a/tests/mcp/local-harness.test.ts +++ b/tests/mcp/local-harness.test.ts @@ -221,7 +221,7 @@ async function runCodexPromptEvidenceFixture( describe('runLocalHarness', () => { it('runs the harness, captures stdout + stderr, returns exit code', async () => { const result = await runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd: '/tmp/wt', taskPrompt: 'add util.ts', spawn: () => makeFakeChild({ stdoutChunks: ['hello'], stderrChunks: ['warn'], exitCode: 0 }), @@ -242,7 +242,7 @@ describe('runLocalHarness', () => { await expect( runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd: '/tmp/wt', taskPrompt: 'must not launch', signal: controller.signal, @@ -709,7 +709,7 @@ describe('runLocalHarness', () => { it('kills subprocess + flags timedOut when timeoutMs elapses', async () => { vi.useFakeTimers() const promise = runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd: '/tmp/wt', taskPrompt: 'slow', timeoutMs: 100, @@ -744,7 +744,7 @@ describe('runLocalHarness', () => { }) try { const promise = runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd: '/tmp/wt', taskPrompt: 'ignore termination', timeoutMs: 20, @@ -786,7 +786,7 @@ describe('runLocalHarness', () => { ].join(';') const ctl = new AbortController() const run = runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd, taskPrompt: 'graceful process-tree cancellation smoke', invocation: { @@ -834,7 +834,7 @@ describe('runLocalHarness', () => { try { const result = await runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd, taskPrompt: 'normal-exit process-tree cleanup smoke', invocation: { command: process.execPath, args: ['-e', parentScript, pidFile] }, @@ -877,7 +877,7 @@ describe('runLocalHarness', () => { let run: ReturnType | undefined try { run = runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd, taskPrompt: 'process-tree cancellation smoke', invocation: { command: process.execPath, args: ['-e', parentScript, pidFile] }, @@ -908,7 +908,7 @@ describe('runLocalHarness', () => { it('retains only the newest configured bytes from noisy output', async () => { const result = await runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd: process.cwd(), taskPrompt: 'bounded output smoke', invocation: { @@ -926,7 +926,7 @@ describe('runLocalHarness', () => { it('kills subprocess on AbortSignal', async () => { const ctl = new AbortController() const promise = runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd: '/tmp/wt', taskPrompt: 'slow', signal: ctl.signal, @@ -1103,7 +1103,7 @@ describe('runLocalHarness', () => { const spawnSpy = vi.fn((_cmd: string, _args: ReadonlyArray) => makeFakeChild({ exitCode: 0 }), ) - for (const harness of ['claude', 'codex', 'opencode'] as const) { + for (const harness of ['claude-code', 'codex', 'opencode'] as const) { await runLocalHarness({ harness, cwd: '/tmp/wt', taskPrompt: 'go', spawn: spawnSpy }) } const calls = spawnSpy.mock.calls @@ -1120,7 +1120,7 @@ describe('runLocalHarness', () => { makeFakeChild({ exitCode: 0 }), ) await runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd: '/tmp/isolated-worktree', taskPrompt: 'go', dangerouslySkipPermissions: true, @@ -1134,7 +1134,7 @@ describe('runLocalHarness', () => { makeFakeChild({ exitCode: 0 }), ) await runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd: '/tmp/wt', // The prompt-only fallback path would emit ['-p','go'] — the override wins exactly. taskPrompt: 'go', @@ -1154,7 +1154,7 @@ describe('harnessInvocation (the §1.5 profile-aware mapper)', () => { }) it('threads the authored systemPrompt into the prompt channel for every harness', () => { - for (const harness of ['claude', 'codex', 'opencode'] as const) { + for (const harness of ['claude-code', 'codex', 'opencode'] as const) { const inv = harnessInvocation( harness, profileWith('You are a careful refactorer.'), @@ -1167,7 +1167,7 @@ describe('harnessInvocation (the §1.5 profile-aware mapper)', () => { }) it('maps the authored model to the harness -m selector', () => { - for (const harness of ['claude', 'codex', 'opencode'] as const) { + for (const harness of ['claude-code', 'codex', 'opencode'] as const) { const inv = harnessInvocation(harness, profileWith(undefined, 'deepseek/deepseek-v4'), 'go') const mIdx = inv.args.indexOf('-m') expect(mIdx).toBeGreaterThanOrEqual(0) @@ -1176,7 +1176,7 @@ describe('harnessInvocation (the §1.5 profile-aware mapper)', () => { }) it('threads BOTH systemPrompt and model together', () => { - const inv = harnessInvocation('claude', profileWith('SYS', 'kimi-k2.7'), 'task') + const inv = harnessInvocation('claude-code', profileWith('SYS', 'kimi-k2.7'), 'task') expect(inv.command).toBe('claude') expect(inv.args).toEqual(['-p', 'SYS\n\ntask', '-m', 'kimi-k2.7']) }) @@ -1309,7 +1309,7 @@ describe('harnessInvocation (the §1.5 profile-aware mapper)', () => { ).toThrow(/requires profile\.model\.reasoningEffort/) expect(() => harnessInvocation( - 'claude', + 'claude-code', { model: { default: 'claude-opus-4-1', reasoningEffort: 'high' } }, 'task', { codexReproducible: true }, @@ -1317,7 +1317,7 @@ describe('harnessInvocation (the §1.5 profile-aware mapper)', () => { ).toThrow(/requires the Codex harness/) }) - it('rejects an unknown Codex reasoning effort instead of emitting invalid config', () => { + it('rejects a reasoning effort the harness cannot express instead of emitting invalid config', () => { expect(() => harnessInvocation( 'codex', @@ -1325,22 +1325,68 @@ describe('harnessInvocation (the §1.5 profile-aware mapper)', () => { { model: { reasoningEffort: 'unbounded' } }, 'task', ), - ).toThrow(/unsupported Codex reasoning effort unbounded/) + ).toThrow(/codex cannot express reasoning effort unbounded/) + // claude-code's `--effort` has no thinking-off or `minimal` level, so those canonical levels + // are refused rather than silently dropped or coerced to a level the author did not ask for. + for (const reasoningEffort of ['none', 'minimal'] as const) { + expect(() => + harnessInvocation('claude-code', { model: { reasoningEffort } }, 'task'), + ).toThrow(/claude-code cannot express reasoning effort/) + } + expect(() => + harnessInvocation('opencode', { model: { reasoningEffort: 'none' } }, 'task'), + ).toThrow(/opencode cannot express reasoning effort none/) + }) + + it('threads reasoning effort onto every harness that has a native control', () => { + expect( + harnessInvocation('claude-code', { model: { reasoningEffort: 'high' } }, 'go').args, + ).toEqual(['-p', 'go', '--effort', 'high']) + // Canonical `ultracode` is claude-code's native `max` and opencode's `max` variant. + expect( + harnessInvocation('claude-code', { model: { reasoningEffort: 'ultracode' } }, 'go').args, + ).toEqual(['-p', 'go', '--effort', 'max']) + expect( + harnessInvocation('opencode', { model: { reasoningEffort: 'high' } }, 'go').args, + ).toEqual(['run', 'go', '--variant', 'high']) + expect(harnessInvocation('codex', { model: { reasoningEffort: 'high' } }, 'go').args).toEqual([ + 'exec', + 'go', + '-c', + 'model_reasoning_effort="high"', + ]) + }) + + it("emits each harness's own permission-bypass argv, and none where there is no gate", () => { + const bypass = { dangerouslySkipPermissions: true } + expect(harnessInvocation('claude-code', { name: 'x' }, 'go', bypass).args).toEqual([ + '-p', + 'go', + '--dangerously-skip-permissions', + ]) + expect(harnessInvocation('codex', { name: 'x' }, 'go', bypass).args).toEqual([ + 'exec', + 'go', + '--dangerously-bypass-approvals-and-sandbox', + ]) + // `opencode run` is non-interactive and declares no bypass argv, so asking for one is a no-op + // rather than a silently dropped request for a flag that does not exist. + expect(harnessInvocation('opencode', { name: 'x' }, 'go', bypass).args).toEqual(['run', 'go']) }) it('an empty/absent profile yields exactly the legacy prompt-only shape (byte-identical)', () => { - expect(harnessInvocation('claude', { name: 'x' }, 'go').args).toEqual(['-p', 'go']) + expect(harnessInvocation('claude-code', { name: 'x' }, 'go').args).toEqual(['-p', 'go']) expect(harnessInvocation('codex', { name: 'x' }, 'go').args).toEqual(['exec', 'go']) expect(harnessInvocation('opencode', { name: 'x' }, 'go').args).toEqual(['run', 'go']) }) it('adds Claude permission bypass only when an isolated worktree explicitly opts in', () => { expect( - harnessInvocation('claude', { name: 'x' }, 'go', { + harnessInvocation('claude-code', { name: 'x' }, 'go', { dangerouslySkipPermissions: true, }).args, ).toEqual(['-p', 'go', '--dangerously-skip-permissions']) - expect(harnessInvocation('claude', { name: 'x' }, 'go').args).toEqual(['-p', 'go']) + expect(harnessInvocation('claude-code', { name: 'x' }, 'go').args).toEqual(['-p', 'go']) }) it('throws on an unknown harness', () => { @@ -1381,7 +1427,7 @@ describe('runLocalHarness trace-context inheritance (in-process placement)', () }, ) await runLocalHarness({ - harness: 'claude', + harness: 'claude-code', cwd: '/tmp/wt', taskPrompt: 'go', spawn: spawnSpy, diff --git a/tests/mcp/worktree-harness.test.ts b/tests/mcp/worktree-harness.test.ts index fa3435fb..7d770f7b 100644 --- a/tests/mcp/worktree-harness.test.ts +++ b/tests/mcp/worktree-harness.test.ts @@ -283,7 +283,7 @@ describe('runWorktreeHarness profile materialization', () => { runWorktreeHarness({ repoRoot, profile: {}, - harness: 'claude', + harness: 'claude-code', taskPrompt: 'task', runId, testCmd: 'must-not-run', @@ -405,11 +405,11 @@ describe('runWorktreeHarness profile materialization', () => { hooks: { PreToolUse: [{ command: 'node hook.mjs', matcher: 'Bash' }] }, subagents: { helper: { description: 'Helper', prompt: 'SUBAGENT_MARKER_37bb713b' } }, }, - harness: 'claude', + harness: 'claude-code', taskPrompt: 'DIRECT_TASK_72c5c757', runId, runHarness: async (options) => { - expect(options.harness).toBe('claude') + expect(options.harness).toBe('claude-code') expect(options.invocation?.command).toBe('claude') expect(options.invocation?.args).toContain('claude-model') expect(readFileSync(join(options.cwd, paths.file), 'utf8')).toContain( @@ -741,7 +741,7 @@ describe('runWorktreeHarness profile materialization', () => { const runHarness = vi.fn() const cases: Array<{ runId: string - harness: 'claude' | 'codex' | 'opencode' + harness: 'claude-code' | 'codex' | 'opencode' profile: AgentProfile dropped: string[] }> = [ @@ -765,10 +765,12 @@ describe('runWorktreeHarness profile materialization', () => { ], }, { + // `high` is now DELIVERED on claude-code (`--effort high`); what it genuinely cannot + // express is thinking-off, so that is what the refusal is proved against. runId: 'claude-nested-controls', - harness: 'claude', + harness: 'claude-code', profile: { - model: { reasoningEffort: 'high' }, + model: { reasoningEffort: 'none' }, }, dropped: ['model.reasoningEffort'], }, diff --git a/tests/runtime/worktree-cli-executor.test.ts b/tests/runtime/worktree-cli-executor.test.ts index 094193ed..994937f1 100644 --- a/tests/runtime/worktree-cli-executor.test.ts +++ b/tests/runtime/worktree-cli-executor.test.ts @@ -172,7 +172,7 @@ describe('createWorktreeCliExecutor', () => { const exec = createWorktreeCliExecutor({ repoRoot: '/workspace', profile: authoredProfile, - harness: 'claude', + harness: 'claude-code', taskPrompt: 'fix the off-by-one', runGit: makeFakeGit(state), runHarness, @@ -181,7 +181,7 @@ describe('createWorktreeCliExecutor', () => { await exec.execute(undefined, new AbortController().signal) expect(seen).toBeDefined() - expect(seen?.harness).toBe('claude') + expect(seen?.harness).toBe('claude-code') // The §1.5 fix: the authored systemPrompt reaches the harness PROMPT channel ... const promptArg = seen?.invocation?.args.find((a) => a.includes('fix the off-by-one')) expect(promptArg).toBe( @@ -258,7 +258,7 @@ describe('createWorktreeCliExecutor', () => { const exec = createWorktreeCliExecutor({ repoRoot: '/workspace', profile: authoredProfile, - harness: 'claude', + harness: 'claude-code', taskPrompt: 'x', runGit: makeFakeGit(freshGitState()), runHarness: vi.fn(), @@ -290,7 +290,7 @@ describe('createWorktreeCliExecutor', () => { ...authoredProfile, connections: [{ connectionId: 'github', capabilities: ['issues:read'] }], }, - harness: 'claude', + harness: 'claude-code', taskPrompt: 'x', runGit: makeFakeGit(state), runHarness: vi.fn(), @@ -432,7 +432,7 @@ describe('createWorktreeCliExecutor', () => { createWorktreeCliExecutor({ repoRoot: '/workspace', profile: reproducibleCodexProfile, - harness: 'claude', + harness: 'claude-code', taskPrompt: 'x', codexReproducible: true, }), @@ -477,7 +477,7 @@ describe('createWorktreeCliExecutor', () => { const exec = createWorktreeCliExecutor({ repoRoot: '/workspace', profile: authoredProfile, - harness: 'claude', + harness: 'claude-code', taskPrompt: 'x', budgetExempt: false, runGit: makeFakeGit(freshGitState()), @@ -508,7 +508,7 @@ describe('createWorktreeCliExecutor', () => { const executor = createWorktreeCliExecutor({ repoRoot: '/workspace', profile: authoredProfile, - harness: 'claude', + harness: 'claude-code', runGit: makeFakeGit(state), runHarness: vi.fn(async (options) => { seen = options @@ -534,7 +534,7 @@ describe('createWorktreeCliExecutor', () => { const exec = createWorktreeCliExecutor({ repoRoot: '/workspace', profile: authoredProfile, - harness: 'claude', + harness: 'claude-code', taskPrompt: 'x', runGit: makeFakeGit(freshGitState()), runHarness: vi.fn(), @@ -613,7 +613,7 @@ describe('createWorktreeCliExecutor', () => { const exec = createWorktreeCliExecutor({ repoRoot: '/workspace', profile: authoredProfile, - harness: 'claude', + harness: 'claude-code', taskPrompt: 'x', runGit: makeFakeGit(freshGitState()), runHarness: vi.fn(async () => ({ @@ -705,7 +705,7 @@ describe('createWorktreeCliExecutor', () => { createWorktreeCliExecutor({ repoRoot: '', profile: authoredProfile, - harness: 'claude', + harness: 'claude-code', taskPrompt: 'x', }), ).toThrow(/repoRoot required/) @@ -713,7 +713,7 @@ describe('createWorktreeCliExecutor', () => { createWorktreeCliExecutor({ repoRoot: '/workspace', profile: authoredProfile, - harness: 'claude', + harness: 'claude-code', taskPrompt: '', }), ).toThrow(/taskPrompt required/) @@ -721,7 +721,7 @@ describe('createWorktreeCliExecutor', () => { const noTask = createWorktreeCliExecutor({ repoRoot: '/workspace', profile: authoredProfile, - harness: 'claude', + harness: 'claude-code', }) await expect(noTask.execute(undefined, new AbortController().signal)).rejects.toThrow( /execute task required/, From 1e5ed5e9f5ea5a11432d15764960e35f168e3ac9 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 1 Aug 2026 02:06:52 -0600 Subject: [PATCH 2/2] fix(harness): codex keeps its OS sandbox under permission bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing the per-harness conditional made all three callers pass dangerouslySkipPermissions unconditionally, which turned codex's bypass argv on for every worktree worker. That argv was --dangerously-bypass-approvals-and-sandbox: codex's own help calls it 'Skip all confirmation prompts and execute commands without sandboxing. EXTREMELY DANGEROUS.' Writes stopped being confined to the worktree, so a prompt-injected worker could reach ~/.ssh, ~/.aws, and secrets on the box. The stated motivation was also wrong: codex exec has no approval gate to stall on — -a/--ask-for-approval exists only on the top-level codex, not the non-interactive exec subcommand. The flag bought nothing on the approvals axis and paid the entire sandbox for it. Codex now contributes --sandbox workspace-write -c approval_policy="never": same non-interactive editing, writes still confined. Same form the repo already proves out in CODEX_REPRODUCIBLE_ARGS. The option doc now states that a bypass never surrenders an OS sandbox, and a test pins that codex's argv never contains the sandbox-surrendering flag. --- CHANGELOG.md | 2 +- src/mcp/local-harness.ts | 14 ++++++++++++-- tests/mcp/local-harness.test.ts | 11 ++++++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4442ba..7dd7ca82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ A level a harness genuinely cannot express is still refused, and the refusal now **Permission bypass is a property of the workspace, not of one CLI.** `dangerouslySkipPermissions` was tested against `'claude'` in four places; three were caller-side duplication of the fourth, which dropped the flag for every other harness with no error. Each harness now declares its own bypass argv: - `claude-code` → `--dangerously-skip-permissions` (unchanged). -- `codex` → `--dangerously-bypass-approvals-and-sandbox`. NEW: a codex worker in a disposable worktree previously had its bypass request silently dropped and could stall on an approval gate. +- `codex` → `--sandbox workspace-write -c approval_policy="never"`. NEW: a codex worker in a disposable worktree previously had its bypass request silently dropped. It edits non-interactively now and **keeps its OS sandbox** — writes stay confined to the workspace. `--dangerously-bypass-approvals-and-sandbox` is deliberately NOT used: `codex exec` has no approval gate to stall on (`-a/--ask-for-approval` exists only on the top-level `codex`), so it would surrender the sandbox for nothing, and the sandbox is what keeps a worker's blast radius equal to its worktree. - `opencode` → nothing; `opencode run` has no approval gate. - Reproducible Codex is unchanged: its controlled config already pins `approval_policy="never"` with the sandbox intact, so the blanket bypass flag is suppressed rather than layered on top. Reproducible argv is byte-identical to 0.118.0. diff --git a/src/mcp/local-harness.ts b/src/mcp/local-harness.ts index ebab1201..b29df27b 100644 --- a/src/mcp/local-harness.ts +++ b/src/mcp/local-harness.ts @@ -149,7 +149,12 @@ const HARNESS_INVOCATIONS: Record = { levels: CODEX_REASONING_LEVELS, args: (level) => ['-c', `model_reasoning_effort="${level}"`], }, - permissionBypassArgs: () => ['--dangerously-bypass-approvals-and-sandbox'], + // Non-interactive editing WITHOUT surrendering the OS sandbox. `codex exec` has no approval + // gate to stall on (`-a/--ask-for-approval` exists only on the top-level `codex`), so + // `--dangerously-bypass-approvals-and-sandbox` buys nothing on the approvals axis and pays the + // entire sandbox for it — writes would escape the worktree to ~/.ssh, ~/.aws, and secrets. + // The sandbox is what MAKES the blast radius the worktree. Same form as CODEX_REPRODUCIBLE_ARGS. + permissionBypassArgs: () => ['--sandbox', 'workspace-write', '-c', 'approval_policy="never"'], }, opencode: { command: 'opencode', @@ -213,7 +218,12 @@ export interface HarnessInvocationOptions { /** Let an unattended process edit its isolated candidate worktree without stopping on an * interactive approval gate. A property of the WORKSPACE, not of any one CLI: each harness * contributes its own `permissionBypassArgs`, and a harness with no approval gate on its - * non-interactive path contributes nothing. */ + * non-interactive path contributes nothing. + * + * This never surrenders an OS sandbox. A harness that offers one flag for "skip approvals" and + * another for "skip approvals AND sandbox" must contribute the former: the sandbox is what keeps + * the blast radius equal to the worktree, and without it a prompt-injected worker reaches + * `~/.ssh`, `~/.aws`, and any secrets on the box. */ dangerouslySkipPermissions?: boolean /** Run Codex with benchmark-safe process controls and JSONL usage output. * Valid only for the Codex harness. */ diff --git a/tests/mcp/local-harness.test.ts b/tests/mcp/local-harness.test.ts index 35a5a40f..11b63f0c 100644 --- a/tests/mcp/local-harness.test.ts +++ b/tests/mcp/local-harness.test.ts @@ -1364,11 +1364,20 @@ describe('harnessInvocation (the §1.5 profile-aware mapper)', () => { 'go', '--dangerously-skip-permissions', ]) + // Codex keeps its OS sandbox. `--dangerously-bypass-approvals-and-sandbox` would surrender it, + // and `codex exec` has no approval gate to stall on in the first place, so that flag pays the + // whole sandbox for nothing. The sandbox is what keeps the blast radius equal to the worktree. expect(harnessInvocation('codex', { name: 'x' }, 'go', bypass).args).toEqual([ 'exec', 'go', - '--dangerously-bypass-approvals-and-sandbox', + '--sandbox', + 'workspace-write', + '-c', + 'approval_policy="never"', ]) + expect(harnessInvocation('codex', { name: 'x' }, 'go', bypass).args).not.toContain( + '--dangerously-bypass-approvals-and-sandbox', + ) // `opencode run` is non-interactive and declares no bypass argv, so asking for one is a no-op // rather than a silently dropped request for a flag that does not exist. expect(harnessInvocation('opencode', { name: 'x' }, 'go', bypass).args).toEqual(['run', 'go'])