Compare commits

...
Author SHA1 Message Date
Mohamed Boudra a7533e8330 fix(codex): honor full-access MCP elicitations
Codex's never-ask policy accepts empty MCP forms, but Paseo routed those app-server requests into its permission channel. Mirror that provider contract while declining data-bearing forms that Paseo cannot answer.
2026-08-10 17:58:56 +02:00
4 changed files with 139 additions and 0 deletions
+5
View File
@@ -37,6 +37,11 @@ Each provider definition owns its option schema and exact MCP preapproval mappin
must fail closed for Hub unattended execution until it can approve one exact injected MCP server must fail closed for Hub unattended execution until it can approve one exact injected MCP server
and tool identity without approving native tools. and tool identity without approving native tools.
Codex **Full Access** sends `approvalPolicy: "never"` and `danger-full-access` to the app server.
If a permission-only MCP form still reaches the provider, Paseo accepts it when its schema has no
properties. Forms with fields and URL/auth flows are declined because Paseo cannot supply user data
or credentials. Restricted Codex modes keep their existing approval behavior.
## Two Integration Patterns ## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended ### ACP (Agent Client Protocol) -- recommended
@@ -564,6 +564,32 @@ describe("Codex app-server provider", () => {
} }
}); });
test("launches full-access turns with Codex's no-approval unrestricted policy", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project", modeId: "full-access" }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
await session.startTurn("run an ordinary command");
await expect(appServer.waitForThreadStart()).resolves.toMatchObject({
approvalPolicy: "never",
sandbox: "danger-full-access",
});
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
approvalPolicy: "never",
sandboxPolicy: { type: "dangerFullAccess" },
});
} finally {
await session.close();
}
});
test("preapproves only granted tools on the injected Codex MCP server", async () => { test("preapproves only granted tools on the injected Codex MCP server", async () => {
const session = createSession({ const session = createSession({
modeId: undefined, modeId: undefined,
@@ -1024,6 +1050,80 @@ describe("Codex app-server provider", () => {
await session.close(); await session.close();
}); });
test("allows permission-only MCP elicitations in full-access mode", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project", modeId: "full-access" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
await session.connect();
session.subscribe((event) => events.push(event));
try {
appServer.requestMcpElicitation({
threadId: "thread-1",
turnId: "turn-1",
serverName: "github",
message: "Allow Codex to use the GitHub connector?",
requestedSchema: {
type: "object",
properties: {},
},
});
await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({
action: "accept",
content: {},
_meta: null,
});
expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_requested" }));
} finally {
await session.close();
}
});
test("does not fabricate data for a data-bearing full-access MCP elicitation", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project", modeId: "full-access" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
await session.connect();
session.subscribe((event) => events.push(event));
try {
appServer.requestMcpElicitation({
threadId: "thread-1",
turnId: "turn-1",
serverName: "github",
message: "Enter the repository name to use.",
requestedSchema: {
type: "object",
properties: {
repository: { type: "string" },
},
},
});
await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({
action: "decline",
content: null,
_meta: null,
});
expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_requested" }));
} finally {
await session.close();
}
});
test("initializes Codex app-server without making Paseo the request originator", async () => { test("initializes Codex app-server without making Paseo the request originator", async () => {
let initializeParams: unknown; let initializeParams: unknown;
const appServer = createFakeCodexAppServer({ const appServer = createFakeCodexAppServer({
@@ -830,6 +830,22 @@ function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined; return isRecord(value) ? value : undefined;
} }
function isPermissionOnlyMcpElicitationSchema(schema: unknown): boolean {
const schemaRecord = toObjectRecord(schema);
const properties = toObjectRecord(schemaRecord?.properties);
return properties !== undefined && Object.keys(properties).length === 0;
}
function isCodexFullAccessMode(modeId: string, providerOptions: CodexProviderOptions): boolean {
return (
modeId === "full-access" &&
(providerOptions.approval_policy === undefined ||
providerOptions.approval_policy === "never") &&
(providerOptions.sandbox_mode === undefined ||
providerOptions.sandbox_mode === "danger-full-access")
);
}
// Codex app-server API response types // Codex app-server API response types
interface CodexReasoningEffortEntry { interface CodexReasoningEffortEntry {
reasoningEffort?: string; reasoningEffort?: string;
@@ -6414,6 +6430,16 @@ export class CodexAppServerAgentSession implements AgentSession {
if (parsed.mode === "url") { if (parsed.mode === "url") {
return Promise.resolve({ action: "decline", content: null, _meta: null }); return Promise.resolve({ action: "decline", content: null, _meta: null });
} }
if (isCodexFullAccessMode(this.currentMode, this.providerOptions)) {
// Codex's never-ask policy accepts only empty MCP forms and declines
// forms that need data. Keep that provider contract when an app-server
// request still reaches Paseo; never fabricate form or auth values.
return Promise.resolve(
isPermissionOnlyMcpElicitationSchema(parsed.requestedSchema)
? { action: "accept", content: {}, _meta: null }
: { action: "decline", content: null, _meta: null },
);
}
const requiredFields = toObjectRecord(parsed.requestedSchema)?.required; const requiredFields = toObjectRecord(parsed.requestedSchema)?.required;
if (Array.isArray(requiredFields) && requiredFields.length > 0) { if (Array.isArray(requiredFields) && requiredFields.length > 0) {
return Promise.resolve({ action: "decline", content: null, _meta: null }); return Promise.resolve({ action: "decline", content: null, _meta: null });
@@ -48,6 +48,7 @@ export interface FakeCodexAppServer {
readonly child: CodexAppServerChildProcess; readonly child: CodexAppServerChildProcess;
readonly recordedRollbacks: JsonObject[]; readonly recordedRollbacks: JsonObject[];
assertNoErrors(): void; assertNoErrors(): void;
waitForThreadStart(): Promise<JsonObject>;
waitForTurnStart(): Promise<JsonObject>; waitForTurnStart(): Promise<JsonObject>;
nextResponse(): Promise<string>; nextResponse(): Promise<string>;
startsTurn(params: { threadId: string; turnId?: string }): void; startsTurn(params: { threadId: string; turnId?: string }): void;
@@ -297,6 +298,13 @@ export function createFakeCodexAppServer(
throw errors[0]; throw errors[0];
} }
}, },
async waitForThreadStart() {
const message = await waitForMessage(
(candidate) => candidate.method === "thread/start",
"thread start request",
);
return toJsonObject(message.params);
},
async waitForTurnStart() { async waitForTurnStart() {
const message = await waitForMessage( const message = await waitForMessage(
(candidate) => candidate.method === "turn/start", (candidate) => candidate.method === "turn/start",