diff --git a/docs/codex-voice-mcp-approval.md b/docs/codex-voice-mcp-approval.md new file mode 100644 index 0000000000..0520d6bbea --- /dev/null +++ b/docs/codex-voice-mcp-approval.md @@ -0,0 +1,40 @@ +# Codex Voice MCP Approval + +## Problem + +Codex voice-mode sessions were receiving the injected `paseo` MCP server, but Paseo was not forwarding Codex-specific MCP approval settings into the app-server config. As a result, `paseo.speak` fell back to Codex's default MCP approval behavior and could be rejected before Paseo's voice auto-allow hook ever ran. + +## Fix + +Paseo now carries Codex MCP approval metadata in `extra.codex.mcpServerPolicies` and forwards it only through the Codex adapter as: + +- `mcp_servers..enabled_tools` +- `mcp_servers..default_tools_approval_mode` +- `mcp_servers..tools..approval_mode` + +For Codex voice mode, the injected `paseo` server is now configured with: + +- `enabled_tools = ["speak"]` +- `default_tools_approval_mode = "prompt"` +- `tools.speak.approval_mode = "approve"` + +This keeps the approval override limited to `speak` while leaving the normal generic `paseo` MCP server in place. + +The voice approval override is intentionally Codex-only. Other providers use different permission systems or do not +accept these Codex-specific MCP approval fields, so they receive the generic `paseo` MCP server without this metadata. + +## Verification + +1. Build or restart the Paseo server from this branch. +2. Start a fresh Codex voice conversation. +3. Speak a short prompt. +4. Confirm the response uses `mcp__paseo.speak` and plays without a manual approval prompt. + +Focused tests: + +- `./node_modules/.bin/vitest run packages/server/src/server/session.voice-mcp-config.test.ts packages/server/src/server/session/voice/voice-session.test.ts` +- `./node_modules/.bin/vitest run packages/server/src/server/agent/providers/codex-app-server-agent.test.ts -t "maps MCP approval settings into Codex inner config"` + +## Revert + +Revert the commit that introduced this change, then restart the daemon so Codex sessions pick up the reverted MCP config mapping. diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index fa1d77f5c1..91e7dea58d 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -39,6 +39,7 @@ import type { } from "./agent-sdk-types.js"; import type { PaseoToolCatalog } from "./tools/types.js"; import type { ProviderDefinition } from "./provider-registry.js"; +import { PASEO_MCP_SERVER_NAME } from "../voice-config.js"; interface Deferred { promise: Promise; @@ -3764,6 +3765,54 @@ test("fetchTimeline returns a bounded reset window when cursor epoch is stale", expect(older.hasOlder).toBe(true); }); +test("Codex voice MCP launch config preserves voice approval and injects Paseo MCP", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-codex-voice-mcp-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new TestAgentClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + }); + manager.setMcpBaseUrl("http://127.0.0.1:4123/mcp/agents"); + + await manager.createAgent( + { + provider: "codex", + cwd: workdir, + extra: { + codex: { + mcpServerPolicies: { + [PASEO_MCP_SERVER_NAME]: { + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, + }, + }, + }, + }, + }, + undefined, + { workspaceId: undefined }, + ); + + expect(client.createdConfigs).toHaveLength(1); + expect(client.createdConfigs[0]?.extra?.codex?.mcpServerPolicies).toMatchObject({ + [PASEO_MCP_SERVER_NAME]: { + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, + }, + }); + expect(client.createdConfigs[0]?.mcpServers).toMatchObject({ + [PASEO_MCP_SERVER_NAME]: { type: "http" }, + }); + expect(client.createdConfigs[0]?.mcpServers?.[PASEO_MCP_SERVER_NAME]).toHaveProperty( + "url", + expect.stringMatching(/^http:\/\/127\.0\.0\.1:4123\/mcp\/agents\?callerAgentId=[0-9a-f-]+$/), + ); +}); + test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rows-fallback-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 6836cd8bc4..6e19822d31 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -11,6 +11,20 @@ export interface AgentMetadata { [key: string]: unknown; } +export type CodexMcpApprovalMode = "auto" | "prompt" | "approve"; + +/** Native Codex MCP server settings stored under the Codex-specific extra config. */ +export interface CodexMcpServerPolicy { + enabled_tools?: string[]; + disabled_tools?: string[]; + default_tools_approval_mode?: CodexMcpApprovalMode; + tools?: Record; +} + +export interface CodexAgentExtra extends AgentMetadata { + mcpServerPolicies?: Record; +} + /** * Stdio-based MCP server (spawns a subprocess). */ @@ -573,7 +587,7 @@ export interface AgentSessionConfig { networkAccess?: boolean; webSearch?: boolean; extra?: { - codex?: AgentMetadata; + codex?: CodexAgentExtra; claude?: Partial; }; mcpServers?: Record; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index a9df432f46..3bc8c9e960 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -48,6 +48,7 @@ interface CollaborationModeRecord { } interface CodexSessionTestAccess { + buildCodexInnerConfig(): Record | null; ensureThreadLoaded(): Promise; handleToolApprovalRequest(params: unknown): Promise; handleNotification(method: string, params: unknown): void; @@ -1079,6 +1080,41 @@ describe("Codex app-server provider", () => { }); }); + test("includes native MCP approval settings in Codex inner config", () => { + const session = createSession({ + mcpServers: { + paseo: { + type: "http", + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test", + }, + }, + extra: { + codex: { + mcpServerPolicies: { + paseo: { + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, + }, + }, + }, + }, + }); + + expect(asInternals(session).buildCodexInnerConfig()).toEqual({ + mcp_servers: { + paseo: { + url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=test", + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { + speak: { approval_mode: "approve" }, + }, + }, + }, + }); + }); + test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => { const threadRequests: string[] = []; const appServer = createFakeCodexAppServer({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ac3017dac2..91d3c78495 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -4,6 +4,8 @@ import { type AgentCapabilityFlags, type AgentClient, type AgentCreateSessionOptions, + type CodexMcpApprovalMode, + type CodexMcpServerPolicy, type AgentFeature, type AgentLaunchContext, type AgentMode, @@ -787,23 +789,40 @@ interface CodexMcpServerConfig { args?: string[]; env?: Record; tool_timeout_sec?: number; + enabled_tools?: string[]; + disabled_tools?: string[]; + default_tools_approval_mode?: CodexMcpApprovalMode; + tools?: Record< + string, + { + approval_mode?: CodexMcpApprovalMode; + } + >; } -function toCodexMcpConfig(config: McpServerConfig): CodexMcpServerConfig { +function toCodexMcpConfig( + config: McpServerConfig, + policy?: CodexMcpServerPolicy, +): CodexMcpServerConfig { + const base: Partial = policy ?? {}; + switch (config.type) { case "stdio": return { + ...base, command: config.command, args: config.args, env: config.env, }; case "http": return { + ...base, url: config.url, http_headers: config.headers, }; case "sse": return { + ...base, url: config.url, http_headers: config.headers, }; @@ -4509,15 +4528,18 @@ export class CodexAppServerAgentSession implements AgentSession { private buildCodexInnerConfig(): Record | null { const innerConfig: Record = {}; + const codexExtra = this.config.extra?.codex; + const mcpServerPolicies = codexExtra?.mcpServerPolicies; if (this.config.mcpServers) { const mcpServers: Record = {}; for (const [name, serverConfig] of Object.entries(this.config.mcpServers)) { - mcpServers[name] = toCodexMcpConfig(serverConfig); + mcpServers[name] = toCodexMcpConfig(serverConfig, mcpServerPolicies?.[name]); } innerConfig.mcp_servers = mcpServers; } - if (this.config.extra?.codex) { - Object.assign(innerConfig, this.config.extra.codex); + if (codexExtra) { + const { mcpServerPolicies: _mcpServerPolicies, ...nativeCodexExtra } = codexExtra; + Object.assign(innerConfig, nativeCodexExtra); } if (this.deps.customCodexConfig) { Object.assign(innerConfig, this.deps.customCodexConfig); diff --git a/packages/server/src/server/session.voice-mcp-config.test.ts b/packages/server/src/server/session.voice-mcp-config.test.ts index 98b1d41020..c7805d069c 100644 --- a/packages/server/src/server/session.voice-mcp-config.test.ts +++ b/packages/server/src/server/session.voice-mcp-config.test.ts @@ -4,6 +4,7 @@ import { buildVoiceAgentMcpServerConfig, buildVoiceModeSystemPrompt, stripVoiceModeSystemPrompt, + wrapSpokenInput, } from "./voice-config.js"; describe("voice MCP stdio config", () => { @@ -76,3 +77,9 @@ describe("voice mode prompt instructions", () => { ).toBeUndefined(); }); }); + +describe("spoken-input wrapping", () => { + test("defaults to the generic speak tool", () => { + expect(wrapSpokenInput("hello")).toContain("Respond using the speak tool only"); + }); +}); diff --git a/packages/server/src/server/session/voice/voice-session.test.ts b/packages/server/src/server/session/voice/voice-session.test.ts index d6232ff4d8..2717d6f7b0 100644 --- a/packages/server/src/server/session/voice/voice-session.test.ts +++ b/packages/server/src/server/session/voice/voice-session.test.ts @@ -4,6 +4,7 @@ import { describe, expect, test, vi } from "vitest"; import { VoiceSession, type VoiceSessionHost } from "./voice-session.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; +import type { AgentSessionConfig } from "../../agent/agent-sdk-types.js"; import type { SessionOutboundMessage } from "../../messages.js"; import type { SpeechToTextProvider, @@ -57,20 +58,27 @@ class FakeVoiceSttSession extends EventEmitter implements StreamingTranscription interface FakeVoiceHost extends VoiceSessionHost { readonly emitted: SessionOutboundMessage[]; readonly spokenInput: Array<{ agentId: string; text: string }>; + readonly reloadedConfigs: Array | undefined>; } -function createFakeHost(): FakeVoiceHost { +function createFakeHost( + config: AgentSessionConfig = { provider: "claude", cwd: "/tmp" }, +): FakeVoiceHost { const emitted: SessionOutboundMessage[] = []; const spokenInput: Array<{ agentId: string; text: string }> = []; + const reloadedConfigs: Array | undefined> = []; return { emitted, spokenInput, + reloadedConfigs, emit: (msg) => { emitted.push(msg); }, - loadAgent: async (agentId) => - ({ id: agentId, config: { systemPrompt: undefined } }) as unknown as ManagedAgent, - reloadAgentSession: async (agentId) => ({ id: agentId }) as unknown as ManagedAgent, + loadAgent: async (agentId) => ({ id: agentId, config }) as unknown as ManagedAgent, + reloadAgentSession: async (agentId, overrides) => { + reloadedConfigs.push(overrides); + return { id: agentId } as unknown as ManagedAgent; + }, sendSpokenInput: async (agentId, text) => { spokenInput.push({ agentId, text }); }, @@ -79,7 +87,7 @@ function createFakeHost(): FakeVoiceHost { }; } -function createVoiceSession() { +function createVoiceSession(config?: AgentSessionConfig) { const detector = new FakeVoiceTurnDetectionSession(); const sttSession = new FakeVoiceSttSession(); const stt: SpeechToTextProvider = { @@ -90,7 +98,7 @@ function createVoiceSession() { id: "local", createSession: vi.fn(() => detector), }; - const host = createFakeHost(); + const host = createFakeHost(config); const voiceSession = new VoiceSession({ host, logger: pino({ level: "silent" }), @@ -103,6 +111,64 @@ function createVoiceSession() { return { voiceSession, detector, sttSession, host }; } +test("Codex voice mode adds and restores its MCP policy in provider-specific config", async () => { + const originalExtra = { codex: { model_provider: "custom" } }; + const { voiceSession, host } = createVoiceSession({ + provider: "codex", + cwd: "/tmp", + extra: originalExtra, + }); + + await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); + + expect(host.reloadedConfigs[0]).toMatchObject({ + extra: { + codex: { + model_provider: "custom", + mcpServerPolicies: { + paseo: { + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, + }, + }, + }, + }, + }); + + await voiceSession.handleSetVoiceMode(false, VOICE_AGENT_ID); + + expect(host.reloadedConfigs[1]).toEqual({ + systemPrompt: expect.any(String), + extra: originalExtra, + }); + await voiceSession.cleanup(); +}); + +test("Codex-derived provider IDs receive the voice MCP policy", async () => { + const { voiceSession, host } = createVoiceSession({ + provider: "custom-codex", + cwd: "/tmp", + }); + + await voiceSession.handleSetVoiceMode(true, VOICE_AGENT_ID); + + expect(host.reloadedConfigs[0]).toMatchObject({ + extra: { + codex: { + mcpServerPolicies: { + paseo: { + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, + }, + }, + }, + }, + }); + await voiceSession.cleanup(); +}); + async function settle(): Promise { await Promise.resolve(); await Promise.resolve(); diff --git a/packages/server/src/server/session/voice/voice-session.ts b/packages/server/src/server/session/voice/voice-session.ts index f6331dba3f..8665a65601 100644 --- a/packages/server/src/server/session/voice/voice-session.ts +++ b/packages/server/src/server/session/voice/voice-session.ts @@ -14,10 +14,14 @@ import { type DictationStreamOutboundMessage, } from "../../dictation/dictation-stream-manager.js"; import { createVoiceTurnController, type VoiceTurnController } from "./voice-turn-controller.js"; -import { buildVoiceModeSystemPrompt, stripVoiceModeSystemPrompt } from "../../voice-config.js"; +import { + buildVoiceModeSystemPrompt, + PASEO_MCP_SERVER_NAME, + stripVoiceModeSystemPrompt, +} from "../../voice-config.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; -import type { AgentSessionConfig } from "../../agent/agent-sdk-types.js"; +import type { AgentSessionConfig, CodexMcpServerPolicy } from "../../agent/agent-sdk-types.js"; import type { LocalSpeechModelId } from "../../speech/providers/local/models.js"; import { toResolver, type Resolvable } from "../../speech/provider-resolver.js"; import type { SpeechReadinessSnapshot, SpeechReadinessState } from "../../speech/speech-runtime.js"; @@ -31,11 +35,17 @@ const MIN_STREAMING_SEGMENT_BYTES = Math.round( PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS, ); const AgentIdSchema = z.guid(); +const CODEX_VOICE_MCP_POLICY: CodexMcpServerPolicy = { + enabled_tools: ["speak"], + default_tools_approval_mode: "prompt", + tools: { speak: { approval_mode: "approve" } }, +}; type ProcessingPhase = "idle" | "transcribing"; interface VoiceModeBaseConfig { systemPrompt?: string; + extra?: AgentSessionConfig["extra"]; } interface AudioBufferState { @@ -482,11 +492,10 @@ export class VoiceSession { const baseConfig: VoiceModeBaseConfig = { systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt), + extra: existing.config.extra, }; this.voiceModeBaseConfig = baseConfig; - const refreshOverrides: Partial = { - systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true), - }; + const refreshOverrides = this.buildVoiceModeRefreshOverrides(existing.config, baseConfig); try { this.sessionLogger.info( @@ -507,6 +516,38 @@ export class VoiceSession { } } + private buildVoiceModeRefreshOverrides( + existingConfig: AgentSessionConfig, + baseConfig: VoiceModeBaseConfig, + ): Partial { + const codexVoiceOverrides = this.buildCodexVoiceModeOverrides(existingConfig); + return { + systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true), + ...codexVoiceOverrides, + }; + } + + private buildCodexVoiceModeOverrides( + existingConfig: AgentSessionConfig, + ): Partial | null { + if (!existingConfig.provider.toLowerCase().includes("codex")) { + return null; + } + + return { + extra: { + ...existingConfig.extra, + codex: { + ...existingConfig.extra?.codex, + mcpServerPolicies: { + ...existingConfig.extra?.codex?.mcpServerPolicies, + [PASEO_MCP_SERVER_NAME]: CODEX_VOICE_MCP_POLICY, + }, + }, + }, + }; + } + private async disableVoiceModeForActiveAgent(restoreAgentConfig: boolean): Promise { await this.stopVoiceTurnController(); @@ -524,6 +565,7 @@ export class VoiceSession { try { await this.host.reloadAgentSession(agentId, { systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false), + extra: baseConfig.extra, }); } catch (error) { this.sessionLogger.warn( diff --git a/packages/server/src/server/voice-config.ts b/packages/server/src/server/voice-config.ts index d8cb1992f4..25c3dd6fae 100644 --- a/packages/server/src/server/voice-config.ts +++ b/packages/server/src/server/voice-config.ts @@ -1,6 +1,6 @@ const VOICE_PROMPT_BLOCK_START = ""; const VOICE_PROMPT_BLOCK_END = ""; - +export const PASEO_MCP_SERVER_NAME = "paseo"; const VOICE_AGENT_SYSTEM_INSTRUCTION = [ "Paseo voice mode is now on.", "You are the Paseo voice assistant.",