Skip to content
Merged
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
2 changes: 2 additions & 0 deletions examples/combined-app/src/client/hooks/useCodexAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ function summarizeApproval(request: ApprovalRequest): string {
return "Apply a patch";
case "item/tool/requestUserInput":
return "Tool input requested";
case "mcpServer/elicitation/request":
return request.params.message;
case "item/permissions/requestApproval":
return "Additional permissions requested";
}
Expand Down
4 changes: 2 additions & 2 deletions sdk/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ await conn.disconnect();
| `startReview(target?, delivery?)` | Start a Codex review (`review/start`); defaults to uncommitted changes |
| `compactThread()` | Compact the active thread's context (`thread/compact/start`) |
| `readConfig(params?)` | Read the app-server's effective config (`config/read`) |
| `onApprovalRequest(method, handler)` | Handle server-initiated approval requests (returns unsubscribe fn) |
| `onApprovalRequest(method, handler)` | Handle server-initiated approval or MCP elicitation requests (returns unsubscribe fn) |
| `threadId` | The active thread id (`string \| undefined`) |
| `onAxonEvent(listener)` | Subscribe to all Axon events (returns unsubscribe fn) |
| `onTimelineEvent(listener)` | Subscribe to classified timeline events (returns unsubscribe fn) |
Expand Down Expand Up @@ -385,7 +385,7 @@ create a new instance.
- **Auto-reconnect (single retry).** If an SSE stream drops unexpectedly, the SDK re-subscribes once. ACP logs a `console.warn`; Claude logs only when `verbose: true` is set. If the retry also fails, the connection is terminal — create a new instance.
- **ACP permissions default to auto-approve** (`allow_always` > `allow_once` > first option). Pass `requestPermission` to customize.
- **Claude permissions also auto-approve** all tool use. Register a `"can_use_tool"` handler via `onControlRequest()` to customize.
- **Codex approvals also auto-approve** by default. Register handlers via `onApprovalRequest()` to customize, or mount with `launch_args: ["-c", "approval_policy=never"]` for headless full-auto (no approval traffic at all).
- **Codex approvals auto-approve, while MCP elicitations cancel safely** by default. Register handlers via `onApprovalRequest()` to customize, or mount with `launch_args: ["-c", "approval_policy=never"]` for headless full-auto.
- **Explicit `connect()` required:** All connections require `await conn.connect()` first, followed by `initialize()` — ACP, Claude, and Codex alike.
- **Node >= 22** required.
- **`@runloop/api-client`** is a peer dep — you must install it yourself.
Expand Down
20 changes: 15 additions & 5 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ Like ACP/Claude, call `initialize()` after `connect()` — it runs the app-serve
|-------|------|-------------|
| `verbose` | `boolean` | Emit verbose logs to stderr |
| `threadStartParams` | `ThreadStartParams` | Defaults for auto-started threads (cwd, model, `sandbox`, `approvalPolicy`, …) |
| `approvalHandlers` | `Partial<Record<ApprovalMethod, ApprovalHandler>>` | Handlers for server-initiated approval requests (see below) |
| `approvalHandlers` | `Partial<Record<ApprovalMethod, ApprovalHandler>>` | Handlers for server-initiated approval and elicitation requests (see below) |
| `requestTimeoutMs` | `number` | Timeout for request/response correlation and approval handlers (default `60000`) |
| `onError` | `(error: unknown) => void` | Error callback (defaults to `console.error`) |
| `onDisconnect` | `() => void \| Promise<void>` | Teardown callback invoked by `disconnect()` (e.g. devbox shutdown) |
Expand Down Expand Up @@ -622,21 +622,31 @@ Like ACP/Claude, call `initialize()` after `connect()` — it runs the app-serve
| `onAxonEvent(listener)` | Register an Axon event listener. Returns unsubscribe function. |
| `onTimelineEvent(listener)` | Register a classified timeline event listener. Returns unsubscribe function. |
| `receiveTimelineEvents()` | Async generator yielding classified `CodexTimelineEvent`s |
| `onApprovalRequest(method, handler)` | Register a handler for a server-initiated approval request. Returns unsubscribe function. |
| `onApprovalRequest(method, handler)` | Register a handler for a server-initiated approval or elicitation request. Returns unsubscribe function. |

### Approval requests
### Approval and elicitation requests

Codex asks the client before running commands, editing files, or escalating permissions — the server sends a JSON-RPC request (`item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/tool/requestUserInput`, `item/permissions/requestApproval`, or the legacy `execCommandApproval` / `applyPatchApproval`) and waits for the client's response.
Codex asks the client before running commands, editing files, escalating permissions, or collecting structured input from an MCP server. The server sends a JSON-RPC request (`item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request`, `item/permissions/requestApproval`, or the legacy `execCommandApproval` / `applyPatchApproval`) and waits for the client's response.

