Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/codex-voice-mcp-approval.md
Original file line number Diff line number Diff line change
@@ -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.<id>.enabled_tools`
- `mcp_servers.<id>.default_tools_approval_mode`
- `mcp_servers.<id>.tools.<tool>.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.
49 changes: 49 additions & 0 deletions packages/server/src/server/agent/agent-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
promise: Promise<T>;
Expand Down Expand Up @@ -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");
Expand Down
16 changes: 15 additions & 1 deletion packages/server/src/server/agent/agent-sdk-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { approval_mode?: CodexMcpApprovalMode }>;
}

export interface CodexAgentExtra extends AgentMetadata {
mcpServerPolicies?: Record<string, CodexMcpServerPolicy>;
}

/**
* Stdio-based MCP server (spawns a subprocess).
*/
Expand Down Expand Up @@ -573,7 +587,7 @@ export interface AgentSessionConfig {
networkAccess?: boolean;
webSearch?: boolean;
extra?: {
codex?: AgentMetadata;
codex?: CodexAgentExtra;
claude?: Partial<ClaudeAgentOptions>;
};
mcpServers?: Record<string, McpServerConfig>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface CollaborationModeRecord {
}

interface CodexSessionTestAccess {
buildCodexInnerConfig(): Record<string, unknown> | null;
ensureThreadLoaded(): Promise<void>;
handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): void;
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type CodexMcpApprovalMode,
type CodexMcpServerPolicy,
type AgentFeature,
type AgentLaunchContext,
type AgentMode,
Expand Down Expand Up @@ -787,23 +789,40 @@ interface CodexMcpServerConfig {
args?: string[];
env?: Record<string, string>;
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<CodexMcpServerConfig> = 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,
};
Expand Down Expand Up @@ -4509,15 +4528,18 @@ export class CodexAppServerAgentSession implements AgentSession {

private buildCodexInnerConfig(): Record<string, unknown> | null {
const innerConfig: Record<string, unknown> = {};
const codexExtra = this.config.extra?.codex;
const mcpServerPolicies = codexExtra?.mcpServerPolicies;
if (this.config.mcpServers) {
const mcpServers: Record<string, CodexMcpServerConfig> = {};
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);
Expand Down
7 changes: 7 additions & 0 deletions packages/server/src/server/session.voice-mcp-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildVoiceAgentMcpServerConfig,
buildVoiceModeSystemPrompt,
stripVoiceModeSystemPrompt,
wrapSpokenInput,
} from "./voice-config.js";

describe("voice MCP stdio config", () => {
Expand Down Expand Up @@ -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");
});
});
78 changes: 72 additions & 6 deletions packages/server/src/server/session/voice/voice-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Partial<AgentSessionConfig> | 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<Partial<AgentSessionConfig> | 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 });
},
Expand All @@ -79,7 +87,7 @@ function createFakeHost(): FakeVoiceHost {
};
}

function createVoiceSession() {
function createVoiceSession(config?: AgentSessionConfig) {
const detector = new FakeVoiceTurnDetectionSession();
const sttSession = new FakeVoiceSttSession();
const stt: SpeechToTextProvider = {
Expand All @@ -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" }),
Expand All @@ -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<void> {
await Promise.resolve();
await Promise.resolve();
Expand Down
Loading