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
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
### 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 () => {
const session = createSession({
modeId: undefined,
@@ -1024,6 +1050,80 @@ describe("Codex app-server provider", () => {
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 () => {
let initializeParams: unknown;
const appServer = createFakeCodexAppServer({
@@ -830,6 +830,22 @@ function toObjectRecord(value: unknown): Record<string, unknown> | 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
interface CodexReasoningEffortEntry {
reasoningEffort?: string;
@@ -6414,6 +6430,16 @@ export class CodexAppServerAgentSession implements AgentSession {
if (parsed.mode === "url") {
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;
if (Array.isArray(requiredFields) && requiredFields.length > 0) {
return Promise.resolve({ action: "decline", content: null, _meta: null });
@@ -48,6 +48,7 @@ export interface FakeCodexAppServer {
readonly child: CodexAppServerChildProcess;
readonly recordedRollbacks: JsonObject[];
assertNoErrors(): void;
waitForThreadStart(): Promise<JsonObject>;
waitForTurnStart(): Promise<JsonObject>;
nextResponse(): Promise<string>;
startsTurn(params: { threadId: string; turnId?: string }): void;
@@ -297,6 +298,13 @@ export function createFakeCodexAppServer(
throw errors[0];
}
},
async waitForThreadStart() {
const message = await waitForMessage(
(candidate) => candidate.method === "thread/start",
"thread start request",
);
return toJsonObject(message.params);
},
async waitForTurnStart() {
const message = await waitForMessage(
(candidate) => candidate.method === "turn/start",