By default the SDK **auto-approves**: command/file approvals are accepted, legacy approvals are approved, user-input answers are empty, and requested permissions are granted for the turn. Register a handler to customize; a handler that doesn't answer within `requestTimeoutMs` is treated as a decline.
By default the SDK **auto-approves approval requests**: command/file approvals are accepted, legacy approvals are approved, user-input answers are empty, and requested permissions are granted for the turn. MCP elicitations default to a safe `cancel` response. Register a handler to customize; a handler that doesn't answer within `requestTimeoutMs` is treated as a decline or cancellation.

```typescript
const off = conn.onApprovalRequest("item/commandExecution/requestApproval", (request) => {
console.log(`Approve command? (item ${request.params.itemId})`);
return { decision: "accept" }; // or { decision: "decline" }
});

conn.onApprovalRequest("mcpServer/elicitation/request", (request) => {
return {
action: "accept",
content: { city: "Paris" },
_meta: null,
};
});
```

Handlers receive a second argument with an `AbortSignal`. The signal aborts when app-server resolves the request elsewhere, the handler times out, or the connection closes, allowing applications to discard parked UI state without publishing a late response. Replay also treats `serverRequest/resolved` as terminal, so reconnecting a client does not re-open an elicitation that Codex already cleared. This recovery applies while the Codex process still owns the request. Restarting Codex destroys its in-memory MCP request, so applications must durably reissue the operation rather than replay only the old response.

Whether approvals fire at all depends on the thread's policy — pass `threadStartParams: { approvalPolicy: "on-request", sandbox: "read-only" }` (or per-call `startThread(params)`) to route actions through the client. For headless full-auto use, skip the approval flow entirely by mounting with `launch_args: ["-c", "approval_policy=never"]`.

### Codex Timeline Event Type Guards
Expand Down
22 changes: 22 additions & 0 deletions sdk/src/codex/classify-codex-axon-event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isCodexApprovalRequestEvent,
isCodexItemCompletedEvent,
isCodexResponseEvent,
isCodexServerRequestResolvedEvent,
isCodexThreadStartedEvent,
isTurnCompletedEvent,
isUnknownTimelineEvent,
Expand Down Expand Up @@ -115,6 +116,25 @@ describe("classifyCodexAxonEvent", () => {
if (isCodexApprovalRequestEvent(event)) expect(event.data.id).toBe(41);
});

it("classifies MCP elicitations and their resolved notification", () => {
const request = classifyCodexAxonEvent(
frame("mcpServer/elicitation/request", {
method: "mcpServer/elicitation/request",
id: 0,
params: { mode: "form", message: "Which city?", requestedSchema: {} },
}),
);
expect(isCodexApprovalRequestEvent(request)).toBe(true);

const resolved = classifyCodexAxonEvent(
frame("serverRequest/resolved", {
method: "serverRequest/resolved",
params: { threadId: "thr-1", requestId: 0 },
}),
);
expect(isCodexServerRequestResolvedEvent(resolved)).toBe(true);
});

