@agentex/agent 0.0.40 → 0.0.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +24 -4
- package/dist/providers/antigravity/execute.d.ts.map +1 -1
- package/dist/providers/antigravity/execute.js +9 -3
- package/dist/providers/antigravity/execute.js.map +1 -1
- package/dist/providers/antigravity/runtime.d.ts +4 -2
- package/dist/providers/antigravity/runtime.d.ts.map +1 -1
- package/dist/providers/antigravity/runtime.js +7 -4
- package/dist/providers/antigravity/runtime.js.map +1 -1
- package/dist/providers/antigravity/session.d.ts.map +1 -1
- package/dist/providers/antigravity/session.js +59 -17
- package/dist/providers/antigravity/session.js.map +1 -1
- package/dist/providers/codex/parse.d.ts +7 -0
- package/dist/providers/codex/parse.d.ts.map +1 -1
- package/dist/providers/codex/parse.js +56 -0
- package/dist/providers/codex/parse.js.map +1 -1
- package/dist/providers/codex/permissions.d.ts +40 -0
- package/dist/providers/codex/permissions.d.ts.map +1 -0
- package/dist/providers/codex/permissions.js +50 -0
- package/dist/providers/codex/permissions.js.map +1 -0
- package/dist/providers/codex/session.d.ts +34 -0
- package/dist/providers/codex/session.d.ts.map +1 -1
- package/dist/providers/codex/session.js +213 -20
- package/dist/providers/codex/session.js.map +1 -1
- package/dist/utils/auth.d.ts.map +1 -1
- package/dist/utils/auth.js +12 -10
- package/dist/utils/auth.js.map +1 -1
- package/package.json +1 -1
- package/src/providers/antigravity/execute.ts +10 -3
- package/src/providers/antigravity/runtime.ts +7 -4
- package/src/providers/antigravity/session.ts +59 -18
- package/src/providers/codex/parse.ts +59 -0
- package/src/providers/codex/permissions.ts +61 -0
- package/src/providers/codex/session.ts +238 -19
- package/src/utils/auth.ts +12 -11
|
@@ -38,7 +38,8 @@ import { AGY_SIGN_IN_MESSAGE, buildAgyArgs, findAgyBinary, readAgyResumeId } fro
|
|
|
38
38
|
interface ActiveTurn {
|
|
39
39
|
turnId: string;
|
|
40
40
|
abort: AbortController;
|
|
41
|
-
|
|
41
|
+
/** Set once the turn starts; a turn_start handler can interrupt before that. */
|
|
42
|
+
done: Promise<TurnResult> | null;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -74,8 +75,20 @@ class AntigravitySession implements AgentSession {
|
|
|
74
75
|
/** Events received from agy processes, to tell whether one saw a message. */
|
|
75
76
|
private connectionEvents = 0;
|
|
76
77
|
private draining = false;
|
|
77
|
-
/**
|
|
78
|
-
|
|
78
|
+
/**
|
|
79
|
+
* agy has no system-prompt flag, so instructions ride on the first message
|
|
80
|
+
* each process accepts, like a per-process system prompt. They stay pending
|
|
81
|
+
* until agy acknowledges a message that carried them, so a failed start or
|
|
82
|
+
* sign-in never drops them, and a resume agy could not honor (a new
|
|
83
|
+
* conversation) still gets them.
|
|
84
|
+
*/
|
|
85
|
+
private readonly instructions: string | null;
|
|
86
|
+
/** Conversations this session saw agy accept the instructions into. */
|
|
87
|
+
private readonly instructedConversations = new Set<string>();
|
|
88
|
+
/** The current process accepted a message that carried the instructions. */
|
|
89
|
+
private connectionInstructed = false;
|
|
90
|
+
/** The conversation the current process was started to resume, if any. */
|
|
91
|
+
private connectionResumeId: string | null = null;
|
|
79
92
|
private readonly cwd: string;
|
|
80
93
|
private readonly goals: GoalController;
|
|
81
94
|
|
|
@@ -86,7 +99,7 @@ class AntigravitySession implements AgentSession {
|
|
|
86
99
|
) {
|
|
87
100
|
this.cwd = ctx.cwd ?? process.cwd();
|
|
88
101
|
this.conversationId = readAgyResumeId(ctx.sessionParams, this.cwd);
|
|
89
|
-
this.
|
|
102
|
+
this.instructions = instructions;
|
|
90
103
|
this.goals = new GoalController({
|
|
91
104
|
providerType: ANTIGRAVITY_PROVIDER_TYPE,
|
|
92
105
|
capability: EMULATED_GOAL_CAPABILITY,
|
|
@@ -118,12 +131,14 @@ class AntigravitySession implements AgentSession {
|
|
|
118
131
|
const uuid = uuidv7();
|
|
119
132
|
const turnId = uuidv7();
|
|
120
133
|
const abort = new AbortController();
|
|
121
|
-
const text = this.pendingInstructions ? `${this.pendingInstructions}\n\n${message}` : message;
|
|
122
|
-
this.pendingInstructions = null;
|
|
123
134
|
|
|
124
135
|
this._state = "thinking";
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
// Registered before the turn runs, so interrupt() can reach it from inside
|
|
137
|
+
// a turn_start handler.
|
|
138
|
+
const active: ActiveTurn = { turnId, abort, done: null };
|
|
139
|
+
this.active = active;
|
|
140
|
+
const done = this.runTurn(message, turnId, abort, options);
|
|
141
|
+
active.done = done;
|
|
127
142
|
void done.then((result) => this.goals.onTurnSettled(result)).catch(() => undefined);
|
|
128
143
|
return { uuid, result: done };
|
|
129
144
|
}
|
|
@@ -133,6 +148,8 @@ class AntigravitySession implements AgentSession {
|
|
|
133
148
|
const config = this.ctx.config ?? {};
|
|
134
149
|
// A replacement process picks the conversation back up where it stopped.
|
|
135
150
|
this.conversationId = this.sessionId;
|
|
151
|
+
this.connectionResumeId = this.conversationId;
|
|
152
|
+
this.connectionInstructed = false;
|
|
136
153
|
const parser = new AgyStreamParser({
|
|
137
154
|
includePartialMessages: config.includePartialMessages,
|
|
138
155
|
conversationId: this.conversationId,
|
|
@@ -162,18 +179,21 @@ class AntigravitySession implements AgentSession {
|
|
|
162
179
|
}
|
|
163
180
|
|
|
164
181
|
private async runTurn(
|
|
165
|
-
|
|
182
|
+
message: string,
|
|
166
183
|
turnId: string,
|
|
167
184
|
abort: AbortController,
|
|
168
185
|
options?: SendOptions,
|
|
169
186
|
): Promise<TurnResult> {
|
|
170
|
-
|
|
171
|
-
|
|
187
|
+
// Arm every stop before the first await: an interrupt that lands while
|
|
188
|
+
// turn_start handlers run must still stop the turn.
|
|
172
189
|
const timeoutSec = options?.timeoutSec ?? this.ctx.config?.timeoutSec;
|
|
173
190
|
const graceSec = this.ctx.config?.graceSec;
|
|
174
191
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
175
192
|
const guards: Promise<"timeout" | "aborted">[] = [
|
|
176
|
-
new Promise((resolve) =>
|
|
193
|
+
new Promise((resolve) => {
|
|
194
|
+
if (abort.signal.aborted) resolve("aborted");
|
|
195
|
+
else abort.signal.addEventListener("abort", () => resolve("aborted"), { once: true });
|
|
196
|
+
}),
|
|
177
197
|
];
|
|
178
198
|
if (timeoutSec && timeoutSec > 0) {
|
|
179
199
|
guards.push(new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), timeoutSec * 1000); }));
|
|
@@ -184,15 +204,30 @@ class AntigravitySession implements AgentSession {
|
|
|
184
204
|
else options.signal.addEventListener("abort", onSendAbort, { once: true });
|
|
185
205
|
}
|
|
186
206
|
|
|
187
|
-
let connection =
|
|
207
|
+
let connection: AgyConnection | null = null;
|
|
188
208
|
try {
|
|
209
|
+
await this.emitTurnEdge({ type: "turn_start", turnId, trigger: "send" });
|
|
210
|
+
if (abort.signal.aborted) {
|
|
211
|
+
// Stopped before agy saw anything: nothing to interrupt, nothing spawned.
|
|
212
|
+
await this.emitTurnEdge({ type: "turn_end", turnId, trigger: "send", reason: "cancelled" });
|
|
213
|
+
return { summary: null, costUsd: null, status: "aborted", errorCode: "aborted", errorMessage: "Turn aborted" };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
connection = this.ensureConnection();
|
|
189
217
|
let result: TurnResult | null = null;
|
|
190
218
|
let reason: "result" | "cancelled" | "session_closed" = "result";
|
|
191
219
|
for (let attempt = 0; result === null; attempt++) {
|
|
192
220
|
const eventsBefore = this.connectionEvents;
|
|
221
|
+
const carriesInstructions = this.needsInstructions();
|
|
193
222
|
const pending = connection.nextTurn();
|
|
194
|
-
connection.write(
|
|
223
|
+
connection.write(carriesInstructions ? `${this.instructions}\n\n${message}` : message);
|
|
195
224
|
const first = await Promise.race([pending, ...guards]);
|
|
225
|
+
// Any event from agy means it accepted the message, instructions included.
|
|
226
|
+
if (carriesInstructions && this.connectionEvents > eventsBefore) {
|
|
227
|
+
this.connectionInstructed = true;
|
|
228
|
+
const conversation = this.parser?.conversationId;
|
|
229
|
+
if (conversation) this.instructedConversations.add(conversation);
|
|
230
|
+
}
|
|
196
231
|
if (first === "timeout" || first === "aborted") {
|
|
197
232
|
const outcome = await interruptAgyTurn(connection, pending, graceSec);
|
|
198
233
|
// agy may report INTERRUPTED and then exit. Learn which before the
|
|
@@ -230,11 +265,17 @@ class AntigravitySession implements AgentSession {
|
|
|
230
265
|
if (timer) clearTimeout(timer);
|
|
231
266
|
options?.signal?.removeEventListener("abort", onSendAbort);
|
|
232
267
|
this.active = null;
|
|
233
|
-
if (!connection.alive && this.connection === connection) this.connection = null;
|
|
268
|
+
if (connection && !connection.alive && this.connection === connection) this.connection = null;
|
|
234
269
|
if (this._state !== "closed") this._state = "idle";
|
|
235
270
|
}
|
|
236
271
|
}
|
|
237
272
|
|
|
273
|
+
/** The next message must carry the instructions (see `instructions`). */
|
|
274
|
+
private needsInstructions(): boolean {
|
|
275
|
+
if (!this.instructions || this.connectionInstructed) return false;
|
|
276
|
+
return !(this.connectionResumeId && this.instructedConversations.has(this.connectionResumeId));
|
|
277
|
+
}
|
|
278
|
+
|
|
238
279
|
private usageOf(outcome: AgyTurnOutcome | null): { usage?: TurnResult["usage"] } {
|
|
239
280
|
const usage = agyUsageRecord(outcome?.result?.turnUsage ?? null, this.parser?.model ?? this.ctx.config?.model ?? null);
|
|
240
281
|
return usage ? { usage } : {};
|
|
@@ -335,12 +376,12 @@ class AntigravitySession implements AgentSession {
|
|
|
335
376
|
const active = this.active;
|
|
336
377
|
if (!active) return;
|
|
337
378
|
active.abort.abort();
|
|
338
|
-
await active.done
|
|
379
|
+
await active.done?.catch(() => undefined);
|
|
339
380
|
}
|
|
340
381
|
|
|
341
382
|
async drain(): Promise<void> {
|
|
342
383
|
this.draining = true;
|
|
343
|
-
|
|
384
|
+
await this.active?.done?.catch(() => undefined);
|
|
344
385
|
await this.close();
|
|
345
386
|
}
|
|
346
387
|
|
|
@@ -350,7 +391,7 @@ class AntigravitySession implements AgentSession {
|
|
|
350
391
|
const active = this.active;
|
|
351
392
|
if (active) {
|
|
352
393
|
active.abort.abort();
|
|
353
|
-
await active.done
|
|
394
|
+
await active.done?.catch(() => undefined);
|
|
354
395
|
}
|
|
355
396
|
const connection = this.connection;
|
|
356
397
|
this.connection = null;
|
|
@@ -81,6 +81,61 @@ function parseStringArray(value: unknown): string[] {
|
|
|
81
81
|
* tells it to do — rendered no row for completions that reached it by the
|
|
82
82
|
* reconcile path. Mirrors the Claude gate in `claude/parse.ts`.
|
|
83
83
|
*/
|
|
84
|
+
/**
|
|
85
|
+
* The name an MCP tool call goes by: `mcp__<server>__<tool>`, the name the
|
|
86
|
+
* model calls it by and the shape Claude reports, so hosts render both alike.
|
|
87
|
+
* Codex's `tool` can be dotted (`github.create_branch` on the `codex_apps`
|
|
88
|
+
* server); the model sees `mcp__codex_apps__github_create_branch`.
|
|
89
|
+
*/
|
|
90
|
+
export function codexMcpToolName(server: string, tool: string | null): string {
|
|
91
|
+
const clean = (part: string) => part.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
92
|
+
return tool ? `mcp__${clean(server)}__${clean(tool)}` : `mcp__${clean(server)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isMcpToolCallItem(itemType: string): boolean {
|
|
96
|
+
return itemType === "mcpToolCall" || itemType === "mcp_tool_call";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What an MCP tool call returned: its error, else its text content, else its structured content. */
|
|
100
|
+
function mcpToolCallOutput(item: Record<string, unknown>): string {
|
|
101
|
+
const error = parseObject(item["error"]);
|
|
102
|
+
const message = asString(error["message"], "");
|
|
103
|
+
if (message) return message;
|
|
104
|
+
const result = parseObject(item["result"]);
|
|
105
|
+
const content = Array.isArray(result["content"]) ? result["content"] : [];
|
|
106
|
+
const text = content
|
|
107
|
+
.map((part) => {
|
|
108
|
+
const block = parseObject(part);
|
|
109
|
+
return block["type"] === "text" ? asString(block["text"], "") : JSON.stringify(part);
|
|
110
|
+
})
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join("\n");
|
|
113
|
+
if (text) return text;
|
|
114
|
+
const structured = result["structuredContent"] ?? result["structured_content"];
|
|
115
|
+
return structured === undefined || structured === null ? "" : JSON.stringify(structured);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function mcpToolCallEvent(
|
|
119
|
+
phase: "started" | "completed",
|
|
120
|
+
item: Record<string, unknown>,
|
|
121
|
+
itemId: string | null,
|
|
122
|
+
base: BaseStreamEventFields,
|
|
123
|
+
): StreamEvent {
|
|
124
|
+
const name = codexMcpToolName(asString(item["server"], ""), asNullableString(item["tool"]));
|
|
125
|
+
if (phase === "started") {
|
|
126
|
+
return { type: "tool_call", toolCallId: itemId, name, input: item["arguments"] ?? {}, ...base };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
type: "tool_result",
|
|
130
|
+
toolCallId: itemId,
|
|
131
|
+
toolName: name,
|
|
132
|
+
content: mcpToolCallOutput(item),
|
|
133
|
+
isError: item["status"] === "failed" || Object.keys(parseObject(item["error"])).length > 0,
|
|
134
|
+
exitCode: null,
|
|
135
|
+
...base,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
84
139
|
export function codexBackgroundTaskReport(
|
|
85
140
|
phase: "started" | "progress" | "completed",
|
|
86
141
|
status: "pending" | "running" | "paused" | "completed" | "failed" | "stopped" | null,
|
|
@@ -402,6 +457,7 @@ function parseV2Notification(event: Record<string, unknown>): StreamEvent | Stre
|
|
|
402
457
|
...base,
|
|
403
458
|
};
|
|
404
459
|
}
|
|
460
|
+
if (isMcpToolCallItem(itemType)) return mcpToolCallEvent("started", item, itemId, base);
|
|
405
461
|
// reasoning, agentMessage, userMessage — wait for item/completed.
|
|
406
462
|
return null;
|
|
407
463
|
}
|
|
@@ -432,6 +488,7 @@ function parseV2Notification(event: Record<string, unknown>): StreamEvent | Stre
|
|
|
432
488
|
...base,
|
|
433
489
|
};
|
|
434
490
|
}
|
|
491
|
+
if (isMcpToolCallItem(itemType)) return mcpToolCallEvent("completed", item, itemId, base);
|
|
435
492
|
if (itemType === "agentMessage") {
|
|
436
493
|
const directText = asString(item["text"], "");
|
|
437
494
|
if (directText || directText === "") {
|
|
@@ -690,6 +747,7 @@ function parseNdjsonEvent(
|
|
|
690
747
|
...base,
|
|
691
748
|
};
|
|
692
749
|
}
|
|
750
|
+
if (isMcpToolCallItem(itemType)) return mcpToolCallEvent("started", item, itemId, base);
|
|
693
751
|
}
|
|
694
752
|
|
|
695
753
|
if (type === "item.completed") {
|
|
@@ -721,6 +779,7 @@ function parseNdjsonEvent(
|
|
|
721
779
|
...base,
|
|
722
780
|
};
|
|
723
781
|
}
|
|
782
|
+
if (isMcpToolCallItem(itemType)) return mcpToolCallEvent("completed", item, itemId, base);
|
|
724
783
|
if (itemType === "agent_message") {
|
|
725
784
|
const phase = asMessagePhase(item["phase"]);
|
|
726
785
|
const directText = asString(item["text"], "");
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval policy and sandbox a Codex app-server thread runs with.
|
|
3
|
+
*
|
|
4
|
+
* `codex app-server` reads root `-c key=value` overrides but drops the
|
|
5
|
+
* interactive flags (`--sandbox`, `--dangerously-bypass-approvals-and-sandbox`):
|
|
6
|
+
* they belong to the TUI, and the `app-server` subcommand never receives them
|
|
7
|
+
* (codex-rs `cli/src/main.rs`, verified on 0.160). Sessions spawned with those
|
|
8
|
+
* flags ran on Codex's own defaults instead: approvals `on-request` and a
|
|
9
|
+
* `workspace-write` or `read-only` sandbox, whatever the host asked for.
|
|
10
|
+
*
|
|
11
|
+
* So the policy is set twice, through the two channels app-server does read:
|
|
12
|
+
* the `-c` overrides at spawn (every thread the process runs, child agents and
|
|
13
|
+
* resumed threads included) and the `thread/start` / `thread/resume` params
|
|
14
|
+
* (the root thread, explicitly, so a resumed thread created under different
|
|
15
|
+
* settings takes the current ones).
|
|
16
|
+
*
|
|
17
|
+
* It matters beyond the sandbox. Under `never` with full disk access, Codex
|
|
18
|
+
* approves MCP and app tool calls itself (`mcp_permission_prompt_is_auto_approved`
|
|
19
|
+
* in codex-rs `codex-mcp`). Under `on-request` it asks the client first, through
|
|
20
|
+
* `mcpServer/elicitation/request`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ProviderConfig } from "../../types.js";
|
|
24
|
+
|
|
25
|
+
export type CodexSandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
|
26
|
+
|
|
27
|
+
export interface CodexThreadPermissions {
|
|
28
|
+
/** Unset leaves Codex's configured default (`on-request` unless the user changed it). */
|
|
29
|
+
approvalPolicy?: "never";
|
|
30
|
+
/** Unset leaves Codex's configured default, which depends on the folder's trust. */
|
|
31
|
+
sandbox?: CodexSandboxMode;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Plan mode wins over `skipPermissions`, as it does for the spawn flags:
|
|
36
|
+
* read-only, with approvals left on so an escalation still reaches the host.
|
|
37
|
+
* Anything else leaves Codex's defaults, which is what no flag ever meant.
|
|
38
|
+
*/
|
|
39
|
+
export function codexThreadPermissions(
|
|
40
|
+
config: Pick<ProviderConfig, "planMode" | "skipPermissions"> | undefined,
|
|
41
|
+
): CodexThreadPermissions {
|
|
42
|
+
if (config?.planMode) return { sandbox: "read-only" };
|
|
43
|
+
if (config?.skipPermissions) return { approvalPolicy: "never", sandbox: "danger-full-access" };
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The same settings as root `-c` overrides, placed before the `app-server` subcommand. */
|
|
48
|
+
export function codexPermissionConfigArgs(permissions: CodexThreadPermissions): string[] {
|
|
49
|
+
const args: string[] = [];
|
|
50
|
+
if (permissions.approvalPolicy) args.push("-c", `approval_policy="${permissions.approvalPolicy}"`);
|
|
51
|
+
if (permissions.sandbox) args.push("-c", `sandbox_mode="${permissions.sandbox}"`);
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The same settings as `thread/start` / `thread/resume` params. */
|
|
56
|
+
export function codexPermissionThreadParams(permissions: CodexThreadPermissions): Record<string, unknown> {
|
|
57
|
+
return {
|
|
58
|
+
...(permissions.approvalPolicy ? { approvalPolicy: permissions.approvalPolicy } : {}),
|
|
59
|
+
...(permissions.sandbox ? { sandbox: permissions.sandbox } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import type {
|
|
5
5
|
BackgroundTaskReport,
|
|
6
6
|
AgentSession,
|
|
7
|
+
ElicitationResponse,
|
|
7
8
|
CancelResult,
|
|
8
9
|
ClearGoalResult,
|
|
9
10
|
GoalOptions,
|
|
@@ -28,11 +29,16 @@ import { translateEndpoint } from "../../utils/endpoint.js";
|
|
|
28
29
|
import { injectWorkspaceSkills } from "../../utils/skills.js";
|
|
29
30
|
import { resolveInstructions } from "../../utils/instructions.js";
|
|
30
31
|
import { createToolNameTracker } from "../../utils/tool-names.js";
|
|
31
|
-
import { parseCodexStreamLines, codexBackgroundTaskReport } from "./parse.js";
|
|
32
|
+
import { parseCodexStreamLines, codexBackgroundTaskReport, codexMcpToolName } from "./parse.js";
|
|
32
33
|
import { withPlanModePreamble } from "./plan-mode.js";
|
|
33
34
|
import { scanCodexSessionUsage } from "./usage-scanner.js";
|
|
34
35
|
import { codexSessionCodec } from "./codec.js";
|
|
35
36
|
import { parseCollaborationModes, resolveCollaborationModeParam } from "./modes.js";
|
|
37
|
+
import {
|
|
38
|
+
codexPermissionConfigArgs,
|
|
39
|
+
codexPermissionThreadParams,
|
|
40
|
+
codexThreadPermissions,
|
|
41
|
+
} from "./permissions.js";
|
|
36
42
|
|
|
37
43
|
/**
|
|
38
44
|
* Extract a resume thread id from session params (reusing the codec's
|
|
@@ -122,6 +128,52 @@ function buildCodexUserInputAnswers(
|
|
|
122
128
|
return out;
|
|
123
129
|
}
|
|
124
130
|
|
|
131
|
+
/** An MCP tool call Codex has started and not finished (`item/started`, `mcpToolCall`). */
|
|
132
|
+
interface InFlightMcpCall {
|
|
133
|
+
itemId: string;
|
|
134
|
+
threadId: string | null;
|
|
135
|
+
server: string;
|
|
136
|
+
tool: string;
|
|
137
|
+
arguments: unknown;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* One line for an MCP tool approval from Codex's `tool_params_display`
|
|
142
|
+
* (`[{ name, display_name, value }]`), e.g.
|
|
143
|
+
* `Repository: acme/web · Branch name: fix/login · Base ref: main`.
|
|
144
|
+
*/
|
|
145
|
+
function describeMcpToolParams(display: unknown): string | undefined {
|
|
146
|
+
if (!Array.isArray(display)) return undefined;
|
|
147
|
+
const parts: string[] = [];
|
|
148
|
+
for (const entry of display) {
|
|
149
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
150
|
+
const param = entry as Record<string, unknown>;
|
|
151
|
+
const label = str(param, "display_name") || str(param, "name");
|
|
152
|
+
if (!label) continue;
|
|
153
|
+
const raw = param["value"];
|
|
154
|
+
const value = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
155
|
+
if (value === undefined) continue;
|
|
156
|
+
parts.push(`${label}: ${value.length > 120 ? `${value.slice(0, 117)}...` : value}`);
|
|
157
|
+
}
|
|
158
|
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The elicitation reply Codex expects: `action`, plus `content` only on an accept. */
|
|
162
|
+
function elicitationReply(resp: ElicitationResponse): Record<string, unknown> {
|
|
163
|
+
return {
|
|
164
|
+
action: resp.action,
|
|
165
|
+
...(resp.action === "accept" && resp.content ? { content: resp.content } : {}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** JSON with object keys sorted, so two argument objects compare by value. */
|
|
170
|
+
function stableJson(value: unknown): string {
|
|
171
|
+
return JSON.stringify(value, (_key, v: unknown) =>
|
|
172
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
173
|
+
? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)))
|
|
174
|
+
: v) ?? "";
|
|
175
|
+
}
|
|
176
|
+
|
|
125
177
|
/** A pending `send()` whose `result` Promise hasn't settled yet. */
|
|
126
178
|
interface PendingResult {
|
|
127
179
|
resolve: (result: TurnResult) => void;
|
|
@@ -328,15 +380,13 @@ export async function createCodexSession(ctx: SessionContext): Promise<AgentSess
|
|
|
328
380
|
// Spawn Codex in interactive JSON-RPC mode via the `app-server` subcommand
|
|
329
381
|
// (codex-cli 0.130.0+; the old top-level `--json` flag was removed).
|
|
330
382
|
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
383
|
+
// Approval policy and sandbox go in as root `-c` overrides, BEFORE the
|
|
384
|
+
// subcommand: app-server ignores `--sandbox` and
|
|
385
|
+
// `--dangerously-bypass-approvals-and-sandbox` (see ./permissions.ts). The
|
|
386
|
+
// handshake repeats them on the thread itself.
|
|
333
387
|
// extraArgs land after the subcommand — semantics depend on the user's intent.
|
|
334
388
|
const args = [...resolved.prefixArgs];
|
|
335
|
-
|
|
336
|
-
args.push("--sandbox", "read-only");
|
|
337
|
-
} else if (config.skipPermissions) {
|
|
338
|
-
args.push("--dangerously-bypass-approvals-and-sandbox");
|
|
339
|
-
}
|
|
389
|
+
args.push(...codexPermissionConfigArgs(codexThreadPermissions(config)));
|
|
340
390
|
// Custom endpoint model_providers overrides are top-level `-c` options, placed
|
|
341
391
|
// with the other top-level flags before the `app-server` subcommand (the
|
|
342
392
|
// position that is always valid for global options).
|
|
@@ -437,6 +487,9 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
437
487
|
/** Stamps `tool_result.toolName` by correlating with prior `tool_call`s. */
|
|
438
488
|
private readonly _trackToolName = createToolNameTracker();
|
|
439
489
|
|
|
490
|
+
/** MCP tool calls started and not yet completed, by item id, for pairing approvals. */
|
|
491
|
+
private readonly _mcpCallsInFlight = new Map<string, InFlightMcpCall>();
|
|
492
|
+
|
|
440
493
|
// Per-turn accumulators. Cleared after each result delivery so a subsequent
|
|
441
494
|
// turn's events don't inherit stale values.
|
|
442
495
|
private _turnSummary: string | null = null;
|
|
@@ -771,6 +824,10 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
771
824
|
this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
|
|
772
825
|
}
|
|
773
826
|
|
|
827
|
+
private rpcError(id: number, code: number, message: string): void {
|
|
828
|
+
this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n");
|
|
829
|
+
}
|
|
830
|
+
|
|
774
831
|
// -------------------------------------------------------------------------
|
|
775
832
|
// Handshake
|
|
776
833
|
// -------------------------------------------------------------------------
|
|
@@ -791,9 +848,12 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
791
848
|
// otherwise start a fresh one. `thread/resume` continues the SAME thread
|
|
792
849
|
// with its full context retained — distinct from `thread/fork`, which is
|
|
793
850
|
// a divergent rewind copy. The thread keeps its original cwd/model, so we
|
|
794
|
-
// pass only the thread id (+ refreshed developer instructions
|
|
851
|
+
// pass only the thread id (+ refreshed developer instructions, and the
|
|
852
|
+
// approval policy and sandbox this session runs with, which may differ
|
|
853
|
+
// from the ones the thread was created under).
|
|
854
|
+
const permissionParams = codexPermissionThreadParams(codexThreadPermissions(this.ctx.config));
|
|
795
855
|
if (this._resumeThreadId) {
|
|
796
|
-
const resumeParams: Record<string, unknown> = { threadId: this._resumeThreadId };
|
|
856
|
+
const resumeParams: Record<string, unknown> = { threadId: this._resumeThreadId, ...permissionParams };
|
|
797
857
|
if (this.instructions) resumeParams["developerInstructions"] = this.instructions;
|
|
798
858
|
try {
|
|
799
859
|
const res = await this.rpcRequest("thread/resume", resumeParams);
|
|
@@ -830,7 +890,7 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
830
890
|
}
|
|
831
891
|
|
|
832
892
|
// thread/start (fresh)
|
|
833
|
-
const threadParams: Record<string, unknown> = { cwd: this.cwd };
|
|
893
|
+
const threadParams: Record<string, unknown> = { cwd: this.cwd, ...permissionParams };
|
|
834
894
|
if (this.model) threadParams["model"] = this.model;
|
|
835
895
|
if (this.instructions) threadParams["developerInstructions"] = this.instructions;
|
|
836
896
|
|
|
@@ -1236,16 +1296,174 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1236
1296
|
) {
|
|
1237
1297
|
// `tool/requestUserInput` is the legacy method name on older codex builds.
|
|
1238
1298
|
void this.handleUserInputRequest(id, params);
|
|
1299
|
+
} else if (method === "mcpServer/elicitation/request") {
|
|
1300
|
+
void this.handleMcpElicitation(id, params);
|
|
1301
|
+
} else {
|
|
1302
|
+
// A request this file does not handle. Every server request the protocol
|
|
1303
|
+
// defines declares required response fields, so the `{}` this used to
|
|
1304
|
+
// send was schema-invalid, and Codex read it as a refusal: that is how
|
|
1305
|
+
// MCP tool approvals were silently declined before they were handled
|
|
1306
|
+
// here. "Method not found" is the honest answer and fails visibly.
|
|
1307
|
+
// What's left is opt-in (`attestation/generate`), never sent to a client
|
|
1308
|
+
// that registers no dynamic tools and no external auth (`item/tool/call`,
|
|
1309
|
+
// `account/chatgptAuthTokens/refresh`), or v1-only (`applyPatchApproval`,
|
|
1310
|
+
// `execCommandApproval`).
|
|
1311
|
+
this.rpcError(id, -32601, `agentex does not handle the ${method} request`);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Handle `mcpServer/elicitation/request`. Codex sends two kinds.
|
|
1317
|
+
*
|
|
1318
|
+
* With `_meta.codex_approval_kind: "mcp_tool_call"` it is a tool permission:
|
|
1319
|
+
* an MCP tool (a ChatGPT app such as GitHub, or a server in the user's
|
|
1320
|
+
* config.toml) that needs a yes before it runs. Codex asks only while the
|
|
1321
|
+
* thread's approval policy is on; under `never` with full access it approves
|
|
1322
|
+
* these itself. It goes to `onUserInputRequest` like every other approval.
|
|
1323
|
+
*
|
|
1324
|
+
* Anything else is an MCP elicitation proper, a form or a URL an MCP server
|
|
1325
|
+
* asks the user to fill in or open, and goes to `onElicitation`.
|
|
1326
|
+
*
|
|
1327
|
+
* The reply must carry `action`. Before this was handled, the generic `{}`
|
|
1328
|
+
* reply was read as a decline, so every app write failed as "user rejected
|
|
1329
|
+
* MCP tool call" with nobody asked.
|
|
1330
|
+
*/
|
|
1331
|
+
private async handleMcpElicitation(id: number, params: Record<string, unknown>): Promise<void> {
|
|
1332
|
+
const meta = asObj(params, "_meta");
|
|
1333
|
+
if (str(meta, "codex_approval_kind") === "mcp_tool_call") {
|
|
1334
|
+
await this.handleMcpToolApproval(id, params, meta);
|
|
1239
1335
|
} else {
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1336
|
+
await this.handleElicitation(id, params);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
private async handleMcpToolApproval(
|
|
1341
|
+
id: number,
|
|
1342
|
+
params: Record<string, unknown>,
|
|
1343
|
+
meta: Record<string, unknown>,
|
|
1344
|
+
): Promise<void> {
|
|
1345
|
+
const requestThreadId = str(params, "threadId") || null;
|
|
1346
|
+
const foreign = this.isForeignThread(requestThreadId);
|
|
1347
|
+
if (!foreign) this._state = "waiting_for_approval";
|
|
1348
|
+
const restore = () => { if (!foreign) this.restoreStateAfter("waiting_for_approval"); };
|
|
1349
|
+
|
|
1350
|
+
// No host handler: allowed, like the command and file-change approvals.
|
|
1351
|
+
if (!this.ctx.onUserInputRequest) {
|
|
1352
|
+
this.rpcResponse(id, { action: "accept" });
|
|
1353
|
+
restore();
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// The request names neither the tool nor the call it gates. The call has
|
|
1358
|
+
// already started (Codex reports `item/started` before it asks), so pair
|
|
1359
|
+
// the two through the in-flight MCP calls.
|
|
1360
|
+
const server = str(params, "serverName");
|
|
1361
|
+
const toolParams = meta["tool_params"];
|
|
1362
|
+
const call = this.findMcpCallForApproval(requestThreadId, server, toolParams);
|
|
1363
|
+
const input = call?.arguments ?? toolParams;
|
|
1364
|
+
|
|
1365
|
+
try {
|
|
1366
|
+
const resp = await this.ctx.onUserInputRequest({
|
|
1367
|
+
toolName: codexMcpToolName(call?.server ?? server, call?.tool ?? null),
|
|
1368
|
+
input: input && typeof input === "object" && !Array.isArray(input)
|
|
1369
|
+
? input as Record<string, unknown>
|
|
1370
|
+
: {},
|
|
1371
|
+
// The call's item id lines the approval up with its tool_call event,
|
|
1372
|
+
// as `itemId` does for command approvals. Without a match, a fresh id:
|
|
1373
|
+
// hosts key pending approvals on this, so it must never repeat.
|
|
1374
|
+
toolUseId: call?.itemId ?? `codex-mcp-approval-${randomUUID()}`,
|
|
1375
|
+
// Codex's own question, e.g. `Allow GitHub to run tool "create_branch"?`.
|
|
1376
|
+
title: str(params, "message") || undefined,
|
|
1377
|
+
displayName: str(meta, "tool_title") || str(meta, "connector_name") || undefined,
|
|
1378
|
+
description: describeMcpToolParams(meta["tool_params_display"]),
|
|
1379
|
+
...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
|
|
1380
|
+
});
|
|
1381
|
+
// A plain accept runs this one call. Codex also offers "for this
|
|
1382
|
+
// session" and "always" (`_meta.persist`), which agentex's yes/no
|
|
1383
|
+
// permission has no way to express, so the host is asked each time.
|
|
1384
|
+
this.rpcResponse(id, { action: resp.allow ? "accept" : "decline" });
|
|
1385
|
+
} catch {
|
|
1386
|
+
this.rpcResponse(id, { action: "decline" });
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
restore();
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/** An MCP server's form or URL request, for the host's `onElicitation`. Declined without one. */
|
|
1393
|
+
private async handleElicitation(id: number, params: Record<string, unknown>): Promise<void> {
|
|
1394
|
+
if (!this.ctx.onElicitation) {
|
|
1395
|
+
this.rpcResponse(id, { action: "decline" });
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
const requestThreadId = str(params, "threadId") || null;
|
|
1400
|
+
const foreign = this.isForeignThread(requestThreadId);
|
|
1401
|
+
if (!foreign) this._state = "waiting_for_input";
|
|
1402
|
+
const restore = () => { if (!foreign) this.restoreStateAfter("waiting_for_input"); };
|
|
1403
|
+
|
|
1404
|
+
const url = str(params, "url");
|
|
1405
|
+
const elicitationId = str(params, "elicitationId");
|
|
1406
|
+
const schema = params["requestedSchema"];
|
|
1407
|
+
try {
|
|
1408
|
+
const resp = await this.ctx.onElicitation({
|
|
1409
|
+
mcpServerName: str(params, "serverName"),
|
|
1410
|
+
message: str(params, "message"),
|
|
1411
|
+
mode: str(params, "mode") === "url" ? "url" : "form",
|
|
1412
|
+
...(url ? { url } : {}),
|
|
1413
|
+
...(elicitationId ? { elicitationId } : {}),
|
|
1414
|
+
...(schema && typeof schema === "object" && !Array.isArray(schema)
|
|
1415
|
+
? { requestedSchema: schema as Record<string, unknown> }
|
|
1416
|
+
: {}),
|
|
1417
|
+
});
|
|
1418
|
+
this.rpcResponse(id, elicitationReply(resp));
|
|
1419
|
+
} catch {
|
|
1420
|
+
this.rpcResponse(id, { action: "cancel" });
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
restore();
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Remember the MCP tool calls Codex has started and not finished, on every
|
|
1428
|
+
* thread, so an approval request can be paired with the call it gates.
|
|
1429
|
+
*/
|
|
1430
|
+
private trackMcpToolCall(method: string, params: Record<string, unknown>): void {
|
|
1431
|
+
if (method !== "item/started" && method !== "item/completed") return;
|
|
1432
|
+
const item = asObj(params, "item");
|
|
1433
|
+
if (str(item, "type") !== "mcpToolCall") return;
|
|
1434
|
+
const itemId = str(item, "id");
|
|
1435
|
+
if (!itemId) return;
|
|
1436
|
+
if (method === "item/completed") {
|
|
1437
|
+
this._mcpCallsInFlight.delete(itemId);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
this._mcpCallsInFlight.set(itemId, {
|
|
1441
|
+
itemId,
|
|
1442
|
+
threadId: str(params, "threadId") || null,
|
|
1443
|
+
server: str(item, "server"),
|
|
1444
|
+
tool: str(item, "tool"),
|
|
1445
|
+
arguments: item["arguments"],
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* The in-flight call an approval is about: on the asking thread and server,
|
|
1451
|
+
* the one whose arguments match the approval's `tool_params`, else the most
|
|
1452
|
+
* recently started (Codex asks right after it starts a call).
|
|
1453
|
+
*/
|
|
1454
|
+
private findMcpCallForApproval(
|
|
1455
|
+
threadId: string | null,
|
|
1456
|
+
server: string,
|
|
1457
|
+
toolParams: unknown,
|
|
1458
|
+
): InFlightMcpCall | null {
|
|
1459
|
+
const candidates = [...this._mcpCallsInFlight.values()].filter((call) =>
|
|
1460
|
+
call.server === server && (!threadId || !call.threadId || call.threadId === threadId));
|
|
1461
|
+
if (toolParams !== undefined) {
|
|
1462
|
+
const wanted = stableJson(toolParams);
|
|
1463
|
+
const exact = candidates.find((call) => stableJson(call.arguments) === wanted);
|
|
1464
|
+
if (exact) return exact;
|
|
1248
1465
|
}
|
|
1466
|
+
return candidates.at(-1) ?? null;
|
|
1249
1467
|
}
|
|
1250
1468
|
|
|
1251
1469
|
/**
|
|
@@ -1965,6 +2183,7 @@ export class CodexSessionImpl implements AgentSession {
|
|
|
1965
2183
|
|
|
1966
2184
|
private handleNotification(method: string, params: Record<string, unknown>, rawLine: string): void {
|
|
1967
2185
|
if (this._state === "closed") return;
|
|
2186
|
+
this.trackMcpToolCall(method, params);
|
|
1968
2187
|
// codex/event — legacy wrapper
|
|
1969
2188
|
if (method === "codex/event") {
|
|
1970
2189
|
const innerMsg = str(params, "msg");
|