diff --git a/.changeset/codex-session-mcp.md b/.changeset/codex-session-mcp.md new file mode 100644 index 000000000..a94065cff --- /dev/null +++ b/.changeset/codex-session-mcp.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": patch +--- + +Attach generated Sapiom MCP configuration to Codex sessions on launch and resume, using session-specific server names and environment-based credentials while preserving existing Codex settings. Invalid or unreadable generated configuration now reports a launch error instead of silently starting without MCP servers. diff --git a/packages/harness/README.md b/packages/harness/README.md index 41b401f33..d8b830be2 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -34,6 +34,17 @@ system prompt, in whatever project directory you choose. Uninstall: `rm -rf ~/.sapiom/harness` (all harness-owned state lives there). +Codex receives the generated remote Sapiom, local `sapiom-dev`, and optional +Agent Map MCP configuration on every session launch and resume. Studio uses session-specific +server names such as `sapiom-dev-` and identifies them in the +agent's instructions. This keeps existing Codex MCP registrations intact and +avoids inheriting old credentials or conflicting transports from a server with +the same name. Credentials are passed through Codex's environment and cleared from +shell-tool environments; they never appear in command arguments. Authoring-process +settings stay on the MCP server. Studio does not write to your Codex `config.toml`. If a generated MCP +file cannot be read or parsed, the session reports an error so you can start a +new session to regenerate it. + ## Telemetry With explicit opt-in, Agent Studio collects usage events (prompts, tool calls, diff --git a/packages/harness/src/core/adapters/codex-mcp.test.ts b/packages/harness/src/core/adapters/codex-mcp.test.ts new file mode 100644 index 000000000..b0195bc87 --- /dev/null +++ b/packages/harness/src/core/adapters/codex-mcp.test.ts @@ -0,0 +1,340 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { CodexAdapter } from "./codex.js"; +import type { SpawnSpec } from "../../shared/types.js"; + +function serverArg(spec: SpawnSpec, name: string): string | undefined { + return spec.args.find((arg) => + new RegExp(`^mcp_servers\\.${name}-[a-f0-9]{12}=`).test(arg), + ); +} + +function serverConfig(spec: SpawnSpec, name: string): string | undefined { + const arg = serverArg(spec, name); + return arg?.slice(arg.indexOf("=") + 1); +} + +describe("Codex per-session MCP configuration", () => { + let dir: string; + let mcpConfigFile: string; + const adapter = new CodexAdapter({ binary: "fake-codex" }); + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "harness-codex-mcp-")); + mcpConfigFile = join(dir, "mcp-config.json"); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + const options = () => ({ + harnessSessionId: "session-1", + cwd: dir, + mcpConfigFile, + }); + const writeConfig = (mcpServers: unknown) => + writeFile(mcpConfigFile, JSON.stringify({ mcpServers })); + + it("attaches remote, local, and Agent Map MCP servers on launch and resume without putting credentials in argv", async () => { + await writeConfig({ + sapiom: { + type: "http", + url: "https://api.sapiom.ai/v1/mcp", + headers: { "x-api-key": "private-sapiom-api-key" }, + }, + "sapiom-dev": { + command: "/Applications/Agent Studio.app/Contents/MacOS/Agent Studio", + args: ["/Applications/Agent Studio.app/Contents/Resources/mcp.js"], + env: { + ELECTRON_RUN_AS_NODE: "1", + SAPIOM_ENVIRONMENT: "staging", + SAPIOM_HARNESS_VERSION: "0.14.0", + SAPIOM_API_KEY: "private-stdio-api-key", + }, + }, + "agent-map": { + type: "http", + url: "http://127.0.0.1:4312/mcp/agent-map", + headers: { Authorization: "Bearer private-map-token" }, + }, + }); + const before = await readFile(mcpConfigFile, "utf8"); + const parentApiKey = process.env.SAPIOM_API_KEY; + + for (const spec of [ + adapter.launch(options()), + adapter.resume("rollout-1", options()), + ]) { + const remote = serverArg(spec, "sapiom"); + expect(remote).toContain('"url" = "https://api.sapiom.ai/v1/mcp"'); + expect(remote).toContain( + '"env_http_headers" = { "x-api-key" = "SAPIOM_CODEX_MCP_0_HEADER_0" }', + ); + expect(spec.env.SAPIOM_CODEX_MCP_0_HEADER_0).toBe( + "private-sapiom-api-key", + ); + + const local = serverArg(spec, "sapiom-dev"); + expect(local).toContain( + '"command" = "/Applications/Agent Studio.app/Contents/MacOS/Agent Studio"', + ); + expect(local).toContain( + '"args" = ["/Applications/Agent Studio.app/Contents/Resources/mcp.js"]', + ); + expect(local).toContain('"env_vars" = ["SAPIOM_API_KEY"]'); + expect(local).toContain( + '"env" = { "ELECTRON_RUN_AS_NODE" = "1", "SAPIOM_ENVIRONMENT" = "staging", "SAPIOM_HARNESS_VERSION" = "0.14.0" }', + ); + expect(spec.env.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(spec.env.SAPIOM_ENVIRONMENT).toBeUndefined(); + expect(spec.env.SAPIOM_HARNESS_VERSION).toBeUndefined(); + expect(spec.env.SAPIOM_API_KEY).toBe("private-stdio-api-key"); + for (const variable of Object.keys(spec.env)) { + expect(spec.args).toContain( + `shell_environment_policy.set.${variable}=""`, + ); + } + + const agentMap = serverArg(spec, "agent-map"); + expect(agentMap).toContain( + '"env_http_headers" = { "Authorization" = "SAPIOM_CODEX_MCP_2_HEADER_0" }', + ); + expect(spec.env.SAPIOM_CODEX_MCP_2_HEADER_0).toBe( + "Bearer private-map-token", + ); + expect(spec.args.join(" ")).not.toContain("private-"); + expect(spec.args.some((arg) => arg.startsWith("mcp_servers="))).toBe( + false, + ); + } + expect(await readFile(mcpConfigFile, "utf8")).toBe(before); + expect(process.env.SAPIOM_API_KEY).toBe(parentApiKey); + }); + + it("keeps managed runtime arguments and places MCP options before the initial prompt", async () => { + await writeConfig({ + sapiom: { + type: "http", + url: "https://api.sapiom.ai/v1/mcp", + headers: { "x-api-key": "private-managed-key" }, + }, + }); + const managed = new CodexAdapter({ + binary: "/Applications/Agent Studio.app/Contents/MacOS/Agent Studio", + binaryArgs: ["/managed/codex/launcher.cjs"], + binaryEnv: { ELECTRON_RUN_AS_NODE: "1" }, + }); + const opts = { ...options(), initialPrompt: "Create an agent" }; + const launch = managed.launch(opts); + const resume = managed.resume("rollout-1", opts); + for (const spec of [launch, resume]) { + expect(spec.args[0]).toBe("/managed/codex/launcher.cjs"); + expect(spec.env.ELECTRON_RUN_AS_NODE).toBe("1"); + expect(spec.env.SAPIOM_CODEX_MCP_0_HEADER_0).toBe("private-managed-key"); + expect(serverArg(spec, "sapiom")).toBeDefined(); + } + expect(launch.args.slice(-2)).toEqual(["--", opts.initialPrompt]); + expect(launch.args.indexOf(serverArg(launch, "sapiom")!)).toBeLessThan( + launch.args.indexOf("--"), + ); + expect(resume.args.slice(1, 3)).toEqual(["resume", "rollout-1"]); + expect(resume.args).not.toContain(opts.initialPrompt); + }); + + it("supports signed-out sessions and the default npx launcher", async () => { + await writeConfig({ + sapiom: { type: "http", url: "https://api.sapiom.ai/v1/mcp" }, + "sapiom-dev": { command: "npx", args: ["-y", "@sapiom/mcp@latest"] }, + }); + const spec = adapter.launch(options()); + expect(serverConfig(spec, "sapiom")).toBe( + '{ "url" = "https://api.sapiom.ai/v1/mcp" }', + ); + expect(serverConfig(spec, "sapiom-dev")).toBe( + '{ "command" = "npx", "args" = ["-y", "@sapiom/mcp@latest"] }', + ); + expect(spec.env).toEqual({}); + }); + + it("keeps the profile prompt and identifies stable MCP aliases unique to each Studio session", async () => { + await writeConfig({ "sapiom-dev": { command: "node", args: ["mcp.js"] } }); + const systemPromptFile = join(dir, "system-prompt.txt"); + await writeFile( + systemPromptFile, + "Build useful agents.\nKeep the user's constraints.", + ); + const opts = { ...options(), systemPromptFile }; + const launched = adapter.launch(opts); + const resumed = adapter.resume("rollout", opts); + const other = adapter.launch({ ...opts, harnessSessionId: "session-2" }); + const alias = serverArg(launched, "sapiom-dev")! + .split("=")[0] + .slice("mcp_servers.".length); + expect(serverArg(resumed, "sapiom-dev")).toBe( + serverArg(launched, "sapiom-dev"), + ); + expect(serverArg(other, "sapiom-dev")).not.toBe( + serverArg(launched, "sapiom-dev"), + ); + for (const spec of [launched, resumed]) { + const overrides = spec.args.filter((arg) => + arg.startsWith("developer_instructions="), + ); + expect(overrides).toHaveLength(1); + const prompt: string = JSON.parse( + overrides[0].slice("developer_instructions=".length), + ); + expect(prompt).toContain( + "Build useful agents.\nKeep the user's constraints.", + ); + expect(prompt).toContain(`sapiom-dev is registered as ${alias}`); + expect(prompt).toContain("References to the original server names"); + } + }); + + it("reads regenerated credentials on resume instead of reusing launch credentials", async () => { + const servers = (key: string) => ({ + sapiom: { + type: "http", + url: "https://api.sapiom.ai/v1/mcp", + headers: { "x-api-key": key }, + }, + }); + await writeConfig(servers("old-session-key")); + const launched = adapter.launch(options()); + await writeConfig(servers("new-session-key")); + const resumed = adapter.resume("rollout-1", options()); + expect(launched.env.SAPIOM_CODEX_MCP_0_HEADER_0).toBe("old-session-key"); + expect(resumed.env.SAPIOM_CODEX_MCP_0_HEADER_0).toBe("new-session-key"); + expect(resumed.args.slice(0, 2)).toEqual(["resume", "rollout-1"]); + expect(serverArg(resumed, "sapiom")).toBe(serverArg(launched, "sapiom")); + }); + + it("uses a newly issued Agent Map capability over the copy in the generated file", async () => { + await writeConfig({ + "agent-map": { + type: "http", + url: "http://127.0.0.1:1/mcp", + headers: { Authorization: "Bearer old-token" }, + }, + }); + const spec = adapter.launch({ + ...options(), + agentMapMcp: { + url: "http://127.0.0.1:2/mcp", + bearerToken: "fresh-token", + }, + }); + expect( + spec.args.filter((arg) => arg.startsWith("mcp_servers.agent-map-")), + ).toHaveLength(1); + expect(spec.args.join(" ")).toContain('"url" = "http://127.0.0.1:2/mcp"'); + expect(spec.args.join(" ")).not.toMatch(/old-token|fresh-token/); + expect(spec.env).toEqual({ SAPIOM_AGENT_MAP_CAPABILITY: "fresh-token" }); + }); + + it("escapes Windows paths, quotes, newlines, and TOML control characters", async () => { + await writeConfig({ + "sapiom-dev": { + command: 'C:\\Program Files\\Agent "Studio"\\node.exe', + args: ["line\nnext\u007f", "C:\\mcp\\index.js"], + }, + }); + const spec = adapter.launch(options()); + expect(serverConfig(spec, "sapiom-dev")).toBe( + '{ "command" = "C:\\\\Program Files\\\\Agent \\"Studio\\"\\\\node.exe", "args" = ["line\\nnext\\u007f", "C:\\\\mcp\\\\index.js"] }', + ); + }); + + it.each([ + ["missing server map", {}], + ["array server map", { mcpServers: [] }], + [ + "dotted server name", + { mcpServers: { "sapiom.injected": { command: "node" } } }, + ], + [ + "wrong header type", + { + mcpServers: { + sapiom: { + type: "http", + url: "https://api.sapiom.ai", + headers: { "x-api-key": 42 }, + }, + }, + }, + ], + [ + "unknown transport", + { mcpServers: { sapiom: { type: "sse", url: "https://api.sapiom.ai" } } }, + ], + [ + "mixed transports", + { + mcpServers: { + sapiom: { + type: "http", + url: "https://api.sapiom.ai", + command: "node", + }, + }, + }, + ], + [ + "non-string argument", + { mcpServers: { "sapiom-dev": { command: "node", args: [42] } } }, + ], + [ + "invalid env name", + { + mcpServers: { + "sapiom-dev": { command: "node", env: { "bad=name": "private-key" } }, + }, + }, + ], + ])( + "rejects %s visibly instead of silently dropping MCP wiring", + async (_label, config) => { + await writeFile(mcpConfigFile, JSON.stringify(config)); + for (const launch of [ + () => adapter.launch(options()), + () => adapter.resume("rollout", options()), + ]) { + expect(launch).toThrow( + "Could not load the generated Codex MCP configuration. Start a new session to regenerate it.", + ); + } + }, + ); + + it("does not expose JSON parser snippets or file paths when a credential-bearing file is malformed", async () => { + const diagnostic = vi.spyOn(console, "error").mockImplementation(() => {}); + await writeFile(mcpConfigFile, 'private-api-key: "broken JSON"'); + expect(() => adapter.launch(options())).toThrow( + /^Could not load the generated Codex MCP configuration\./, + ); + try { + adapter.launch(options()); + } catch (error) { + expect(String(error)).not.toContain("private-api-key"); + expect(String(error)).not.toContain(mcpConfigFile); + } + expect(diagnostic.mock.calls.flat().join(" ")).toContain("Invalid JSON"); + expect(diagnostic.mock.calls.flat().join(" ")).not.toContain( + "private-api-key", + ); + expect(diagnostic.mock.calls.flat().join(" ")).not.toContain(mcpConfigFile); + diagnostic.mockRestore(); + }); + + it("fails visibly when the generated MCP file is unavailable", () => { + expect(() => adapter.launch(options())).toThrow( + "Could not load the generated Codex MCP configuration", + ); + }); +}); diff --git a/packages/harness/src/core/adapters/codex-mcp.ts b/packages/harness/src/core/adapters/codex-mcp.ts new file mode 100644 index 000000000..73c072b24 --- /dev/null +++ b/packages/harness/src/core/adapters/codex-mcp.ts @@ -0,0 +1,206 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +import type { LaunchOpts, SpawnSpec } from "../../shared/types.js"; + +type TomlValue = string | string[] | { [key: string]: TomlValue }; + +class InvalidMcpConfigError extends Error {} + +// Studio emits these non-secret values to configure the authoring process. +// They belong on that server, especially Electron's process-mode flag. +const STDIO_SETTINGS = new Set([ + "ELECTRON_RUN_AS_NODE", + "SAPIOM_ENVIRONMENT", + "SAPIOM_HARNESS_VERSION", +]); + +/** JSON string escaping also works for TOML basic strings, except that TOML + * requires DEL to be escaped as well. Inline-table keys are always quoted. */ +function toml(value: TomlValue): string { + if (typeof value === "string") + return JSON.stringify(value).replace(/\u007f/g, "\\u007f"); + if (Array.isArray(value)) return `[${value.map(toml).join(", ")}]`; + return `{ ${Object.entries(value) + .map(([key, item]) => `${toml(key)} = ${toml(item)}`) + .join(", ")} }`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringRecord(value: unknown): Record { + if ( + !isRecord(value) || + Object.values(value).some((item) => typeof item !== "string") + ) { + throw new InvalidMcpConfigError("Invalid string map"); + } + return value as Record; +} + +/** + * Adapt Studio's generated Claude-shaped MCP file to Codex's per-process + * config overrides. Codex deep-merges even whole-table overrides, so stable + * session aliases prevent existing same-name transport/auth fields from + * bleeding into Studio's servers. All user servers remain unchanged and no + * config.toml is written. The prompt identifies Studio's aliases explicitly. + * + * Known stdio settings stay on the MCP server. Credentials and other stdio + * values reach Codex through its environment, never argv, and are cleared + * from shell-tool environments. Per-variable overrides preserve the user's + * other shell settings. No persistent `codex mcp add` registration is needed. + */ +export function buildCodexMcpConfig( + opts: Pick, +): Pick & { instructions?: string } { + const args: string[] = []; + const aliases: string[] = []; + const suffix = createHash("sha256") + .update(opts.harnessSessionId) + .digest("hex") + .slice(0, 12); + const env: Record = {}; + const bindEnv = (name: string, value: string): void => { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name === "__proto__") { + throw new InvalidMcpConfigError("Invalid environment name"); + } + if ( + Object.prototype.hasOwnProperty.call(env, name) && + env[name] !== value + ) { + throw new InvalidMcpConfigError("Conflicting environment values"); + } + env[name] = value; + }; + const addServer = (name: string, config: Record): void => { + // Codex -c paths split on dots; restricting generated server names also + // prevents a name from addressing another part of the user's config. + if (!/^[A-Za-z0-9_-]+$/.test(name)) + throw new InvalidMcpConfigError("Invalid server name"); + const alias = `${name}-${suffix}`; + aliases.push(`${name} is registered as ${alias}`); + args.push("-c", `mcp_servers.${alias}=${toml(config)}`); + }; + + try { + if (opts.mcpConfigFile) { + const parsed: unknown = JSON.parse( + readFileSync(opts.mcpConfigFile, "utf8"), + ); + if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) { + throw new InvalidMcpConfigError("Invalid MCP configuration"); + } + for (const [index, [name, server]] of Object.entries( + parsed.mcpServers, + ).entries()) { + // A freshly issued capability supplied separately wins over a copy + // in the generated file. Keep compatibility with callers using only + // agentMapMcp as well. + if (name === "agent-map" && opts.agentMapMcp) continue; + if (!isRecord(server)) + throw new InvalidMcpConfigError("Invalid server"); + if (server.type === "http") { + if ( + typeof server.url !== "string" || + !/^https?:\/\//.test(server.url) + ) { + throw new InvalidMcpConfigError("Invalid HTTP URL"); + } + if ( + Object.keys(server).some( + (key) => !["type", "url", "headers"].includes(key), + ) + ) { + throw new InvalidMcpConfigError("Unsupported HTTP configuration"); + } + const config: Record = { url: server.url }; + if (server.headers !== undefined) { + const headers = stringRecord(server.headers); + config.env_http_headers = Object.fromEntries( + Object.entries(headers).map(([header, value], headerIndex) => { + const variable = `SAPIOM_CODEX_MCP_${index}_HEADER_${headerIndex}`; + bindEnv(variable, value); + return [header, variable]; + }), + ); + } + addServer(name, config); + } else { + if ( + (server.type !== undefined && server.type !== "stdio") || + typeof server.command !== "string" || + !server.command || + (server.args !== undefined && + (!Array.isArray(server.args) || + server.args.some((arg) => typeof arg !== "string"))) || + Object.keys(server).some( + (key) => !["type", "command", "args", "env"].includes(key), + ) + ) { + throw new InvalidMcpConfigError("Invalid stdio configuration"); + } + const config: Record = { + command: server.command, + ...(server.args !== undefined + ? { args: server.args as string[] } + : {}), + }; + if (server.env !== undefined) { + const values = stringRecord(server.env); + const local: Record = {}; + const forwarded: string[] = []; + for (const [variable, value] of Object.entries(values)) { + if (STDIO_SETTINGS.has(variable)) local[variable] = value; + else { + bindEnv(variable, value); + forwarded.push(variable); + } + } + if (Object.keys(local).length > 0) config.env = local; + if (forwarded.length > 0) config.env_vars = forwarded; + } + addServer(name, config); + } + } + } + if (opts.agentMapMcp) { + bindEnv("SAPIOM_AGENT_MAP_CAPABILITY", opts.agentMapMcp.bearerToken); + addServer("agent-map", { + url: opts.agentMapMcp.url, + bearer_token_env_var: "SAPIOM_AGENT_MAP_CAPABILITY", + }); + } + } catch (error) { + // Parser and filesystem messages can contain credentials or private paths. + const reason = + error instanceof InvalidMcpConfigError + ? error.message + : error instanceof SyntaxError + ? "Invalid JSON" + : isRecord(error) && + ["ENOENT", "EACCES", "EPERM"].includes(String(error.code)) + ? String(error.code) + : "Read failure"; + console.error(`[codex adapter] generated MCP configuration: ${reason}`); + throw new Error( + "Could not load the generated Codex MCP configuration. Start a new session to regenerate it.", + ); + } + // Codex's MCP clients read its process env; shell tools use a separate + // policy. Blank only our forwarded values there, without replacing any + // existing exclusions or unrelated user-provided environment settings. + for (const variable of Object.keys(env)) { + args.push("-c", `shell_environment_policy.set.${variable}=""`); + } + return { + args, + env, + ...(aliases.length > 0 + ? { + instructions: `Studio MCP server names for this session: ${aliases.join("; ")}. References to the original server names in your other instructions mean these session-specific registrations. Use their Sapiom tools; they carry this session's configuration and credentials.`, + } + : {}), + }; +} diff --git a/packages/harness/src/core/adapters/codex.test.ts b/packages/harness/src/core/adapters/codex.test.ts index c452b6ab2..77df51ca8 100644 --- a/packages/harness/src/core/adapters/codex.test.ts +++ b/packages/harness/src/core/adapters/codex.test.ts @@ -127,12 +127,11 @@ describe("CodexAdapter", () => { expect(spec.env).toEqual({}); }); - it("ignores mcpConfigFile/settingsFile — Codex has no per-session injection point for either", () => { + it("ignores the Claude-only settingsFile", () => { const adapter = new CodexAdapter({ binary: "fake-codex" }); const spec = adapter.launch({ harnessSessionId: "h1", cwd: "/tmp/proj", - mcpConfigFile: "/tmp/proj/.sapiom/mcp.json", settingsFile: "/tmp/proj/.sapiom/settings.json", }); expect(spec.args).toEqual([ @@ -155,11 +154,8 @@ describe("CodexAdapter", () => { adapter.launch({ harnessSessionId: "h1", cwd: "/tmp/proj", agentMapMcp }), adapter.resume("rollout", { harnessSessionId: "h1", cwd: "/tmp/proj", agentMapMcp }), ]) { - expect(spec.args).toContain( - `mcp_servers.agent-map.url=${JSON.stringify(agentMapMcp.url)}`, - ); - expect(spec.args).toContain( - 'mcp_servers.agent-map.bearer_token_env_var="SAPIOM_AGENT_MAP_CAPABILITY"', + expect(spec.args.find((arg) => arg.startsWith("mcp_servers.agent-map-"))).toContain( + `{ "url" = ${JSON.stringify(agentMapMcp.url)}, "bearer_token_env_var" = "SAPIOM_AGENT_MAP_CAPABILITY" }`, ); expect(spec.args.join(" ")).not.toContain(agentMapMcp.bearerToken); expect(spec.env).toEqual({ diff --git a/packages/harness/src/core/adapters/codex.ts b/packages/harness/src/core/adapters/codex.ts index bab63fe7e..2e72e4446 100644 --- a/packages/harness/src/core/adapters/codex.ts +++ b/packages/harness/src/core/adapters/codex.ts @@ -35,6 +35,7 @@ import type { SpawnSpec, } from "../../shared/types.js"; import { stripAnsi } from "../strip-ansi.js"; +import { buildCodexMcpConfig } from "./codex-mcp.js"; const execFileAsync = promisify(execFile); @@ -321,27 +322,23 @@ export class CodexAdapter implements HarnessAdapter { } launch(opts: LaunchOpts): SpawnSpec { - const args = buildConfigArgs(opts); + const mcp = buildCodexMcpConfig(opts); + const args = [...buildConfigArgs(opts, mcp.instructions), ...mcp.args]; if (opts.initialPrompt) args.push("--", opts.initialPrompt); return { command: this.binary, args: [...this.binaryArgs, ...args], - // Codex has no analog to Claude's CLAUDECODE nested-agent guard; no env - // overrides are needed for a fresh launch. - env: { ...this.binaryEnv, ...(opts.agentMapMcp - ? { SAPIOM_AGENT_MAP_CAPABILITY: opts.agentMapMcp.bearerToken } - : {}) }, + env: { ...this.binaryEnv, ...mcp.env }, cwd: opts.cwd, }; } resume(agentSessionId: string, opts: LaunchOpts): SpawnSpec { + const mcp = buildCodexMcpConfig(opts); return { command: this.binary, - args: [...this.binaryArgs, "resume", agentSessionId, ...buildConfigArgs(opts)], - env: { ...this.binaryEnv, ...(opts.agentMapMcp - ? { SAPIOM_AGENT_MAP_CAPABILITY: opts.agentMapMcp.bearerToken } - : {}) }, + args: [...this.binaryArgs, "resume", agentSessionId, ...buildConfigArgs(opts, mcp.instructions), ...mcp.args], + env: { ...this.binaryEnv, ...mcp.env }, cwd: opts.cwd, }; } @@ -443,12 +440,9 @@ export class CodexAdapter implements HarnessAdapter { } /** - * Codex has no single-flag equivalent to Claude's `--append-system-prompt` / - * `--mcp-config` — MCP servers are registered globally via `codex mcp add` - * (a persistent config.toml mutation, which the harness's "zero config - * mutation" design deliberately avoids), so `opts.mcpConfigFile` / - * `opts.settingsFile` are intentionally unused here. System-prompt injection - * uses the generic `-c key=value` override mechanism instead. + * System-prompt injection uses Codex's generic `-c key=value` overrides. + * The MCP overrides are built separately by buildCodexMcpConfig; the + * Claude-only hook settings in opts.settingsFile remain unused. * * Confirmed against a locally installed codex-cli 0.134.0: `-c * model_instructions_file=` is a real, recognized key — but if that @@ -469,7 +463,7 @@ export class CodexAdapter implements HarnessAdapter { * one rather than passing a broken reference that's guaranteed to kill the * process on startup. */ -function buildConfigArgs(opts: LaunchOpts): string[] { +function buildConfigArgs(opts: LaunchOpts, mcpInstructions?: string): string[] { const args = [ "-c", "check_for_update_on_startup=false", @@ -489,24 +483,21 @@ function buildConfigArgs(opts: LaunchOpts): string[] { "-c", 'sandbox_mode="workspace-write"', ]; - if (opts.agentMapMcp) { - args.push( - "-c", - `mcp_servers.agent-map.url=${JSON.stringify(opts.agentMapMcp.url)}`, - "-c", - 'mcp_servers.agent-map.bearer_token_env_var="SAPIOM_AGENT_MAP_CAPABILITY"', - ); - } + const instructions: string[] = []; if (opts.systemPromptFile) { try { const prompt = readFileSync(opts.systemPromptFile, "utf8"); - args.push("-c", `developer_instructions=${JSON.stringify(prompt)}`); + instructions.push(prompt); } catch (err) { console.error( `[codex adapter] could not read systemPromptFile "${opts.systemPromptFile}" — launching without an injected system prompt: ${(err as Error).message}`, ); } } + if (mcpInstructions) instructions.push(mcpInstructions); + if (instructions.length > 0) { + args.push("-c", `developer_instructions=${JSON.stringify(instructions.join("\n\n"))}`); + } return args; } diff --git a/packages/harness/src/core/inject/mcp-config.ts b/packages/harness/src/core/inject/mcp-config.ts index 9110b8e0c..504c2c055 100644 --- a/packages/harness/src/core/inject/mcp-config.ts +++ b/packages/harness/src/core/inject/mcp-config.ts @@ -56,11 +56,12 @@ export interface McpConfigOptions { } /** - * Writes the `--mcp-config` file for a harness session: the remote HTTP + * Writes the MCP config file for a harness session: the remote HTTP * capability MCP (`sapiom`) and the local stdio authoring MCP (`sapiom-dev`). * Written under `HARNESS_PATHS.generated//` so concurrent * sessions never share (or race on) a config file. Returns the file's - * absolute path for the adapter to pass as `--mcp-config `. + * absolute path. Claude passes it as `--mcp-config `; Codex translates + * it to per-process config overrides and environment variables. */ export async function generateMcpConfig( harnessSessionId: string,