it("classifies methodless JSON-RPC responses using the broker response event type", () => {
const event = classifyCodexAxonEvent(
frame("response", { id: "sdk-1", result: { thread: {} } }),
Expand Down Expand Up @@ -142,6 +162,8 @@ describe("isCodexProtocolEventType", () => {
expect(isCodexProtocolEventType("turn/started")).toBe(true);
expect(isCodexProtocolEventType("item/reasoning/textDelta")).toBe(true);
expect(isCodexProtocolEventType("applyPatchApproval")).toBe(true);
expect(isCodexProtocolEventType("mcpServer/elicitation/request")).toBe(true);
expect(isCodexProtocolEventType("serverRequest/resolved")).toBe(true);
expect(isCodexProtocolEventType("error")).toBe(true);
expect(isCodexProtocolEventType("response")).toBe(true);
expect(isCodexProtocolEventType("custom/event")).toBe(false);
Expand Down
79 changes: 79 additions & 0 deletions sdk/src/codex/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,11 @@ describe("CodexAxonConnection", () => {
["item/commandExecution/requestApproval", {}, { decision: "accept" }],
["item/fileChange/requestApproval", {}, { decision: "accept" }],
["item/tool/requestUserInput", {}, { answers: {} }],
[
"mcpServer/elicitation/request",
{ mode: "form" },
{ action: "cancel", content: null, _meta: null },
],
[
"item/permissions/requestApproval",
{ permissions: { network: null, fileSystem: null } },
Expand Down Expand Up @@ -367,6 +372,80 @@ describe("CodexAxonConnection", () => {
});
});

it("round-trips an MCP elicitation handler's structured content", async () => {
const { ctrl, mock, conn } = setup();
conn.onApprovalRequest("mcpServer/elicitation/request", async () => ({
action: "accept",
content: { city: "Paris" },
_meta: null,
}));
await conn.connect();
ctrl.push(
makeAgentEvent("mcpServer/elicitation/request", {
method: "mcpServer/elicitation/request",
id: 0,
params: { mode: "form", message: "Which city?", requestedSchema: {} },
}),
);
await tick();
expect(JSON.parse(mock.published[0]?.payload ?? "null")).toEqual({
id: 0,
result: { action: "accept", content: { city: "Paris" }, _meta: null },
});
});

it("stops a parked handler when app-server resolves the request elsewhere", async () => {
const { ctrl, mock, conn } = setup({ requestTimeoutMs: 1_000 });
let handlerSignal: AbortSignal | undefined;
conn.onApprovalRequest("mcpServer/elicitation/request", (_request, context) => {
handlerSignal = context.signal;
return new Promise(() => undefined);
});
await conn.connect();
ctrl.push(
makeAgentEvent("mcpServer/elicitation/request", {
method: "mcpServer/elicitation/request",
id: "elicit-1",
params: { mode: "url", message: "Authorize", url: "https://example.com" },
}),
);
await tick();
ctrl.push(
makeAgentEvent("serverRequest/resolved", {
method: "serverRequest/resolved",
params: { threadId: "thr-1", requestId: "elicit-1" },
}),
);
await tick();
expect(mock.published).toHaveLength(0);
expect(handlerSignal?.aborted).toBe(true);
});

it("replaces a stale parked handler when the same request is replayed", async () => {
const { ctrl, mock, conn } = setup({ requestTimeoutMs: 1_000 });
const signals: AbortSignal[] = [];
conn.onApprovalRequest("mcpServer/elicitation/request", (_request, context) => {
signals.push(context.signal);
return new Promise(() => undefined);
});
await conn.connect();
const request = {
method: "mcpServer/elicitation/request",
id: "elicit-replayed",
params: { mode: "form", message: "Which city?", requestedSchema: {} },
};

ctrl.push(makeAgentEvent(request.method, request));
await tick();
ctrl.push(makeAgentEvent(request.method, request));
await tick();

expect(signals).toHaveLength(2);
expect(signals[0]?.aborted).toBe(true);
expect(signals[1]?.aborted).toBe(false);
expect(mock.published).toHaveLength(0);
});

it("answers unsupported server requests with a JSON-RPC error", async () => {
const { ctrl, mock, conn } = setup();
await conn.connect();
Expand Down
78 changes: 61 additions & 17 deletions sdk/src/codex/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,16 @@ export type TurnOptions = Omit<TurnStartParams, "threadId" | "input"> & {
};
export type ApprovalMethod = CodexApprovalRequestMethod;
export type ApprovalRequest = Extract<ServerRequest, { method: ApprovalMethod }>;
export type ApprovalHandler = (request: ApprovalRequest) => Promise<unknown> | unknown;
/** Lifecycle context for an approval or elicitation request handler. */
export interface ApprovalHandlerContext {
/** Aborted when Codex resolves the request elsewhere, the handler times out, or the connection closes. */
signal: AbortSignal;
}
export type ApprovalHandler = (
request: ApprovalRequest,
context: ApprovalHandlerContext,
) => Promise<unknown> | unknown;
const SERVER_REQUEST_RESOLVED = Symbol("server-request-resolved");
/**
* JSON-RPC error returned by the Codex app-server for a client request.
* Preserves the wire `code` and `data` alongside the message.
Expand Down Expand Up @@ -145,6 +154,7 @@ export class CodexAxonConnection {
private readonly axonListeners: ListenerSet<AxonEventListener>;
private readonly timelineListeners: ListenerSet<TimelineEventListener<CodexTimelineEvent>>;
private readonly handlers = new Map<string, ApprovalHandler>();
private readonly serverRequestResolutionWaiters = new Map<string | number, () => void>();
private readonly handleError: (error: unknown) => void;
private readonly log;
constructor(
Expand Down Expand Up @@ -315,6 +325,7 @@ export class CodexAxonConnection {
this.closed = true;
this.abortController.abort();
this.pending.rejectAll(new Error("Client disconnected"));
this.resolveParkedServerRequests();
this.messageQueue.close();
await this.transport?.close();
this.transport = undefined;
Expand Down Expand Up @@ -356,6 +367,7 @@ export class CodexAxonConnection {
},
onTerminalError: (error) => this.pending.rejectAll(error),
onFinished: () => {
this.resolveParkedServerRequests();
this.running = false;
this.abortController.abort();
this.messageQueue.close(false);
Expand All @@ -380,9 +392,22 @@ export class CodexAxonConnection {
if (frame.method === "turn/started") this._currentTurnId = id;
else if (this._currentTurnId === id) this._currentTurnId = undefined;
}
private captureServerRequestResolved(frame: CodexFrame): void {
if (frame.method !== "serverRequest/resolved") return;
const requestId = (frame.params as { requestId?: unknown } | undefined)?.requestId;
if (typeof requestId !== "string" && typeof requestId !== "number") return;
this.serverRequestResolutionWaiters.get(requestId)?.();
}
private resolveParkedServerRequests(): void {
for (const resolveRequest of [...this.serverRequestResolutionWaiters.values()]) {
resolveRequest();
}
this.serverRequestResolutionWaiters.clear();
}
private route(frame: CodexFrame): void {
this.captureThreadStarted(frame);
this.captureTurnBoundary(frame);
this.captureServerRequestResolved(frame);
if (!frame.method && frame.id != null) {
frame.error
? this.pending.reject(frame.id, toRequestError(frame.error))
Expand All @@ -405,6 +430,8 @@ export class CodexAxonConnection {
return { decision: "approved" };
case "item/tool/requestUserInput":
return { answers: {} };
case "mcpServer/elicitation/request":
return { action: "cancel", content: null, _meta: null };
case "item/permissions/requestApproval": {
const permissions = request.params.permissions;
return {
Expand All @@ -427,6 +454,8 @@ export class CodexAxonConnection {
return { decision: "denied" };
case "item/tool/requestUserInput":
return { answers: {} };
case "mcpServer/elicitation/request":
return { action: "cancel", content: null, _meta: null };
case "item/permissions/requestApproval":
return { permissions: {}, scope: "turn" };
}
Expand All @@ -435,19 +464,32 @@ export class CodexAxonConnection {
request: ApprovalRequest,
handler: ApprovalHandler,
timeoutMs: number,
): Promise<unknown> {
): Promise<unknown | typeof SERVER_REQUEST_RESOLVED> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve(this.defaultDecline(request)), timeoutMs);
Promise.resolve(handler(request)).then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const handlerController = new AbortController();
const finish = (value: unknown | typeof SERVER_REQUEST_RESOLVED, error?: unknown) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (this.serverRequestResolutionWaiters.get(request.id) === onResolved) {
this.serverRequestResolutionWaiters.delete(request.id);
}
handlerController.abort();
if (error !== undefined) reject(error);
else resolve(value);
};
const onResolved = () => finish(SERVER_REQUEST_RESOLVED);
this.serverRequestResolutionWaiters.get(request.id)?.();
this.serverRequestResolutionWaiters.set(request.id, onResolved);
timer = setTimeout(() => finish(this.defaultDecline(request)), timeoutMs);
Promise.resolve()
.then(() => handler(request, { signal: handlerController.signal }))
.then(
(value) => finish(value),
(error) => finish(undefined, error),
);
});
}
private async handleServerRequest(request: ServerRequest): Promise<void> {
Expand All @@ -466,6 +508,7 @@ export class CodexAxonConnection {
const result = handler
? await this.approvalWithTimeout(approval, handler, timeoutMs)
: this.defaultApproval(approval);
if (result === SERVER_REQUEST_RESOLVED) return;
await this.transport?.write({ id: request.id, result });
} catch (error) {
if (this.transport?.isReady()) {
Expand Down Expand Up @@ -710,10 +753,11 @@ export class CodexAxonConnection {
await this.request("turn/steer", { threadId: this._threadId, input });
}
/**
* Registers an approval handler. Without one, command/file approvals are
* accepted, legacy approvals are approved, user-input answers are empty,
* and requested permissions are granted for the turn. Handler timeouts are
* declined. Mounting with `approval_policy=never` avoids approval traffic.
* Registers an approval or elicitation handler. Without one, command/file
* approvals are accepted, legacy approvals are approved, user-input answers
* are empty, requested permissions are granted for the turn, and MCP
* elicitations are canceled. Handler timeouts are declined or canceled.
* Mounting with `approval_policy=never` avoids approval traffic.
*/
onApprovalRequest(method: ApprovalMethod, handler: ApprovalHandler): () => void {
this.handlers.set(method, handler);
Expand Down
2 changes: 2 additions & 0 deletions sdk/src/codex/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ export const CODEX_NOTIFICATION_METHODS = [
"item/reasoning/summaryTextDelta",
"item/reasoning/summaryPartAdded",
"item/reasoning/textDelta",
"serverRequest/resolved",
"error",
] as const;

export const CODEX_APPROVAL_REQUEST_METHODS = [
"item/commandExecution/requestApproval",
"item/fileChange/requestApproval",
"item/tool/requestUserInput",
"mcpServer/elicitation/request",
"item/permissions/requestApproval",
"execCommandApproval",
"applyPatchApproval",
Expand Down
Loading
Loading