@inixiative/agent-session 0.5.0 → 0.6.0
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/README.md +31 -1
- package/package.json +5 -4
- package/src/claude-agent-sdk-session.ts +128 -3
- package/src/claude-code-session.ts +196 -2
- package/src/codex-session.ts +242 -4
- package/src/harness-session.ts +51 -1
- package/src/index.ts +5 -0
- package/src/pool.ts +11 -0
- package/src/routing.ts +22 -18
- package/src/tool-gate.ts +61 -0
- package/src/transport.ts +6 -4
package/README.md
CHANGED
|
@@ -75,12 +75,42 @@ const session = createSession(kind, { cwd, model: "gpt-6-luna", env: { CODEX_HOM
|
|
|
75
75
|
| Resume / fork | native / native | loaded-only / same-thread | native / – | native / native | – | – |
|
|
76
76
|
| Interrupt | acknowledged | local-only | acknowledged | acknowledged | – | – |
|
|
77
77
|
| Push | – | – | steer | – | – | – |
|
|
78
|
-
| Approvals |
|
|
78
|
+
| Approvals | callback | mode | callback | callback | – | – |
|
|
79
79
|
| Limits stream / poll | ✅ / ✅ | – / – | ✅ / ✅ | ✅ / – | – | – |
|
|
80
80
|
| Primed reset | fork | – | fork | – | – | – |
|
|
81
81
|
|
|
82
82
|
Full matrix, notes and what is stubbed: [docs/transports-pools-routing.md](docs/transports-pools-routing.md).
|
|
83
83
|
|
|
84
|
+
### Tool gate
|
|
85
|
+
|
|
86
|
+
`onToolRequest(gate)` puts a decision on each tool call. Gates compose in
|
|
87
|
+
registration order; the first deny wins and a throwing gate denies.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const session = new ClaudeCodeSession({ cwd, permissionMode: hostMode, toolGate: true });
|
|
91
|
+
session.onToolRequest(async (call) => {
|
|
92
|
+
if (call.stage === "pre-use") return structural(call) ?? { behavior: "allow" };
|
|
93
|
+
return askTheHuman(call, call.signal); // the runtime's own mode wanted to ask
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- `pre-use` (Claude only) runs before every call in every mode,
|
|
98
|
+
`bypassPermissions` included; only a deny counts. A PreToolUse callback over
|
|
99
|
+
the control protocol; one the session cannot read is denied.
|
|
100
|
+
- `permission` is an ask the runtime's mode raised; the answer is final (no gate:
|
|
101
|
+
denied). Claude: `--permission-prompt-tool stdio`; `decisionReason` says why it
|
|
102
|
+
asked, `agentId` marks a subagent's call. Codex app-server: command and
|
|
103
|
+
file-change `requestApproval`, answered accept/decline, under
|
|
104
|
+
`appServer: codexPermissions(mode)`. File-change asks carry the item's
|
|
105
|
+
`toolInput.changes` (path, kind, bounded diff); command asks carry
|
|
106
|
+
`networkApprovalContext`. Gated threads pin `approvalsReviewer: "user"` and
|
|
107
|
+
are refused if a config routes approvals elsewhere. Under the default `never`
|
|
108
|
+
policy nothing is asked, so `onToolRequest` throws.
|
|
109
|
+
- `call.signal` aborts when the runtime withdraws the request or the session
|
|
110
|
+
ends (kill, exit, transport failure); a decision after that is never written.
|
|
111
|
+
A turn timeout does not withdraw a pending Claude ask; `interruptNative()` does.
|
|
112
|
+
- A fork starts with a snapshot of the parent's gates.
|
|
113
|
+
|
|
84
114
|
## Primed decision sessions
|
|
85
115
|
|
|
86
116
|
One live, warm session per middleware role, primed once with its role
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/agent-session",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Drive coding-agent CLIs as persistent, streaming, event-captured sessions over interchangeable transports, with primed decision sessions, subscription pools, routing, limit polling and continuity.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".": "./src/index.ts"
|
|
9
|
+
".": "./src/index.ts",
|
|
10
|
+
"./routing": "./src/routing.ts"
|
|
10
11
|
},
|
|
11
12
|
"files": [
|
|
12
13
|
"src"
|
|
@@ -35,11 +36,11 @@
|
|
|
35
36
|
],
|
|
36
37
|
"license": "MIT",
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"@inixiative/json-rules": "^3.5.
|
|
39
|
+
"@inixiative/json-rules": "^3.5.1"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"@biomejs/biome": "2.5.2",
|
|
42
|
-
"@inixiative/config": "^0.5.
|
|
43
|
+
"@inixiative/config": "^0.5.67",
|
|
43
44
|
"@types/bun": "1.4.2",
|
|
44
45
|
"lefthook": "2.1.9",
|
|
45
46
|
"typescript": "6.0.3"
|
|
@@ -52,7 +52,8 @@ export interface ClaudeAgentSdkSessionConfig extends Omit<ClaudeCodeSessionConfi
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
const SDK_OWNED = new Set(['--print', '--verbose']);
|
|
55
|
-
|
|
55
|
+
// `--permission-prompt-tool stdio` is the SDK's own canUseTool channel; the bridge wires it.
|
|
56
|
+
const SDK_OWNED_VALUE = new Set(['--input-format', '--output-format', '--permission-prompt-tool']);
|
|
56
57
|
|
|
57
58
|
/** Map a ClaudeCodeSession argv (without the binary) to SDK options. Exported for tests. */
|
|
58
59
|
export function sdkOptionsFromArgv(argv: readonly string[]): Record<string, unknown> {
|
|
@@ -170,6 +171,94 @@ function bridge(
|
|
|
170
171
|
},
|
|
171
172
|
};
|
|
172
173
|
|
|
174
|
+
// Tool gate: the SDK's canUseTool and PreToolUse hook become the CLI's own control requests
|
|
175
|
+
// on the bridged stdout, so ClaudeCodeSession answers them exactly as on the CLI transport.
|
|
176
|
+
const gated = cmd.includes('--permission-prompt-tool');
|
|
177
|
+
let hookCallbackId: string | undefined;
|
|
178
|
+
const awaiting = new Map<string, (response: unknown) => void>();
|
|
179
|
+
const ask = (request: Record<string, unknown>, signal?: AbortSignal) =>
|
|
180
|
+
new Promise<unknown>((resolve) => {
|
|
181
|
+
const id = `sdk-bridge-${crypto.randomUUID()}`;
|
|
182
|
+
awaiting.set(id, resolve);
|
|
183
|
+
signal?.addEventListener(
|
|
184
|
+
'abort',
|
|
185
|
+
() => {
|
|
186
|
+
if (awaiting.delete(id)) {
|
|
187
|
+
emit({ type: 'control_cancel_request', request_id: id });
|
|
188
|
+
resolve(undefined);
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
{ once: true },
|
|
192
|
+
);
|
|
193
|
+
emit({ type: 'control_request', request_id: id, request });
|
|
194
|
+
});
|
|
195
|
+
const gate = gated
|
|
196
|
+
? {
|
|
197
|
+
canUseTool: async (
|
|
198
|
+
toolName: string,
|
|
199
|
+
input: Record<string, unknown>,
|
|
200
|
+
context: {
|
|
201
|
+
signal?: AbortSignal;
|
|
202
|
+
toolUseID?: string;
|
|
203
|
+
blockedPath?: string;
|
|
204
|
+
decisionReason?: string;
|
|
205
|
+
description?: string;
|
|
206
|
+
agentID?: string;
|
|
207
|
+
} = {},
|
|
208
|
+
) =>
|
|
209
|
+
((await ask(
|
|
210
|
+
{
|
|
211
|
+
subtype: 'can_use_tool',
|
|
212
|
+
tool_name: toolName,
|
|
213
|
+
input,
|
|
214
|
+
...(context.toolUseID ? { tool_use_id: context.toolUseID } : {}),
|
|
215
|
+
...(context.blockedPath ? { blocked_path: context.blockedPath } : {}),
|
|
216
|
+
...(typeof context.decisionReason === 'string' && context.decisionReason
|
|
217
|
+
? { decision_reason: context.decisionReason }
|
|
218
|
+
: {}),
|
|
219
|
+
...(typeof context.description === 'string' && context.description
|
|
220
|
+
? { description: context.description }
|
|
221
|
+
: {}),
|
|
222
|
+
...(context.agentID ? { agent_id: context.agentID } : {}),
|
|
223
|
+
},
|
|
224
|
+
context.signal,
|
|
225
|
+
)) as Record<string, unknown> | undefined) ?? {
|
|
226
|
+
behavior: 'deny',
|
|
227
|
+
message: 'Approval withdrawn',
|
|
228
|
+
},
|
|
229
|
+
hooks: {
|
|
230
|
+
PreToolUse: [
|
|
231
|
+
{
|
|
232
|
+
matcher: '.*',
|
|
233
|
+
hooks: [
|
|
234
|
+
async (
|
|
235
|
+
input: Record<string, unknown>,
|
|
236
|
+
toolUseID: string | undefined,
|
|
237
|
+
context: { signal?: AbortSignal } = {},
|
|
238
|
+
) =>
|
|
239
|
+
((await ask(
|
|
240
|
+
{
|
|
241
|
+
subtype: 'hook_callback',
|
|
242
|
+
callback_id: hookCallbackId,
|
|
243
|
+
input,
|
|
244
|
+
...(toolUseID ? { tool_use_id: toolUseID } : {}),
|
|
245
|
+
},
|
|
246
|
+
context.signal,
|
|
247
|
+
)) as Record<string, unknown> | undefined) ?? {
|
|
248
|
+
// No answer (bridge closed, or the session could not read it): fail closed.
|
|
249
|
+
hookSpecificOutput: {
|
|
250
|
+
hookEventName: 'PreToolUse',
|
|
251
|
+
permissionDecision: 'deny',
|
|
252
|
+
permissionDecisionReason: 'Tool gate unavailable',
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
: undefined;
|
|
261
|
+
|
|
173
262
|
const stringEnv = Object.fromEntries(
|
|
174
263
|
Object.entries(env).filter(
|
|
175
264
|
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
@@ -181,6 +270,19 @@ function bridge(
|
|
|
181
270
|
cwd,
|
|
182
271
|
env: stringEnv,
|
|
183
272
|
...(config.canUseTool ? { canUseTool: config.canUseTool } : {}),
|
|
273
|
+
...(gate
|
|
274
|
+
? {
|
|
275
|
+
canUseTool: gate.canUseTool,
|
|
276
|
+
hooks: {
|
|
277
|
+
...((config.sdkOptions?.hooks as Record<string, unknown> | undefined) ?? {}),
|
|
278
|
+
PreToolUse: [
|
|
279
|
+
...(((config.sdkOptions?.hooks as Record<string, unknown[]> | undefined)
|
|
280
|
+
?.PreToolUse ?? []) as unknown[]),
|
|
281
|
+
...gate.hooks.PreToolUse,
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
: {}),
|
|
184
286
|
...(config.bundledCli ? {} : { pathToClaudeCodeExecutable: Bun.which(cmd[0]!) ?? cmd[0] }),
|
|
185
287
|
};
|
|
186
288
|
let query: ClaudeAgentSdkQuery,
|
|
@@ -223,10 +325,27 @@ function bridge(
|
|
|
223
325
|
inbox.push({ ...message, parent_tool_use_id: null, session_id: '' });
|
|
224
326
|
wake?.();
|
|
225
327
|
wake = undefined;
|
|
328
|
+
} else if (message.type === 'control_response') {
|
|
329
|
+
const response = message.response as Record<string, unknown> | undefined;
|
|
330
|
+
const id = String(response?.request_id);
|
|
331
|
+
const resolve = awaiting.get(id);
|
|
332
|
+
if (resolve) {
|
|
333
|
+
awaiting.delete(id);
|
|
334
|
+
resolve(response?.subtype === 'success' ? response.response : undefined);
|
|
335
|
+
}
|
|
226
336
|
} else if (message.type === 'control_request') {
|
|
227
337
|
const id = String(message.request_id);
|
|
228
|
-
const
|
|
229
|
-
|
|
338
|
+
const request = message.request as Record<string, unknown> | undefined;
|
|
339
|
+
const subtype = request?.subtype;
|
|
340
|
+
if (subtype === 'initialize' && gated) {
|
|
341
|
+
// Hooks were configured as SDK options at launch; only the callback id is new.
|
|
342
|
+
const pre = (request?.hooks as Record<string, unknown> | undefined)?.PreToolUse;
|
|
343
|
+
const ids = Array.isArray(pre)
|
|
344
|
+
? (pre[0] as { hookCallbackIds?: unknown } | undefined)?.hookCallbackIds
|
|
345
|
+
: undefined;
|
|
346
|
+
hookCallbackId = Array.isArray(ids) ? String(ids[0]) : undefined;
|
|
347
|
+
queueMicrotask(() => respond(id));
|
|
348
|
+
} else if (subtype === 'interrupt')
|
|
230
349
|
query.interrupt().then(
|
|
231
350
|
() => respond(id),
|
|
232
351
|
(e) => respond(id, String((e as Error)?.message ?? e)),
|
|
@@ -254,6 +373,8 @@ function bridge(
|
|
|
254
373
|
kill() {
|
|
255
374
|
if (killed) return;
|
|
256
375
|
killed = true;
|
|
376
|
+
for (const resolve of awaiting.values()) resolve(undefined);
|
|
377
|
+
awaiting.clear();
|
|
257
378
|
inputEnded = true;
|
|
258
379
|
wake?.();
|
|
259
380
|
wake = undefined;
|
|
@@ -277,6 +398,10 @@ export class ClaudeAgentSdkSession extends ClaudeCodeSession {
|
|
|
277
398
|
constructor(config: ClaudeAgentSdkSessionConfig) {
|
|
278
399
|
if (typeof config?.query !== 'function')
|
|
279
400
|
throw Error('ClaudeAgentSdkSession requires `query` from @anthropic-ai/claude-agent-sdk');
|
|
401
|
+
if (config.canUseTool && config.toolGate)
|
|
402
|
+
throw Error(
|
|
403
|
+
'ClaudeAgentSdkSession: pass either canUseTool or toolGate (gates registered through onToolRequest), not both',
|
|
404
|
+
);
|
|
280
405
|
if (config.canUseTool && config.permissionMode === 'bypassPermissions')
|
|
281
406
|
throw Error(
|
|
282
407
|
'ClaudeAgentSdkSession: canUseTool is never consulted under permissionMode "bypassPermissions"; pass another mode or drop canUseTool',
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
mergeLimits,
|
|
6
6
|
} from './limits';
|
|
7
7
|
import { retainEvidence } from './retained-evidence';
|
|
8
|
+
import { mcpToolParts, ToolGates } from './tool-gate';
|
|
8
9
|
import { TRANSPORTS } from './transport';
|
|
9
10
|
import { TurnState } from './turn-state';
|
|
10
11
|
|
|
@@ -53,6 +54,9 @@ import type {
|
|
|
53
54
|
SessionResult,
|
|
54
55
|
SessionSendOptions,
|
|
55
56
|
SessionTokens,
|
|
57
|
+
ToolDecision,
|
|
58
|
+
ToolGate,
|
|
59
|
+
ToolRequest,
|
|
56
60
|
} from './harness-session';
|
|
57
61
|
|
|
58
62
|
// Re-export types so existing import paths keep working
|
|
@@ -113,6 +117,14 @@ export interface ClaudeCodeSessionConfig {
|
|
|
113
117
|
textOnly?: boolean;
|
|
114
118
|
/** False adds `--no-session-persistence` (nothing written to history; cannot be resumed). Default true. */
|
|
115
119
|
persistSession?: boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Launch for per-call tool decisions: `--permission-prompt-tool stdio` plus a PreToolUse
|
|
122
|
+
* callback registered over the control protocol at start(). Gates added with
|
|
123
|
+
* onToolRequest() decide (a `permission` ask with no gate is denied). A callback or ask that
|
|
124
|
+
* cannot be read as a tool request is denied (fail closed). A turn timeout leaves a pending
|
|
125
|
+
* ask open; interruptNative() withdraws it. Not with textOnly.
|
|
126
|
+
*/
|
|
127
|
+
toolGate?: boolean;
|
|
116
128
|
/**
|
|
117
129
|
* Override for the process spawner. Defaults to Bun.spawn. Tests inject a
|
|
118
130
|
* fake subprocess that emulates the claude CLI's stream-json protocol.
|
|
@@ -252,6 +264,11 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
252
264
|
private _env?: ClaudeCodeSessionConfig['env'];
|
|
253
265
|
private _textOnly: boolean;
|
|
254
266
|
private _persistSession: boolean;
|
|
267
|
+
private _toolGate: boolean;
|
|
268
|
+
private _gates = new ToolGates();
|
|
269
|
+
/** Inbound control requests awaiting a gate decision, by request id. */
|
|
270
|
+
private _inbound = new Map<string, AbortController>();
|
|
271
|
+
private readonly _hookCallbackId = `agent-session-pre-tool-${crypto.randomUUID()}`;
|
|
255
272
|
private _limits?: LimitSnapshot;
|
|
256
273
|
private _controlSeq = 0;
|
|
257
274
|
private _control = new Map<
|
|
@@ -325,6 +342,9 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
325
342
|
this._env = config?.env;
|
|
326
343
|
this._textOnly = config?.textOnly ?? false;
|
|
327
344
|
this._persistSession = config?.persistSession ?? true;
|
|
345
|
+
this._toolGate = config?.toolGate ?? false;
|
|
346
|
+
if (this._toolGate && this._textOnly)
|
|
347
|
+
throw new Error('toolGate requires a session with tools; textOnly sessions have none');
|
|
328
348
|
this._startedAt = Date.now();
|
|
329
349
|
}
|
|
330
350
|
|
|
@@ -375,6 +395,12 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
375
395
|
};
|
|
376
396
|
}
|
|
377
397
|
|
|
398
|
+
/** Register a tool gate. A fork gets a snapshot of the gates registered at fork() time. */
|
|
399
|
+
onToolRequest(gate: ToolGate): () => void {
|
|
400
|
+
if (!this._toolGate) throw new Error('Tool gates need a session launched with toolGate: true');
|
|
401
|
+
return this._gates.add(gate);
|
|
402
|
+
}
|
|
403
|
+
|
|
378
404
|
onBeforeSend(hook: BeforeSendHook): () => void {
|
|
379
405
|
this._beforeSendHooks.push(hook);
|
|
380
406
|
return () => {
|
|
@@ -456,11 +482,30 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
456
482
|
this._rejectControl(new Error('Session ended'));
|
|
457
483
|
this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
|
|
458
484
|
});
|
|
485
|
+
|
|
486
|
+
// The PreToolUse callback is registered before any turn is written, so it covers the first call.
|
|
487
|
+
if (this._toolGate) {
|
|
488
|
+
try {
|
|
489
|
+
await this._controlRequest(
|
|
490
|
+
{
|
|
491
|
+
subtype: 'initialize',
|
|
492
|
+
hooks: { PreToolUse: [{ matcher: '.*', hookCallbackIds: [this._hookCallbackId] }] },
|
|
493
|
+
},
|
|
494
|
+
30_000,
|
|
495
|
+
);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
this.kill();
|
|
498
|
+
throw new Error(`Tool gate registration failed: ${(error as Error).message}`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
459
501
|
}
|
|
460
502
|
|
|
461
503
|
private _rejectControl(error: Error): void {
|
|
462
504
|
for (const pending of this._control.values()) pending.reject(error);
|
|
463
505
|
this._control.clear();
|
|
506
|
+
// Decisions still pending have nowhere to go: withdraw them.
|
|
507
|
+
for (const inbound of this._inbound.values()) inbound.abort();
|
|
508
|
+
this._inbound.clear();
|
|
464
509
|
}
|
|
465
510
|
|
|
466
511
|
// ---------------------------------------------------------------------------
|
|
@@ -530,7 +575,7 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
530
575
|
}
|
|
531
576
|
|
|
532
577
|
// ---------------------------------------------------------------------------
|
|
533
|
-
// fork() — branch from current conversation state
|
|
578
|
+
// fork() — branch from current conversation state (and a snapshot of the tool gates)
|
|
534
579
|
// ---------------------------------------------------------------------------
|
|
535
580
|
|
|
536
581
|
fork(opts?: {
|
|
@@ -558,7 +603,9 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
558
603
|
env: this._env,
|
|
559
604
|
textOnly: this._textOnly,
|
|
560
605
|
persistSession: opts?.persistSession ?? this._persistSession,
|
|
606
|
+
toolGate: this._toolGate,
|
|
561
607
|
});
|
|
608
|
+
for (const gate of this._gates.list()) forked._gates.add(gate);
|
|
562
609
|
forked._forking = true;
|
|
563
610
|
return forked;
|
|
564
611
|
}
|
|
@@ -930,9 +977,32 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
930
977
|
}
|
|
931
978
|
return;
|
|
932
979
|
}
|
|
980
|
+
if (raw.type === 'control_cancel_request') {
|
|
981
|
+
const id = typeof raw.request_id === 'string' ? raw.request_id : undefined;
|
|
982
|
+
const inbound = id ? this._inbound.get(id) : undefined;
|
|
983
|
+
if (inbound && id) {
|
|
984
|
+
this._inbound.delete(id);
|
|
985
|
+
inbound.abort();
|
|
986
|
+
}
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
933
989
|
if (raw.type === 'control_request') {
|
|
934
|
-
// No client-side handlers (can_use_tool, hooks) are registered on this transport.
|
|
935
990
|
const id = typeof raw.request_id === 'string' ? raw.request_id : undefined;
|
|
991
|
+
const request = raw.request as Record<string, unknown> | undefined;
|
|
992
|
+
const toolRequest =
|
|
993
|
+
id && this._toolGate && this._proc ? this._toolRequestFrom(request) : undefined;
|
|
994
|
+
if (id && toolRequest) {
|
|
995
|
+
this._answerToolRequest(this._proc!, id, toolRequest);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
// A gated ask or our own callback that cannot be read is denied: an error reply allows.
|
|
999
|
+
const unreadable = id && this._toolGate && this._proc ? this._unreadable(request) : undefined;
|
|
1000
|
+
if (id && unreadable) {
|
|
1001
|
+
this._writeControlResponse(this._proc!, id, unreadable);
|
|
1002
|
+
this._unattributed(raw, 'unrecognized-event');
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
// No other client-side handlers are registered on this transport.
|
|
936
1006
|
if (id && this._proc) {
|
|
937
1007
|
try {
|
|
938
1008
|
this._proc.stdin.write(
|
|
@@ -1094,6 +1164,129 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1094
1164
|
}
|
|
1095
1165
|
}
|
|
1096
1166
|
|
|
1167
|
+
// ---------------------------------------------------------------------------
|
|
1168
|
+
// Private — tool gate (control protocol: hook_callback, can_use_tool)
|
|
1169
|
+
// ---------------------------------------------------------------------------
|
|
1170
|
+
|
|
1171
|
+
private _toolRequestFrom(
|
|
1172
|
+
request: Record<string, unknown> | undefined,
|
|
1173
|
+
): Omit<ToolRequest, 'signal'> | undefined {
|
|
1174
|
+
const record = (value: unknown) =>
|
|
1175
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
1176
|
+
? (value as Record<string, unknown>)
|
|
1177
|
+
: undefined;
|
|
1178
|
+
const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
|
|
1179
|
+
let stage: ToolRequest['stage'];
|
|
1180
|
+
let source: Record<string, unknown> | undefined;
|
|
1181
|
+
let toolName: string | undefined;
|
|
1182
|
+
let toolInput: Record<string, unknown> | undefined;
|
|
1183
|
+
if (request && this._ownHookCallback(request)) {
|
|
1184
|
+
source = record(request.input);
|
|
1185
|
+
if (source?.hook_event_name !== 'PreToolUse') return undefined;
|
|
1186
|
+
stage = 'pre-use';
|
|
1187
|
+
toolName = text(source.tool_name);
|
|
1188
|
+
toolInput = record(source.tool_input);
|
|
1189
|
+
} else if (request?.subtype === 'can_use_tool') {
|
|
1190
|
+
source = request;
|
|
1191
|
+
stage = 'permission';
|
|
1192
|
+
toolName = text(request.tool_name);
|
|
1193
|
+
toolInput = record(request.input);
|
|
1194
|
+
} else return undefined;
|
|
1195
|
+
if (!toolName) return undefined;
|
|
1196
|
+
const callId = text(source?.tool_use_id) ?? text(request.tool_use_id);
|
|
1197
|
+
const agentId = text(source?.agent_id) ?? text(request.agent_id);
|
|
1198
|
+
const turn = this._inflight;
|
|
1199
|
+
return {
|
|
1200
|
+
stage,
|
|
1201
|
+
toolName,
|
|
1202
|
+
...mcpToolParts(toolName),
|
|
1203
|
+
toolInput: Object.freeze(structuredClone(toolInput ?? {})),
|
|
1204
|
+
...(callId ? { callId } : {}),
|
|
1205
|
+
...(turn?.evidence.dispatch === 'attempted' && turn.evidence.nativeOutcome === 'unknown'
|
|
1206
|
+
? { admissionId: turn.evidence.admissionId }
|
|
1207
|
+
: {}),
|
|
1208
|
+
...(text(request.description) ? { description: text(request.description) } : {}),
|
|
1209
|
+
...(text(request.decision_reason) ? { decisionReason: text(request.decision_reason) } : {}),
|
|
1210
|
+
...(text(request.blocked_path) ? { blockedPath: text(request.blocked_path) } : {}),
|
|
1211
|
+
...(agentId ? { agentId } : {}),
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
private _ownHookCallback(request: Record<string, unknown> | undefined): boolean {
|
|
1216
|
+
return request?.subtype === 'hook_callback' && request.callback_id === this._hookCallbackId;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** The deny for a gated request _toolRequestFrom could not read; undefined when not ours. */
|
|
1220
|
+
private _unreadable(
|
|
1221
|
+
request: Record<string, unknown> | undefined,
|
|
1222
|
+
): Record<string, unknown> | undefined {
|
|
1223
|
+
if (this._ownHookCallback(request))
|
|
1224
|
+
return this._toolResponse(
|
|
1225
|
+
{ stage: 'pre-use', toolName: '', toolInput: {} },
|
|
1226
|
+
{ behavior: 'deny', message: 'Tool gate: unreadable PreToolUse callback' },
|
|
1227
|
+
);
|
|
1228
|
+
if (request?.subtype === 'can_use_tool')
|
|
1229
|
+
return { behavior: 'deny', message: 'Tool gate: unreadable permission request' };
|
|
1230
|
+
return undefined;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
private _answerToolRequest(
|
|
1234
|
+
proc: PipedSubprocess,
|
|
1235
|
+
requestId: string,
|
|
1236
|
+
request: Omit<ToolRequest, 'signal'>,
|
|
1237
|
+
): void {
|
|
1238
|
+
const controller = new AbortController();
|
|
1239
|
+
this._inbound.set(requestId, controller);
|
|
1240
|
+
void this._gates
|
|
1241
|
+
.decide({ ...request, signal: controller.signal })
|
|
1242
|
+
.then((decision) => {
|
|
1243
|
+
// Withdrawn by the runtime, or the process is gone: nothing may be written for it.
|
|
1244
|
+
if (controller.signal.aborted || this._proc !== proc || !this._alive) return;
|
|
1245
|
+
this._inbound.delete(requestId);
|
|
1246
|
+
this._writeControlResponse(proc, requestId, this._toolResponse(request, decision));
|
|
1247
|
+
})
|
|
1248
|
+
.finally(() => {
|
|
1249
|
+
if (this._inbound.get(requestId) === controller) this._inbound.delete(requestId);
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
private _toolResponse(
|
|
1254
|
+
request: Omit<ToolRequest, 'signal'>,
|
|
1255
|
+
decision: ToolDecision | undefined,
|
|
1256
|
+
): Record<string, unknown> {
|
|
1257
|
+
if (request.stage === 'pre-use')
|
|
1258
|
+
return decision?.behavior === 'deny'
|
|
1259
|
+
? {
|
|
1260
|
+
hookSpecificOutput: {
|
|
1261
|
+
hookEventName: 'PreToolUse',
|
|
1262
|
+
permissionDecision: 'deny',
|
|
1263
|
+
permissionDecisionReason: decision.message,
|
|
1264
|
+
},
|
|
1265
|
+
}
|
|
1266
|
+
: {};
|
|
1267
|
+
return decision?.behavior === 'allow'
|
|
1268
|
+
? { behavior: 'allow', updatedInput: structuredClone(request.toolInput) }
|
|
1269
|
+
: { behavior: 'deny', message: decision?.message ?? 'Denied' };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
private _writeControlResponse(
|
|
1273
|
+
proc: PipedSubprocess,
|
|
1274
|
+
requestId: string,
|
|
1275
|
+
response: Record<string, unknown>,
|
|
1276
|
+
): void {
|
|
1277
|
+
try {
|
|
1278
|
+
proc.stdin.write(
|
|
1279
|
+
`${JSON.stringify({
|
|
1280
|
+
type: 'control_response',
|
|
1281
|
+
response: { subtype: 'success', request_id: requestId, response },
|
|
1282
|
+
})}\n`,
|
|
1283
|
+
);
|
|
1284
|
+
proc.stdin.flush();
|
|
1285
|
+
} catch {
|
|
1286
|
+
/* transport failure surfaces on the stream */
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1097
1290
|
// ---------------------------------------------------------------------------
|
|
1098
1291
|
// Private — spawn args (called once at start())
|
|
1099
1292
|
// ---------------------------------------------------------------------------
|
|
@@ -1115,6 +1308,7 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1115
1308
|
'--permission-mode',
|
|
1116
1309
|
this._permissionMode,
|
|
1117
1310
|
'--include-hook-events',
|
|
1311
|
+
...(this._toolGate ? ['--permission-prompt-tool', 'stdio'] : []),
|
|
1118
1312
|
];
|
|
1119
1313
|
|
|
1120
1314
|
// Resume for fork or crash recovery. _externalSessionId is set from
|
package/src/codex-session.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
2
|
import { codexLimitSnapshot, type LimitSnapshot, mergeLimits } from './limits';
|
|
3
3
|
import { retainEvidence } from './retained-evidence';
|
|
4
|
+
import { ToolGates } from './tool-gate';
|
|
4
5
|
import { TRANSPORTS } from './transport';
|
|
5
6
|
import { TurnState } from './turn-state';
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -40,6 +41,7 @@ import type {
|
|
|
40
41
|
SessionEvent,
|
|
41
42
|
SessionEventHandler,
|
|
42
43
|
SessionResult,
|
|
44
|
+
ToolGate,
|
|
43
45
|
} from './harness-session';
|
|
44
46
|
|
|
45
47
|
// Re-export types so importers can stay on one path (mirrors claude-code-session).
|
|
@@ -63,6 +65,40 @@ export interface PipedSubprocess {
|
|
|
63
65
|
kill(): void;
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
export type CodexApprovalPolicy = 'never' | 'on-request' | 'untrusted';
|
|
69
|
+
export type CodexSandbox = 'read-only' | 'workspace-write' | 'danger-full-access';
|
|
70
|
+
export type HostPermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The app-server approval policy and sandbox for a host permission mode (Claude's mode ids).
|
|
74
|
+
* `acceptEdits` maps like `default`: under workspace-write, edits inside the workspace are never
|
|
75
|
+
* asked about; only escalations (writes outside it, commands needing more access) reach the gates.
|
|
76
|
+
*/
|
|
77
|
+
export function codexPermissions(mode: HostPermissionMode): {
|
|
78
|
+
approvalPolicy: CodexApprovalPolicy;
|
|
79
|
+
sandbox: CodexSandbox;
|
|
80
|
+
} {
|
|
81
|
+
switch (mode) {
|
|
82
|
+
case 'default':
|
|
83
|
+
case 'acceptEdits':
|
|
84
|
+
return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
|
|
85
|
+
case 'plan':
|
|
86
|
+
return { approvalPolicy: 'never', sandbox: 'read-only' };
|
|
87
|
+
case 'bypassPermissions':
|
|
88
|
+
return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SANDBOX_TYPES: Record<CodexSandbox, string> = {
|
|
93
|
+
'read-only': 'readOnly',
|
|
94
|
+
'workspace-write': 'workspaceWrite',
|
|
95
|
+
'danger-full-access': 'dangerFullAccess',
|
|
96
|
+
};
|
|
97
|
+
const APPROVAL_METHODS = new Set([
|
|
98
|
+
'item/commandExecution/requestApproval',
|
|
99
|
+
'item/fileChange/requestApproval',
|
|
100
|
+
]);
|
|
101
|
+
|
|
66
102
|
export type CodexSpawn = (
|
|
67
103
|
cmd: string[],
|
|
68
104
|
opts: { cwd: string; env: Record<string, string | undefined> },
|
|
@@ -81,6 +117,17 @@ export interface CodexSessionConfig {
|
|
|
81
117
|
requireConfiguration?: boolean;
|
|
82
118
|
config?: Readonly<Record<string, unknown>>;
|
|
83
119
|
requiredMcpServer?: { readonly name: string; readonly tools: readonly string[] };
|
|
120
|
+
/**
|
|
121
|
+
* Native approval policy and sandbox for the thread. Default `never` / `danger-full-access`.
|
|
122
|
+
* With `on-request` or `untrusted`, approval requests go to the gates registered through
|
|
123
|
+
* onToolRequest() (stage `permission` only; Codex has no pre-use stage); with none registered
|
|
124
|
+
* they are declined. The thread is started with `approvalsReviewer: "user"` and refused if the
|
|
125
|
+
* runtime reports another reviewer (a config.toml `approvals_reviewer` would bypass the gates).
|
|
126
|
+
* Under `never` nothing reaches a gate and onToolRequest() throws.
|
|
127
|
+
* `codexPermissions(mode)` maps the host permission modes.
|
|
128
|
+
*/
|
|
129
|
+
approvalPolicy?: CodexApprovalPolicy;
|
|
130
|
+
sandbox?: CodexSandbox;
|
|
84
131
|
};
|
|
85
132
|
/** Path to codex CLI binary. Defaults to "codex". */
|
|
86
133
|
bin?: string;
|
|
@@ -304,6 +351,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
304
351
|
this._rejectQueue(new Error('Session killed'));
|
|
305
352
|
for (const p of this._pending.values()) p.reject(new Error('Session killed'));
|
|
306
353
|
this._pending.clear();
|
|
354
|
+
this._withdrawRequests();
|
|
307
355
|
|
|
308
356
|
try {
|
|
309
357
|
this._proc.stdin.end();
|
|
@@ -388,6 +436,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
388
436
|
this._rejectQueue(new Error('Session ended'));
|
|
389
437
|
for (const p of this._pending.values()) p.reject(new Error('Session ended'));
|
|
390
438
|
this._pending.clear();
|
|
439
|
+
this._withdrawRequests();
|
|
391
440
|
this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
|
|
392
441
|
});
|
|
393
442
|
|
|
@@ -595,6 +644,7 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
595
644
|
this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
|
|
596
645
|
for (const p of this._pending.values()) p.reject(err);
|
|
597
646
|
this._pending.clear();
|
|
647
|
+
this._withdrawRequests();
|
|
598
648
|
}
|
|
599
649
|
|
|
600
650
|
protected _rejectQueue(err: Error): void {
|
|
@@ -791,6 +841,9 @@ abstract class BaseCodexSession implements HarnessSession {
|
|
|
791
841
|
|
|
792
842
|
protected _observeRpcResult(_id: number, _result: unknown): void {}
|
|
793
843
|
|
|
844
|
+
/** The process is gone (kill, exit, transport failure): withdraw server requests still pending. */
|
|
845
|
+
protected _withdrawRequests(): void {}
|
|
846
|
+
|
|
794
847
|
/** Shared fork constructor — subclass passes its own ctor. */
|
|
795
848
|
fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession {
|
|
796
849
|
if (this._inflight) throw new Error('Cannot fork while native ownership is unresolved');
|
|
@@ -1237,6 +1290,11 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
1237
1290
|
private _nativeSessionId?: string;
|
|
1238
1291
|
private _appAdmission?: string;
|
|
1239
1292
|
private _turnDone?: () => void;
|
|
1293
|
+
private _gates = new ToolGates();
|
|
1294
|
+
/** Approval requests awaiting a gate decision, by JSON-RPC request id. */
|
|
1295
|
+
private _approvals = new Map<string, AbortController>();
|
|
1296
|
+
/** The owned turn's in-progress fileChange items' changes, by item id, for their approvals. */
|
|
1297
|
+
private _fileChanges = new Map<string, readonly CodexFileChange[]>();
|
|
1240
1298
|
private _items = new Map<
|
|
1241
1299
|
string,
|
|
1242
1300
|
{
|
|
@@ -1284,7 +1342,113 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
1284
1342
|
}
|
|
1285
1343
|
}
|
|
1286
1344
|
|
|
1345
|
+
/** Needs an `approvalPolicy` other than `never`. Gates registered here are not copied (no fork). */
|
|
1346
|
+
onToolRequest(gate: ToolGate): () => void {
|
|
1347
|
+
if (this._approvalPolicy === 'never')
|
|
1348
|
+
throw new Error(
|
|
1349
|
+
'Tool gates need an app-server session launched with approvalPolicy "on-request" or "untrusted"; under "never" no approval reaches them',
|
|
1350
|
+
);
|
|
1351
|
+
return this._gates.add(gate);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
protected override _withdrawRequests(): void {
|
|
1355
|
+
for (const approval of this._approvals.values()) approval.abort();
|
|
1356
|
+
this._approvals.clear();
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
private get _approvalPolicy(): CodexApprovalPolicy {
|
|
1360
|
+
return this._options.approvalPolicy ?? 'never';
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/** Approval requests are asked of this client, so they must reach the gates. */
|
|
1364
|
+
private get _gated(): boolean {
|
|
1365
|
+
return this._approvalPolicy !== 'never';
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
private get _sandbox(): CodexSandbox {
|
|
1369
|
+
return this._options.sandbox ?? 'danger-full-access';
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/** A command or file-change approval for the owned turn, decided by the gates. */
|
|
1373
|
+
private _approval(request: Record<string, unknown>): boolean {
|
|
1374
|
+
const method = request.method as string;
|
|
1375
|
+
const params = object(request.params);
|
|
1376
|
+
const a = this._inflight?.evidence;
|
|
1377
|
+
if (
|
|
1378
|
+
!APPROVAL_METHODS.has(method) ||
|
|
1379
|
+
!a?.identity.turnId ||
|
|
1380
|
+
params?.threadId !== a.identity.threadId ||
|
|
1381
|
+
params?.turnId !== a.identity.turnId ||
|
|
1382
|
+
(typeof request.id !== 'string' && typeof request.id !== 'number')
|
|
1383
|
+
)
|
|
1384
|
+
return false;
|
|
1385
|
+
const str = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
|
|
1386
|
+
const command = method === 'item/commandExecution/requestApproval';
|
|
1387
|
+
const network = object(params.networkApprovalContext);
|
|
1388
|
+
const host = str(network?.host),
|
|
1389
|
+
protocol = str(network?.protocol);
|
|
1390
|
+
const changes = str(params.itemId) ? this._fileChanges.get(params.itemId as string) : undefined;
|
|
1391
|
+
const toolInput: Record<string, unknown> = command
|
|
1392
|
+
? {
|
|
1393
|
+
...(str(params.command) ? { command: params.command } : {}),
|
|
1394
|
+
...(str(params.cwd) ? { cwd: params.cwd } : {}),
|
|
1395
|
+
...(str(params.kind) ? { kind: params.kind } : {}),
|
|
1396
|
+
...(host ? { networkApprovalContext: { host, ...(protocol ? { protocol } : {}) } } : {}),
|
|
1397
|
+
}
|
|
1398
|
+
: {
|
|
1399
|
+
...(str(params.grantRoot) ? { grantRoot: params.grantRoot } : {}),
|
|
1400
|
+
...(changes ? { changes: structuredClone(changes) } : {}),
|
|
1401
|
+
};
|
|
1402
|
+
const proc = this._proc;
|
|
1403
|
+
const id = request.id;
|
|
1404
|
+
const key = JSON.stringify(id);
|
|
1405
|
+
const controller = new AbortController();
|
|
1406
|
+
this._approvals.get(key)?.abort();
|
|
1407
|
+
this._approvals.set(key, controller);
|
|
1408
|
+
const identity = { ...a.identity, admissionId: a.admissionId };
|
|
1409
|
+
this._emit({
|
|
1410
|
+
...identity,
|
|
1411
|
+
kind: 'native_status',
|
|
1412
|
+
timestamp: Date.now(),
|
|
1413
|
+
raw: { type: 'approval-request', method },
|
|
1414
|
+
});
|
|
1415
|
+
void this._gates
|
|
1416
|
+
.decide({
|
|
1417
|
+
stage: 'permission',
|
|
1418
|
+
toolName: command ? 'shell' : 'apply_patch',
|
|
1419
|
+
toolInput: Object.freeze(toolInput),
|
|
1420
|
+
...(str(params.itemId) ? { callId: params.itemId as string } : {}),
|
|
1421
|
+
admissionId: a.admissionId,
|
|
1422
|
+
...(str(params.reason) ? { description: params.reason as string } : {}),
|
|
1423
|
+
signal: controller.signal,
|
|
1424
|
+
})
|
|
1425
|
+
.then((decision) => {
|
|
1426
|
+
if (controller.signal.aborted || this._proc !== proc || !this._alive || !proc) return;
|
|
1427
|
+
this._approvals.delete(key);
|
|
1428
|
+
const accepted = decision?.behavior === 'allow';
|
|
1429
|
+
try {
|
|
1430
|
+
proc.stdin.write(
|
|
1431
|
+
`${JSON.stringify({ jsonrpc: '2.0', id, result: { decision: accepted ? 'accept' : 'decline' } })}\n`,
|
|
1432
|
+
);
|
|
1433
|
+
proc.stdin.flush();
|
|
1434
|
+
} catch {
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
this._emit({
|
|
1438
|
+
...identity,
|
|
1439
|
+
kind: 'native_status',
|
|
1440
|
+
timestamp: Date.now(),
|
|
1441
|
+
raw: { type: 'approval-response', method, decision: accepted ? 'accept' : 'decline' },
|
|
1442
|
+
});
|
|
1443
|
+
})
|
|
1444
|
+
.finally(() => {
|
|
1445
|
+
if (this._approvals.get(key) === controller) this._approvals.delete(key);
|
|
1446
|
+
});
|
|
1447
|
+
return true;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1287
1450
|
protected override _serverRequest(request: Record<string, unknown>): boolean {
|
|
1451
|
+
if (this._approval(request)) return true;
|
|
1288
1452
|
// JSON-RPC's explicit method-not-supported refusal also covers unknown future
|
|
1289
1453
|
// requests. Never approve native work or echo private request parameters.
|
|
1290
1454
|
const payload = `${JSON.stringify({
|
|
@@ -1435,8 +1599,10 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
1435
1599
|
...(binding ? { threadId: binding } : {}),
|
|
1436
1600
|
model: this._model,
|
|
1437
1601
|
cwd: this._cwd,
|
|
1438
|
-
approvalPolicy:
|
|
1439
|
-
|
|
1602
|
+
approvalPolicy: this._approvalPolicy,
|
|
1603
|
+
// Approvals go to the gates, never to a config-selected reviewer (auto_review, guardian).
|
|
1604
|
+
...(this._gated ? { approvalsReviewer: 'user' } : {}),
|
|
1605
|
+
sandbox: this._sandbox,
|
|
1440
1606
|
...(this._options.config ? { config: this._options.config } : {}),
|
|
1441
1607
|
}),
|
|
1442
1608
|
);
|
|
@@ -1458,6 +1624,16 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
1458
1624
|
? String(object(thread?.status)?.type)
|
|
1459
1625
|
: 'unknown',
|
|
1460
1626
|
);
|
|
1627
|
+
if (
|
|
1628
|
+
this._gated &&
|
|
1629
|
+
(response?.approvalsReviewer !== 'user' || response.approvalPolicy !== this._approvalPolicy)
|
|
1630
|
+
)
|
|
1631
|
+
this._refuse(
|
|
1632
|
+
'approvals-not-routed-to-user',
|
|
1633
|
+
['user', 'auto_review', 'guardian_subagent'].includes(String(response?.approvalsReviewer))
|
|
1634
|
+
? String(response?.approvalsReviewer)
|
|
1635
|
+
: 'unknown',
|
|
1636
|
+
);
|
|
1461
1637
|
if (
|
|
1462
1638
|
this._options.requireConfiguration &&
|
|
1463
1639
|
(typeof response?.model !== 'string' ||
|
|
@@ -1465,9 +1641,9 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
1465
1641
|
typeof response.modelProvider !== 'string' ||
|
|
1466
1642
|
!response.modelProvider ||
|
|
1467
1643
|
response.cwd !== this._cwd ||
|
|
1468
|
-
response.approvalPolicy !==
|
|
1644
|
+
response.approvalPolicy !== this._approvalPolicy ||
|
|
1469
1645
|
response.approvalsReviewer !== 'user' ||
|
|
1470
|
-
object(response.sandbox)?.type !==
|
|
1646
|
+
object(response.sandbox)?.type !== SANDBOX_TYPES[this._sandbox] ||
|
|
1471
1647
|
!Array.isArray(thread?.turns) ||
|
|
1472
1648
|
thread.turns.some((value) => !this._turn(value)))
|
|
1473
1649
|
)
|
|
@@ -1587,6 +1763,13 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
1587
1763
|
const snapshot = codexLimitSnapshot(msg.params, 'stream');
|
|
1588
1764
|
return snapshot ? [this._observeLimits(snapshot)] : [];
|
|
1589
1765
|
}
|
|
1766
|
+
if (method === 'serverRequest/resolved') {
|
|
1767
|
+
// The runtime settled or withdrew an approval itself: a later gate decision is discarded.
|
|
1768
|
+
const requestId = object(msg.params)?.requestId;
|
|
1769
|
+
const key = JSON.stringify(requestId);
|
|
1770
|
+
this._approvals.get(key)?.abort();
|
|
1771
|
+
this._approvals.delete(key);
|
|
1772
|
+
}
|
|
1590
1773
|
const params = object(msg.params),
|
|
1591
1774
|
turn = this._turn(params?.turn),
|
|
1592
1775
|
item = object(params?.item);
|
|
@@ -1645,6 +1828,7 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
1645
1828
|
if (turn.error != null && typeof error?.message !== 'string')
|
|
1646
1829
|
return unowned('unrecognized-event');
|
|
1647
1830
|
this._seenTerminals.add(turnId);
|
|
1831
|
+
this._fileChanges.clear();
|
|
1648
1832
|
this._turnDone?.();
|
|
1649
1833
|
this._turnDone = undefined;
|
|
1650
1834
|
return [
|
|
@@ -1703,8 +1887,19 @@ export class CodexAppServerSession extends BaseCodexSession {
|
|
|
1703
1887
|
},
|
|
1704
1888
|
];
|
|
1705
1889
|
}
|
|
1890
|
+
if (method === 'item/fileChange/patchUpdated') {
|
|
1891
|
+
// Changes only feed the item's approval request; the event itself stays a diagnostic.
|
|
1892
|
+
if (itemId && this._fileChanges.has(itemId))
|
|
1893
|
+
this._fileChanges.set(itemId, fileChanges(params?.changes));
|
|
1894
|
+
return unowned('unrecognized-event');
|
|
1895
|
+
}
|
|
1706
1896
|
if (method === 'item/completed' || method === 'item/started') {
|
|
1707
1897
|
if (!itemId || !item || typeof item.type !== 'string') return unowned('unrecognized-event');
|
|
1898
|
+
if (item.type === 'fileChange') {
|
|
1899
|
+
if (method === 'item/started') this._fileChanges.set(itemId, fileChanges(item.changes));
|
|
1900
|
+
else this._fileChanges.delete(itemId);
|
|
1901
|
+
return unowned('unrecognized-event');
|
|
1902
|
+
}
|
|
1708
1903
|
if (item.type === 'agentMessage') {
|
|
1709
1904
|
if (method !== 'item/completed') return status();
|
|
1710
1905
|
if (this._items.get(itemId)?.completed) return unowned('after-terminal');
|
|
@@ -1844,6 +2039,49 @@ const object = (value: unknown): Record<string, unknown> | undefined =>
|
|
|
1844
2039
|
? (value as Record<string, unknown>)
|
|
1845
2040
|
: undefined;
|
|
1846
2041
|
|
|
2042
|
+
/** A fileChange item's change as a file-change gate sees it (`toolInput.changes`). */
|
|
2043
|
+
export interface CodexFileChange {
|
|
2044
|
+
readonly path: string;
|
|
2045
|
+
readonly kind: 'add' | 'delete' | 'update';
|
|
2046
|
+
/** Rename target of an `update`. */
|
|
2047
|
+
readonly movePath?: string;
|
|
2048
|
+
/** Unified diff, present while it fits the bound; otherwise `diffOmitted`. */
|
|
2049
|
+
readonly diff?: string;
|
|
2050
|
+
readonly diffOmitted?: true;
|
|
2051
|
+
}
|
|
2052
|
+
const FILE_CHANGE_DIFF_LIMIT = 16 * 1024;
|
|
2053
|
+
const FILE_CHANGE_DIFF_TOTAL = 64 * 1024;
|
|
2054
|
+
|
|
2055
|
+
/** Native FileUpdateChange[] (codex 0.155.1) → CodexFileChange[]; diffs only within the bound. */
|
|
2056
|
+
function fileChanges(value: unknown): readonly CodexFileChange[] {
|
|
2057
|
+
if (!Array.isArray(value)) return [];
|
|
2058
|
+
let budget = FILE_CHANGE_DIFF_TOTAL;
|
|
2059
|
+
const out: CodexFileChange[] = [];
|
|
2060
|
+
for (const entry of value) {
|
|
2061
|
+
const change = object(entry);
|
|
2062
|
+
const kind = object(change?.kind);
|
|
2063
|
+
if (
|
|
2064
|
+
typeof change?.path !== 'string' ||
|
|
2065
|
+
!change.path ||
|
|
2066
|
+
(kind?.type !== 'add' && kind?.type !== 'delete' && kind?.type !== 'update')
|
|
2067
|
+
)
|
|
2068
|
+
continue;
|
|
2069
|
+
const diff = typeof change.diff === 'string' ? change.diff : undefined;
|
|
2070
|
+
const fits =
|
|
2071
|
+
diff !== undefined && diff.length <= FILE_CHANGE_DIFF_LIMIT && diff.length <= budget;
|
|
2072
|
+
if (fits) budget -= diff.length;
|
|
2073
|
+
out.push({
|
|
2074
|
+
path: change.path,
|
|
2075
|
+
kind: kind.type,
|
|
2076
|
+
...(kind.type === 'update' && typeof kind.move_path === 'string' && kind.move_path
|
|
2077
|
+
? { movePath: kind.move_path }
|
|
2078
|
+
: {}),
|
|
2079
|
+
...(fits ? { diff } : diff ? { diffOmitted: true as const } : {}),
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
return out;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
1847
2085
|
function publicMcpArguments(value: unknown): Pick<SessionEvent, 'toolInput' | 'toolInputOmitted'> {
|
|
1848
2086
|
if (!object(value)) return { toolInputOmitted: 'Native arguments absent or not an object' };
|
|
1849
2087
|
let omitted = false;
|
package/src/harness-session.ts
CHANGED
|
@@ -232,6 +232,47 @@ export interface SessionSendOptions {
|
|
|
232
232
|
onAdmission?: (attempt: SessionAttempt) => void | Promise<void>;
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Tool gate — per-call decisions from the runtime's native approval channel
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
export interface ToolRequest {
|
|
240
|
+
/**
|
|
241
|
+
* `pre-use` (Claude only; Codex has no pre-use stage): before every tool call, in every
|
|
242
|
+
* permission mode (bypass included). Only a deny counts; an allow leaves the runtime's own mode
|
|
243
|
+
* to decide. `permission`: the runtime's own mode would ask the user; the decision is final.
|
|
244
|
+
*/
|
|
245
|
+
readonly stage: 'pre-use' | 'permission';
|
|
246
|
+
readonly toolName: string;
|
|
247
|
+
/** `mcp__<server>__<tool>` split, as tool_use events carry it. */
|
|
248
|
+
readonly toolServer?: string;
|
|
249
|
+
readonly toolMethod?: string;
|
|
250
|
+
readonly toolInput: Readonly<Record<string, unknown>>;
|
|
251
|
+
/** Native tool_use id (Claude) or item id (Codex). */
|
|
252
|
+
readonly callId?: string;
|
|
253
|
+
/** The in-flight admission, when one is attributable. */
|
|
254
|
+
readonly admissionId?: string;
|
|
255
|
+
/** The runtime's own description of, or reason for, the call. */
|
|
256
|
+
readonly description?: string;
|
|
257
|
+
/** Claude `can_use_tool` decision_reason: why the runtime's mode escalated to an ask. */
|
|
258
|
+
readonly decisionReason?: string;
|
|
259
|
+
/** Claude `can_use_tool` blocked_path. */
|
|
260
|
+
readonly blockedPath?: string;
|
|
261
|
+
/** Claude subagent id (`agent_id`) when a subagent, not the main thread, makes the call. */
|
|
262
|
+
readonly agentId?: string;
|
|
263
|
+
/**
|
|
264
|
+
* Aborted when the runtime withdraws the request or the session ends; a later decision is
|
|
265
|
+
* discarded. A turn timeout does not withdraw a pending Claude ask; interruptNative() does
|
|
266
|
+
* (the CLI sends control_cancel_request).
|
|
267
|
+
*/
|
|
268
|
+
readonly signal?: AbortSignal;
|
|
269
|
+
}
|
|
270
|
+
export type ToolDecision =
|
|
271
|
+
| { readonly behavior: 'allow' }
|
|
272
|
+
| { readonly behavior: 'deny'; readonly message: string };
|
|
273
|
+
/** Gates compose in registration order: the first deny wins; a thrown or rejected gate denies. */
|
|
274
|
+
export type ToolGate = (request: ToolRequest) => ToolDecision | Promise<ToolDecision>;
|
|
275
|
+
|
|
235
276
|
// ---------------------------------------------------------------------------
|
|
236
277
|
// HarnessSession interface
|
|
237
278
|
// ---------------------------------------------------------------------------
|
|
@@ -277,7 +318,8 @@ export interface HarnessSession {
|
|
|
277
318
|
|
|
278
319
|
/**
|
|
279
320
|
* Fork: create a new (unstarted) session branching from current state.
|
|
280
|
-
* Caller must call start() on the forked session.
|
|
321
|
+
* Caller must call start() on the forked session. A fork starts with a snapshot of the
|
|
322
|
+
* parent's tool gates; gates registered or unregistered afterwards on either side stay local.
|
|
281
323
|
*/
|
|
282
324
|
fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession;
|
|
283
325
|
|
|
@@ -297,6 +339,14 @@ export interface HarnessSession {
|
|
|
297
339
|
*/
|
|
298
340
|
onBeforeSend(hook: BeforeSendHook): () => void;
|
|
299
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Register a tool gate. Present where the transport declares `approvals: "callback"`; the
|
|
344
|
+
* session must be launched for callbacks (Claude: `toolGate: true`; Codex app-server: an
|
|
345
|
+
* `approvalPolicy` other than `never`), otherwise this throws. Forks copy the gates registered
|
|
346
|
+
* at fork() time. Returns an unregister function.
|
|
347
|
+
*/
|
|
348
|
+
onToolRequest?(gate: ToolGate): () => void;
|
|
349
|
+
|
|
300
350
|
/**
|
|
301
351
|
* Mid-turn push: out-of-band signal injected into an in-flight turn.
|
|
302
352
|
* Used by guards and Herald to deliver urgent feedback. Best-effort —
|
package/src/index.ts
CHANGED
|
@@ -30,11 +30,16 @@ export {
|
|
|
30
30
|
// Codex CLI adapter (persistent JSON-RPC session). CodexSession defaults to the
|
|
31
31
|
// mcp-server variant; CodexAppServerSession is the app-server one.
|
|
32
32
|
export {
|
|
33
|
+
type CodexApprovalPolicy,
|
|
33
34
|
CodexAppServerSession,
|
|
35
|
+
type CodexFileChange,
|
|
34
36
|
CodexMcpSession,
|
|
37
|
+
type CodexSandbox,
|
|
35
38
|
CodexSession,
|
|
36
39
|
type CodexSessionConfig,
|
|
37
40
|
type CodexSpawn,
|
|
41
|
+
codexPermissions,
|
|
42
|
+
type HostPermissionMode,
|
|
38
43
|
} from './codex-session';
|
|
39
44
|
// The provider-agnostic interface + event taxonomy.
|
|
40
45
|
export * from './harness-session';
|
package/src/pool.ts
CHANGED
|
@@ -19,12 +19,14 @@ import type { HarnessSession } from './harness-session';
|
|
|
19
19
|
import { type LimitSnapshot, mergeLimits } from './limits';
|
|
20
20
|
import { probeClaudeLimits, probeCodexLimits } from './limits-probe';
|
|
21
21
|
import {
|
|
22
|
+
type BillingMode,
|
|
22
23
|
type ExclusionReason,
|
|
23
24
|
type OrderingSpec,
|
|
24
25
|
type RankedCandidate,
|
|
25
26
|
type ResolvedOrdering,
|
|
26
27
|
rankCandidates,
|
|
27
28
|
type SubscriptionCandidate,
|
|
29
|
+
type UnknownUtilizationPolicy,
|
|
28
30
|
} from './routing';
|
|
29
31
|
import { TRANSPORTS, type TransportKind } from './transport';
|
|
30
32
|
import { createSession } from './transports';
|
|
@@ -66,6 +68,9 @@ export interface PoolRequest {
|
|
|
66
68
|
readonly ordering?: OrderingSpec;
|
|
67
69
|
/** Only the preferred instance may serve. Requires preferredInstanceId. */
|
|
68
70
|
readonly pinned?: boolean;
|
|
71
|
+
/** Billing modes this request may use. Default: subscription only. */
|
|
72
|
+
readonly allowedBilling?: readonly BillingMode[];
|
|
73
|
+
readonly unknownUtilization?: UnknownUtilizationPolicy;
|
|
69
74
|
/** Restrict to these transports (e.g. only resumable ones). */
|
|
70
75
|
readonly transports?: readonly TransportKind[];
|
|
71
76
|
readonly excludeInstanceIds?: readonly string[];
|
|
@@ -228,6 +233,8 @@ export class SubscriptionPool {
|
|
|
228
233
|
const limit = config.concurrencyLimit ?? 1;
|
|
229
234
|
if (!Number.isSafeInteger(limit) || limit < 1)
|
|
230
235
|
throw Error(`Instance ${config.id}: concurrencyLimit must be a positive integer`);
|
|
236
|
+
if (config.priority !== undefined && !Number.isSafeInteger(config.priority))
|
|
237
|
+
throw Error(`Instance ${config.id}: priority must be a safe integer`);
|
|
231
238
|
this._instances.set(config.id, {
|
|
232
239
|
config,
|
|
233
240
|
runtime,
|
|
@@ -332,6 +339,10 @@ export class SubscriptionPool {
|
|
|
332
339
|
maximumObservationAgeMs: this._maxAge,
|
|
333
340
|
ordering: request.ordering,
|
|
334
341
|
...(request.pinned !== undefined ? { pinned: request.pinned } : {}),
|
|
342
|
+
...(request.allowedBilling !== undefined ? { allowedBilling: request.allowedBilling } : {}),
|
|
343
|
+
...(request.unknownUtilization !== undefined
|
|
344
|
+
? { unknownUtilization: request.unknownUtilization }
|
|
345
|
+
: {}),
|
|
335
346
|
organizationId: request.organizationId,
|
|
336
347
|
preferredId: request.preferredInstanceId,
|
|
337
348
|
},
|
package/src/routing.ts
CHANGED
|
@@ -74,9 +74,8 @@ const ROUTING_FIELDS = Object.keys(NATURAL_DIRECTION) as RoutingField[];
|
|
|
74
74
|
/** Resolve a strategy name or explicit term list into terms, each with a direction. */
|
|
75
75
|
export const resolveOrdering = (spec: OrderingSpec = DEFAULT_STRATEGY): ResolvedOrdering => {
|
|
76
76
|
if (typeof spec === 'string') {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
return resolveOrdering(preset);
|
|
77
|
+
if (!Object.hasOwn(ROUTING_STRATEGIES, spec)) throw Error(`Unknown routing strategy "${spec}"`);
|
|
78
|
+
return resolveOrdering(ROUTING_STRATEGIES[spec]);
|
|
80
79
|
}
|
|
81
80
|
if (!spec.length) throw Error('Ordering requires at least one term');
|
|
82
81
|
return spec.map((term) => {
|
|
@@ -228,7 +227,7 @@ export function repositoryIdentity(remote: string): string {
|
|
|
228
227
|
return `${url.hostname.toLowerCase()}/${normalized}`;
|
|
229
228
|
}
|
|
230
229
|
|
|
231
|
-
/** Why one candidate is ineligible, or
|
|
230
|
+
/** Why one candidate is ineligible, or its utilization when eligible. */
|
|
232
231
|
export function assessCandidate(
|
|
233
232
|
request: CandidateRequest,
|
|
234
233
|
account: SubscriptionCandidate,
|
|
@@ -257,13 +256,13 @@ export function assessCandidate(
|
|
|
257
256
|
return { excluded: 'unavailable' };
|
|
258
257
|
if (account.activeRuns >= account.concurrencyLimit) return { excluded: 'occupied' };
|
|
259
258
|
if (account.blocked) return { excluded: 'blocked' };
|
|
260
|
-
const
|
|
261
|
-
if (account.windows.length === 0)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
const fractions: number[] = [];
|
|
259
|
+
const rankLast = request.unknownUtilization === 'rank-last';
|
|
260
|
+
if (account.windows.length === 0)
|
|
261
|
+
return rankLast ? { utilizationPercent: null } : { excluded: 'no-observation' };
|
|
262
|
+
const readable: number[] = [];
|
|
263
|
+
let unreadable = false;
|
|
266
264
|
for (const window of account.windows) {
|
|
265
|
+
// Corrupt is not unknown: a structurally impossible reading is never routable under any policy.
|
|
267
266
|
if (
|
|
268
267
|
window.usedPercent === null ||
|
|
269
268
|
!Number.isFinite(window.usedPercent) ||
|
|
@@ -272,7 +271,7 @@ export function assessCandidate(
|
|
|
272
271
|
!Number.isFinite(window.reservedPercent) ||
|
|
273
272
|
window.reservedPercent < 0
|
|
274
273
|
)
|
|
275
|
-
return
|
|
274
|
+
return { excluded: 'invalid' };
|
|
276
275
|
// resetsAt === Infinity: the provider reported no reset for this window, so no reset can have crossed the observation.
|
|
277
276
|
if (
|
|
278
277
|
!Number.isFinite(window.observedAt) ||
|
|
@@ -280,14 +279,17 @@ export function assessCandidate(
|
|
|
280
279
|
request.now - window.observedAt > request.maximumObservationAgeMs ||
|
|
281
280
|
(window.resetsAt !== Number.POSITIVE_INFINITY &&
|
|
282
281
|
(!Number.isFinite(window.resetsAt) || window.resetsAt <= request.now))
|
|
283
|
-
)
|
|
284
|
-
return
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
282
|
+
) {
|
|
283
|
+
if (!rankLast) return { excluded: 'stale-observation' };
|
|
284
|
+
unreadable = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
readable.push(window.usedPercent + window.reservedPercent);
|
|
288
288
|
}
|
|
289
|
-
|
|
290
|
-
return {
|
|
289
|
+
// A window that is readable and full exhausts the instance even when another is not.
|
|
290
|
+
if (readable.some((p) => p >= 100)) return { excluded: 'exhausted' };
|
|
291
|
+
if (unreadable || !readable.length) return { utilizationPercent: null };
|
|
292
|
+
return { utilizationPercent: Math.max(...readable) };
|
|
291
293
|
}
|
|
292
294
|
|
|
293
295
|
/** Term fields as own properties, so json-rules orders them by name. */
|
|
@@ -318,6 +320,8 @@ export function rankCandidates(
|
|
|
318
320
|
throw Error('Invalid observation policy');
|
|
319
321
|
if (request.pinned && !request.preferredId)
|
|
320
322
|
throw Error('Pinned routing requires a preferred instance');
|
|
323
|
+
if (request.allowedBilling?.length === 0)
|
|
324
|
+
throw Error('Ordering requires at least one billing mode');
|
|
321
325
|
if (new Set(accounts.map((a) => a.id)).size !== accounts.length)
|
|
322
326
|
throw Error('Duplicate account identity');
|
|
323
327
|
const ordering = resolveOrdering(request.ordering);
|
package/src/tool-gate.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ToolDecision, ToolGate, ToolRequest } from './harness-session';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A session's registered tool gates. They compose in registration order and the first deny
|
|
5
|
+
* wins; a gate that throws or rejects denies. `pre-use` with no deny is no decision (the
|
|
6
|
+
* runtime's own mode decides); `permission` with no gate registered is denied.
|
|
7
|
+
*/
|
|
8
|
+
export class ToolGates {
|
|
9
|
+
private readonly _gates: ToolGate[] = [];
|
|
10
|
+
|
|
11
|
+
get size(): number {
|
|
12
|
+
return this._gates.length;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
add(gate: ToolGate): () => void {
|
|
16
|
+
this._gates.push(gate);
|
|
17
|
+
return () => {
|
|
18
|
+
const index = this._gates.indexOf(gate);
|
|
19
|
+
if (index !== -1) this._gates.splice(index, 1);
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The gates in order, for a sibling session (fork). */
|
|
24
|
+
list(): readonly ToolGate[] {
|
|
25
|
+
return [...this._gates];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Never rejects. Undefined means no decision: only a `pre-use` request without a deny. */
|
|
29
|
+
async decide(request: ToolRequest): Promise<ToolDecision | undefined> {
|
|
30
|
+
const gates = [...this._gates];
|
|
31
|
+
if (!gates.length)
|
|
32
|
+
return request.stage === 'permission'
|
|
33
|
+
? { behavior: 'deny', message: 'No approval handler' }
|
|
34
|
+
: undefined;
|
|
35
|
+
for (const gate of gates) {
|
|
36
|
+
let decision: ToolDecision;
|
|
37
|
+
try {
|
|
38
|
+
decision = await gate(request);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return {
|
|
41
|
+
behavior: 'deny',
|
|
42
|
+
message: `Tool gate failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (decision?.behavior === 'deny')
|
|
46
|
+
return { behavior: 'deny', message: String(decision.message ?? 'Denied') };
|
|
47
|
+
if (decision?.behavior !== 'allow')
|
|
48
|
+
return { behavior: 'deny', message: 'Tool gate failed: malformed decision' };
|
|
49
|
+
}
|
|
50
|
+
return request.stage === 'permission' ? { behavior: 'allow' } : undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `mcp__<server>__<tool>` split into its server and method. */
|
|
55
|
+
export function mcpToolParts(toolName: string): { toolServer?: string; toolMethod?: string } {
|
|
56
|
+
if (!toolName.startsWith('mcp__')) return {};
|
|
57
|
+
const rest = toolName.slice(5);
|
|
58
|
+
const split = rest.indexOf('__');
|
|
59
|
+
if (split <= 0 || split + 2 >= rest.length) return {};
|
|
60
|
+
return { toolServer: rest.slice(0, split), toolMethod: rest.slice(split + 2) };
|
|
61
|
+
}
|
package/src/transport.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface TransportCapabilities {
|
|
|
34
34
|
readonly interrupt: 'acknowledged' | 'local-only';
|
|
35
35
|
/** `steer`: push() adds input to the in-flight turn; `none`: push() records push_ignored. */
|
|
36
36
|
readonly push: 'steer' | 'none';
|
|
37
|
-
/** `callback`: per-call
|
|
37
|
+
/** `callback`: per-call decisions through onToolRequest(); `mode`: a fixed launch policy (bypass); `refuse`: runtime requests are refused. */
|
|
38
38
|
readonly approvals: 'callback' | 'mode' | 'refuse';
|
|
39
39
|
/** Token usage reported per turn. */
|
|
40
40
|
readonly usage: boolean;
|
|
@@ -72,7 +72,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
|
|
|
72
72
|
rollback: 'none',
|
|
73
73
|
interrupt: 'acknowledged',
|
|
74
74
|
push: 'none',
|
|
75
|
-
approvals: '
|
|
75
|
+
approvals: 'callback',
|
|
76
76
|
usage: true,
|
|
77
77
|
limits: { stream: true, poll: true },
|
|
78
78
|
textOnly: true,
|
|
@@ -84,7 +84,8 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
|
|
|
84
84
|
'textOnly launches with no tools, MCP servers, slash commands or user settings.',
|
|
85
85
|
'Primed decisions (ClaudePrimedSessions) fork a persisted primed session per cycle with a pre-spawned spare.',
|
|
86
86
|
"interruptNative() and readLimits() use the CLI's stdio control protocol (`interrupt`, `get_usage`).",
|
|
87
|
-
'
|
|
87
|
+
'toolGate: true launches with `--permission-prompt-tool stdio` and registers a PreToolUse callback over the control protocol: onToolRequest() gates see every call (`pre-use`, any mode, bypass included; only a deny counts) and the asks the permission mode raises (`permission`, final).',
|
|
88
|
+
'Rollback (`--resume-session-at`) exists natively but is not wired here.',
|
|
88
89
|
],
|
|
89
90
|
},
|
|
90
91
|
'codex-mcp': {
|
|
@@ -121,7 +122,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
|
|
|
121
122
|
rollback: 'none',
|
|
122
123
|
interrupt: 'acknowledged',
|
|
123
124
|
push: 'steer',
|
|
124
|
-
approvals: '
|
|
125
|
+
approvals: 'callback',
|
|
125
126
|
usage: true,
|
|
126
127
|
limits: { stream: true, poll: true },
|
|
127
128
|
textOnly: false,
|
|
@@ -132,6 +133,7 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
|
|
|
132
133
|
'`codex app-server --listen stdio://` with thread/start, thread/resume, turn/start.',
|
|
133
134
|
"CodexAppServerSession runs with the profile's tools; tool-free text sessions are CodexPrimedSessions (primed: fork).",
|
|
134
135
|
'interruptNative() sends turn/interrupt; push() sends turn/steer against the owned turn.',
|
|
136
|
+
'appServer.approvalPolicy/sandbox (default never / danger-full-access; `codexPermissions(mode)`) set the thread policy; command and file-change approvals for the owned turn go to onToolRequest() gates (`permission`) and are answered accept/decline. Other server requests are refused (-32601).',
|
|
135
137
|
'Primed decisions (CodexPrimedSessions) host every key as a thread of one process per account and fork the primed thread per cycle.',
|
|
136
138
|
'thread/rollback is refused by codex-cli 0.155 for both ephemeral and paginated threads; session fork() is not wired.',
|
|
137
139
|
],
|