diff --git a/README.md b/README.md index 7aa8ea1..ac73e41 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ and the first Claude reference-fleet path: - an explicit-capability Claude adapter with durable-message fallback; - an explicit-capability Codex adapter with resumable JSONL thread capture; - per-domain mixed Claude/Codex runtime planning and identity-bound MCP registration plans; +- invocation-scoped MCP injection on every Claude/Codex create and resume + launch, without changing the user's global runtime configuration; - durable-first Codex steering with native `queue` notification when available; - worker-first/Session-Manager-last fresh start and resume planning; - safe wind-down with active-work refusal and local resume snapshots. diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index eea0e30..31cc4e5 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -1482,9 +1482,13 @@ Exit gate: Claude and Codex occupy persistent domains in one Fleet and coordinate through TORCH rather than native vendor messaging. Implementation checkpoint (2026-09-27): the Codex adapter, mixed-runtime -planning, identity-bound MCP configuration plans, JSONL runtime-ID capture, -durable-first steering, and explicit degraded capabilities are implemented and -scenario-tested with virtualized runners. The exit gate remains open until an +planning, invocation-scoped identity-bound MCP configuration for every create +and resume launch, JSONL runtime-ID capture, durable-first steering, and +explicit degraded capabilities are implemented and scenario-tested with +virtualized runners plus a real local stdio MCP handshake. Claude receives an +ephemeral `--mcp-config`; Codex receives invocation-local `mcp_servers` +overrides, so TORCH does not rewrite either runtime's global configuration. +The exit gate remains open until an owner-authorized live Claude/Codex acceptance run demonstrates coordination; the automated suite intentionally incurs no provider usage. diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index 7ef6809..b09927e 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -109,7 +109,7 @@ test_integrity_note: - id: SCN-mixed-runtime given: one approved Codex worker domain and one Claude Session Manager with stable Fleet identities when: the mixed fleet starts and winds down through virtualized runtime boundaries - then: provider-specific commands and runtime IDs remain separate, the manager starts last, Codex completes idle, and only resident processes are stopped + then: provider-specific commands and runtime IDs remain separate, every create and resume launch receives identity-bound TORCH MCP access, the manager starts last, Codex completes idle, and only resident processes are stopped - id: SCN-backlog-lifecycle given: tracked tasks in the Session Manager worktree with domain owners, dependencies, and an integration lookup when: tasks move through assignment, work, blocking, verification, readiness, landing, and completion @@ -296,6 +296,7 @@ test_integrity_note: - MCP and CLI drifting onto separate business logic - launch plans that execute provider commands during preview - a mixed-runtime claim implemented as renamed Claude-only commands + - runtime sessions launching without their identity-bound TORCH tool surface - lost Codex thread IDs or native steering that bypasses durable messaging - manager-first startup or manager-first shutdown - passing receipts detached from the commit actually measured diff --git a/src/adapters/claude.mjs b/src/adapters/claude.mjs index f5dcc24..b2d2127 100644 --- a/src/adapters/claude.mjs +++ b/src/adapters/claude.mjs @@ -28,6 +28,16 @@ function commandRecord(command, args, cwd) { return { command, args, cwd, mutatesRuntime: true }; } +function mcpArgs(mcp) { + if (!mcp) return []; + if (typeof mcp.name !== 'string' || typeof mcp.command !== 'string' || !Array.isArray(mcp.args)) { + throw new TorchError('Claude MCP launch configuration is invalid', { code: 'INVALID_RUNTIME_INPUT' }); + } + return ['--mcp-config', JSON.stringify({ + mcpServers: { [mcp.name]: { command: mcp.command, args: mcp.args } }, + })]; +} + function mcpName(areaId) { return `torch-${areaId.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '')}`; } @@ -85,7 +95,7 @@ export class ClaudeRuntimeAdapter { }; } - createSession({ areaId, title, worktree, promptFile, firstMessage, model = 'opus', background = true } = {}) { + createSession({ areaId, title, worktree, promptFile, firstMessage, model = 'opus', background = true, mcp } = {}) { const runtimeSessionId = this.idFactory(); const args = []; if (background) args.push('--bg'); @@ -94,6 +104,7 @@ export class ClaudeRuntimeAdapter { '--session-id', runtimeSessionId, '-n', `TORCH · ${text(title ?? areaId, 'title')}`, '--append-system-prompt-file', text(promptFile, 'promptFile'), + ...mcpArgs(mcp), ); if (firstMessage) args.push(text(firstMessage, 'firstMessage')); return { @@ -102,10 +113,10 @@ export class ClaudeRuntimeAdapter { }; } - resumeSession({ areaId, runtimeSessionId, worktree, model = 'opus', message, background = true } = {}) { + resumeSession({ areaId, runtimeSessionId, worktree, model = 'opus', message, background = true, mcp } = {}) { const args = []; if (background) args.push('--bg'); - args.push('--model', text(model, 'model'), '--resume', text(runtimeSessionId, 'runtimeSessionId')); + args.push('--model', text(model, 'model'), ...mcpArgs(mcp), '--resume', text(runtimeSessionId, 'runtimeSessionId')); if (message) args.push(text(message, 'message')); return { adapter: this.name, areaId: text(areaId, 'areaId'), runtimeSessionId, diff --git a/src/adapters/codex.mjs b/src/adapters/codex.mjs index 06b00fd..f31f4c9 100644 --- a/src/adapters/codex.mjs +++ b/src/adapters/codex.mjs @@ -31,6 +31,18 @@ function commandRecord(command, args, cwd) { return { command, args, cwd, mutatesRuntime: true }; } +function mcpArgs(mcp) { + if (!mcp) return []; + if (typeof mcp.name !== 'string' || !/^[a-z0-9_-]+$/.test(mcp.name) + || typeof mcp.command !== 'string' || !Array.isArray(mcp.args)) { + throw new TorchError('Codex MCP launch configuration is invalid', { code: 'INVALID_RUNTIME_INPUT' }); + } + return [ + '-c', `mcp_servers.${mcp.name}.command=${JSON.stringify(mcp.command)}`, + '-c', `mcp_servers.${mcp.name}.args=${JSON.stringify(mcp.args)}`, + ]; +} + function mcpName(areaId) { return `torch-${areaId.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-|-$/g, '')}`; } @@ -89,19 +101,20 @@ export class CodexRuntimeAdapter { mutationPerformed: false, mcp: { name: mcpName(id), - command: this.executable, - args: ['mcp', 'add', mcpName(id), '--', this.nodeExecutable, entry, '--root', root, '--area', id], + command: this.nodeExecutable, + args: [entry, '--root', root, '--area', id], cwd: root, }, - note: 'Apply this identity-bound MCP registration only through an owner-approved Codex configuration change.', + note: 'Pass this identity-bound MCP server as an invocation-scoped Codex configuration.', }; } - createSession({ areaId, worktree, promptFile, firstMessage, model } = {}) { + createSession({ areaId, worktree, promptFile, firstMessage, model, mcp } = {}) { const id = text(areaId, 'areaId'); const cwd = text(worktree, 'worktree'); const args = [ 'exec', '--json', '--cd', cwd, '--sandbox', 'workspace-write', '--approve-for-me', + ...mcpArgs(mcp), ...optionalModelArgs(model), `${text(firstMessage, 'firstMessage')}\n\nRead and follow the TORCH domain prompt at ${text(promptFile, 'promptFile')}.`, ]; @@ -115,12 +128,13 @@ export class CodexRuntimeAdapter { }; } - resumeSession({ areaId, runtimeSessionId, worktree, message, model } = {}) { + resumeSession({ areaId, runtimeSessionId, worktree, message, model, mcp } = {}) { const id = text(areaId, 'areaId'); const sessionId = text(runtimeSessionId, 'runtimeSessionId'); const cwd = text(worktree, 'worktree'); const args = [ 'exec', '--json', '--cd', cwd, '--sandbox', 'workspace-write', '--approve-for-me', + ...mcpArgs(mcp), ...optionalModelArgs(model), 'resume', sessionId, text(message, 'message'), ]; return { diff --git a/src/runtime/lifecycle.mjs b/src/runtime/lifecycle.mjs index 70ec28e..812b2ac 100644 --- a/src/runtime/lifecycle.mjs +++ b/src/runtime/lifecycle.mjs @@ -1,11 +1,14 @@ import { execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { TorchError } from '../kernel/errors.mjs'; import { readInstallManifest } from '../kernel/install.mjs'; import { loadFleetDefinition } from '../kernel/worktrees.mjs'; import { validateRuntimeAdapter } from '../adapters/runtime.mjs'; +const MCP_ENTRY = fileURLToPath(new URL('../mcp/server.mjs', import.meta.url)); + function localStateRoot(manifest) { const record = (manifest.external ?? []).find((entry) => entry.type === 'local-state'); if (!record?.path) throw new TorchError('Installation manifest has no local state root', { code: 'LOCAL_STATE_MISSING' }); @@ -140,6 +143,15 @@ export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, f } validateRuntimeAdapter(runtimeAdapter); const identity = controlPlane.identity(area.id); + const runtimeIntegration = runtimeAdapter.configure({ + repositoryRoot, areaId: area.id, mcpEntry: MCP_ENTRY, + }); + if (runtimeIntegration?.mutationPerformed !== false || !runtimeIntegration?.mcp) { + blockers.push({ + areaId: area.id, code: 'RUNTIME_CONFIGURATION_INVALID', runtime, + }); + continue; + } const prompts = promptPaths(stateRoot, worktree, area.id); const runtimeConfig = config.runtimes?.[runtime] ?? {}; const model = area.model ?? runtimeConfig.model; @@ -148,11 +160,11 @@ export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, f const runtimePlan = shouldResume ? runtimeAdapter.resumeSession({ areaId: area.id, runtimeSessionId: identity.runtimeSessionId, worktree: worktree.path, - model, background, message: startupMessage(area.id, false), + model, background, message: startupMessage(area.id, false), mcp: runtimeIntegration.mcp, }) : runtimeAdapter.createSession({ areaId: area.id, title: area.title, worktree: worktree.path, promptFile: prompts.combined, - firstMessage: startupMessage(area.id, true), model, background, + firstMessage: startupMessage(area.id, true), model, background, mcp: runtimeIntegration.mcp, }); actions.push({ areaId: area.id, title: area.title, mode: shouldResume ? 'resume' : 'create', @@ -160,7 +172,8 @@ export function planFleetUp({ repositoryRoot, controlPlane, adapter, adapters, f requiresRuntimeIdCapture: runtimePlan.requiresRuntimeIdCapture ?? false, completionState: runtimePlan.completionState ?? 'starting', worktree: worktree.path, branch: worktree.branch, promptFile: prompts.combined, - promptSources: [prompts.common, prompts.area], launch: runtimePlan.launch, + promptSources: [prompts.common, prompts.area], mcp: runtimeIntegration.mcp, + launch: runtimePlan.launch, }); } @@ -200,6 +213,16 @@ export function planAreaUp({ repositoryRoot, controlPlane, areaId, adapter, adap } validateRuntimeAdapter(runtimeAdapter); const identity = controlPlane.identity(area.id); + const runtimeIntegration = runtimeAdapter.configure({ + repositoryRoot, areaId: area.id, mcpEntry: MCP_ENTRY, + }); + if (runtimeIntegration?.mutationPerformed !== false || !runtimeIntegration?.mcp) { + return { + action: 'area-up', projectId: manifest.projectId, areaId, fresh, + actions: [], blockers: [{ areaId, code: 'RUNTIME_CONFIGURATION_INVALID', runtime }], + canProceed: false, mutationPerformed: false, + }; + } const prompts = promptPaths(localStateRoot(manifest), worktree, area.id); const runtimeConfig = config.runtimes?.[runtime] ?? {}; const model = area.model ?? runtimeConfig.model; @@ -208,11 +231,11 @@ export function planAreaUp({ repositoryRoot, controlPlane, areaId, adapter, adap const runtimePlan = shouldResume ? runtimeAdapter.resumeSession({ areaId: area.id, runtimeSessionId: identity.runtimeSessionId, worktree: worktree.path, - model, background, message: startupMessage(area.id, false), + model, background, message: startupMessage(area.id, false), mcp: runtimeIntegration.mcp, }) : runtimeAdapter.createSession({ areaId: area.id, title: area.title, worktree: worktree.path, promptFile: prompts.combined, - firstMessage: startupMessage(area.id, true), model, background, + firstMessage: startupMessage(area.id, true), model, background, mcp: runtimeIntegration.mcp, }); const action = { areaId: area.id, title: area.title, mode: shouldResume ? 'resume' : 'create', @@ -220,7 +243,8 @@ export function planAreaUp({ repositoryRoot, controlPlane, areaId, adapter, adap requiresRuntimeIdCapture: runtimePlan.requiresRuntimeIdCapture ?? false, completionState: runtimePlan.completionState ?? 'starting', worktree: worktree.path, branch: worktree.branch, promptFile: prompts.combined, - promptSources: [prompts.common, prompts.area], launch: runtimePlan.launch, + promptSources: [prompts.common, prompts.area], mcp: runtimeIntegration.mcp, + launch: runtimePlan.launch, }; return { action: 'area-up', projectId: manifest.projectId, areaId, fresh, diff --git a/test/fleet/lifecycle.test.mjs b/test/fleet/lifecycle.test.mjs index b773cc1..b7d7014 100644 --- a/test/fleet/lifecycle.test.mjs +++ b/test/fleet/lifecycle.test.mjs @@ -48,6 +48,8 @@ test('SCN-fleet-fresh-resume: workers start before manager and stable identities const fresh = planFleetUp({ repositoryRoot: fixture.root, controlPlane: control, adapter, fresh: true }); assert.equal(fresh.actions.at(-1).areaId, 'session-manager'); assert.equal(fresh.actions.every((action) => action.mode === 'create'), true); + assert.equal(fresh.actions.every((action) => action.mcp.args.includes(action.areaId)), true); + assert.equal(fresh.actions.every((action) => action.launch.args.includes('--mcp-config')), true); assert.equal(fresh.mutationPerformed, false); const launches = []; @@ -133,7 +135,10 @@ test('SCN-mixed-runtime: Codex and Claude share stable identities while planning assert.equal(plan.actions[0].areaId, fixture.worker); assert.equal(plan.actions[0].runtime, 'codex'); assert.equal(plan.actions[0].launch.args[0], 'exec'); + assert.equal(plan.actions[0].launch.args.some((arg) => arg.includes('mcp_servers.')), true); + assert.equal(plan.actions[0].launch.args.some((arg) => arg.includes(fixture.worker)), true); assert.equal(plan.actions.at(-1).runtime, 'claude'); + assert.equal(plan.actions.at(-1).launch.args.includes('--mcp-config'), true); assert.equal(plan.actions.at(-1).runtimeSessionId, 'claude-manager-1'); const started = startFleet({ diff --git a/test/fleet/mcp-adapter.test.mjs b/test/fleet/mcp-adapter.test.mjs index c12474e..e760db1 100644 --- a/test/fleet/mcp-adapter.test.mjs +++ b/test/fleet/mcp-adapter.test.mjs @@ -83,12 +83,16 @@ test('SCN-claude-adapter: planning is deterministic, capabilities are honest, an assert.equal(configuration.mcp.args.at(-1), context.worker); const launch = adapter.createSession({ areaId: context.worker, title: 'Core', worktree: '/tmp/core', promptFile: '/tmp/core.md', - firstMessage: 'Resume TASK-1.', model: 'opus', + firstMessage: 'Resume TASK-1.', model: 'opus', mcp: configuration.mcp, }); assert.equal(launch.runtimeSessionId, 'runtime-123'); assert.deepEqual(launch.launch.args.slice(0, 7), [ '--bg', '--model', 'opus', '--session-id', 'runtime-123', '-n', 'TORCH · Core', ]); + const mcpIndex = launch.launch.args.indexOf('--mcp-config'); + const launchMcp = JSON.parse(launch.launch.args[mcpIndex + 1]).mcpServers[`torch-${context.worker}`]; + assert.equal(launchMcp.command, process.execPath); + assert.equal(launchMcp.args.at(-1), context.worker); assert.equal(calls.length, 0, 'creating a launch plan must not execute Claude'); assert.equal(adapter.getStatus({ runtimeSessionId: 'runtime-123' }).status, 'idle'); assert.equal(calls.length, 1); @@ -110,14 +114,13 @@ test('SCN-codex-adapter: turns are resumable, identity-bound, durable, and capab repositoryRoot: context.root, areaId: context.worker, mcpEntry: MCP_SERVER, }); assert.equal(configuration.mutationPerformed, false); - assert.deepEqual(configuration.mcp.args.slice(0, 4), [ - 'mcp', 'add', `torch-${context.worker}`, '--', - ]); + assert.equal(configuration.mcp.command, process.execPath); + assert.equal(configuration.mcp.args[0], MCP_SERVER); assert.equal(configuration.mcp.args.includes(context.worker), true); const launch = adapter.createSession({ areaId: context.worker, worktree: '/tmp/codex-core', promptFile: '/tmp/codex-core.md', - firstMessage: 'Resume TASK-2.', model: 'gpt-test', + firstMessage: 'Resume TASK-2.', model: 'gpt-test', mcp: configuration.mcp, }); assert.equal(launch.runtimeSessionId, null); assert.equal(launch.requiresRuntimeIdCapture, true); @@ -125,6 +128,8 @@ test('SCN-codex-adapter: turns are resumable, identity-bound, durable, and capab assert.deepEqual(launch.launch.args.slice(0, 7), [ 'exec', '--json', '--cd', '/tmp/codex-core', '--sandbox', 'workspace-write', '--approve-for-me', ]); + assert.equal(launch.launch.args.includes(`mcp_servers.torch-${context.worker}.command=${JSON.stringify(process.execPath)}`), true); + assert.equal(launch.launch.args.some((arg) => arg.includes(MCP_SERVER)), true); assert.equal(calls.length, 0, 'creating a launch plan must not execute Codex'); assert.equal(adapter.captureRuntimeId({ areaId: context.worker, @@ -133,10 +138,11 @@ test('SCN-codex-adapter: turns are resumable, identity-bound, durable, and capab const resumed = adapter.resumeSession({ areaId: context.worker, runtimeSessionId: 'codex-thread-123', worktree: '/tmp/codex-core', - message: 'Continue.', + message: 'Continue.', mcp: configuration.mcp, }); assert.equal(resumed.launch.args.includes('resume'), true); assert.equal(resumed.launch.args.includes('codex-thread-123'), true); + assert.equal(resumed.launch.args.some((arg) => arg.includes(MCP_SERVER)), true); const delivery = adapter.sendOrSteer({ controlPlane: control, recipient: context.worker, body: 'Check your durable inbox.',