@ai-sdk/harness-codex 0.0.0-6b196531-20260710185421
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 +391 -0
- package/LICENSE +13 -0
- package/README.md +73 -0
- package/dist/bridge/host-tool-mcp.mjs +103 -0
- package/dist/bridge/host-tool-mcp.mjs.map +1 -0
- package/dist/bridge/index.mjs +1297 -0
- package/dist/bridge/index.mjs.map +1 -0
- package/dist/bridge/package.json +12 -0
- package/dist/bridge/pnpm-lock.yaml +879 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +899 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
- package/src/bridge/cli-relay.ts +198 -0
- package/src/bridge/codex-step-tracker.ts +83 -0
- package/src/bridge/host-tool-mcp.ts +160 -0
- package/src/bridge/index.ts +746 -0
- package/src/bridge/package.json +12 -0
- package/src/bridge/pnpm-lock.yaml +879 -0
- package/src/bridge/tool-relay-auth.ts +195 -0
- package/src/codex-auth.ts +119 -0
- package/src/codex-bridge-protocol.ts +38 -0
- package/src/codex-harness.ts +1132 -0
- package/src/index.ts +13 -0
- package/src/version.ts +6 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
// Long-running process that runs alongside the `codex` CLI in the sandbox.
|
|
2
|
+
// The generic transport — WebSocket server, token auth, single-flight
|
|
3
|
+
// reconnect, the in-memory event log + `seq`, resume replay, and the
|
|
4
|
+
// lifecycle/meta files — lives in the shared `@ai-sdk/harness/bridge` runtime.
|
|
5
|
+
// This file supplies only the Codex-specific turn driver.
|
|
6
|
+
//
|
|
7
|
+
// Host-defined tools are routed through an HTTP relay bound to
|
|
8
|
+
// `127.0.0.1:0`. The codex CLI spawns `host-tool-mcp.mjs` (shipped alongside
|
|
9
|
+
// this file) as a stdio MCP server; the shim POSTs each tool call to the
|
|
10
|
+
// relay, which emits `tool-call` to the host and waits for the matching
|
|
11
|
+
// `tool-result`.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
runBridge,
|
|
15
|
+
type BridgeEvent,
|
|
16
|
+
type BridgeTurn,
|
|
17
|
+
} from '@ai-sdk/harness/bridge';
|
|
18
|
+
import type { HarnessV1BuiltinToolName } from '@ai-sdk/harness';
|
|
19
|
+
import type { StartMessage } from '../codex-bridge-protocol';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { createServer, type Server } from 'node:http';
|
|
23
|
+
// Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
|
|
24
|
+
import {
|
|
25
|
+
CLI_SHIM_FILENAME,
|
|
26
|
+
buildCliShimScript,
|
|
27
|
+
parseToolRelayCommand,
|
|
28
|
+
} from './cli-relay';
|
|
29
|
+
import {
|
|
30
|
+
createCodexStepTracker,
|
|
31
|
+
defaultUsage,
|
|
32
|
+
type CodexStepTracker,
|
|
33
|
+
} from './codex-step-tracker';
|
|
34
|
+
import {
|
|
35
|
+
ToolRelayAuthorizer,
|
|
36
|
+
ToolRelayPendingCalls,
|
|
37
|
+
isToolRelayRequestFromAllowedProcess,
|
|
38
|
+
type ToolRelayCall,
|
|
39
|
+
} from './tool-relay-auth';
|
|
40
|
+
import { argv, env as procEnv, stdout } from 'node:process';
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
* CONSTRAINT — the third-party imports below are NEVER bundled into the
|
|
44
|
+
* compiled `bridge/index.mjs`. They are declared `external` in
|
|
45
|
+
* tsup.config.ts and resolved at runtime from the node_modules that this
|
|
46
|
+
* bridge installs *inside the sandbox* from `src/bridge/package.json` (and
|
|
47
|
+
* its pinned `pnpm-lock.yaml`). That bridge package.json — NOT this host
|
|
48
|
+
* package — is the single source of truth for these packages and their
|
|
49
|
+
* versions; the published `@ai-sdk/harness-codex` package does not provide
|
|
50
|
+
* them at runtime.
|
|
51
|
+
*
|
|
52
|
+
* When adding or changing a third-party import here you MUST keep all three
|
|
53
|
+
* in sync, or the bridge will either get the dependency bundled in or fail
|
|
54
|
+
* to resolve it in the sandbox:
|
|
55
|
+
* 1. the import statement below,
|
|
56
|
+
* 2. the `external` array in tsup.config.ts, and
|
|
57
|
+
* 3. the dependency entry in `src/bridge/package.json`.
|
|
58
|
+
*/
|
|
59
|
+
import * as codexSdkModule from '@openai/codex-sdk';
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* Native Codex tool name → cross-harness common name. Tools outside this map
|
|
63
|
+
* (e.g. MCP tools the model invokes by name) have no common equivalent; their
|
|
64
|
+
* native name is forwarded as-is on `tool-call` events.
|
|
65
|
+
*/
|
|
66
|
+
const NATIVE_TO_COMMON: Readonly<Record<string, HarnessV1BuiltinToolName>> = {
|
|
67
|
+
shell: 'bash',
|
|
68
|
+
web_search: 'webSearch',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function toCommonName(nativeName: string): HarnessV1BuiltinToolName | string {
|
|
72
|
+
return NATIVE_TO_COMMON[nativeName] ?? nativeName;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const args = parseArgs(argv.slice(2));
|
|
76
|
+
const workdir = requireArg({ value: args.workdir, name: '--workdir' });
|
|
77
|
+
const bridgeStateDir = requireArg({
|
|
78
|
+
value: args.bridgeStateDir,
|
|
79
|
+
name: '--bridge-state-dir',
|
|
80
|
+
});
|
|
81
|
+
const cliShimDir = requireArg({
|
|
82
|
+
value: args.cliShimDir,
|
|
83
|
+
name: '--cli-shim-dir',
|
|
84
|
+
});
|
|
85
|
+
const bootstrapDir = args.bootstrapDir ?? workdir;
|
|
86
|
+
const HARNESS_CLIENT_APP = procEnv.AI_SDK_HARNESS_CLIENT_APP;
|
|
87
|
+
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
89
|
+
const codexSdk = codexSdkModule as any;
|
|
90
|
+
|
|
91
|
+
// Codex thread id — survives across turns within this bridge process and is
|
|
92
|
+
// returned to the host on `detach` so a future process can resume the thread.
|
|
93
|
+
const threadState: { id: string | undefined } = { id: undefined };
|
|
94
|
+
|
|
95
|
+
await runBridge<StartMessage>({
|
|
96
|
+
bridgeType: 'codex',
|
|
97
|
+
bridgeStateDir,
|
|
98
|
+
onStart: runTurn,
|
|
99
|
+
onDetach: () => (threadState.id ? { threadId: threadState.id } : {}),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
type Emit = (msg: Record<string, unknown>) => void;
|
|
103
|
+
type ToolRelay = {
|
|
104
|
+
port: number;
|
|
105
|
+
close(): void;
|
|
106
|
+
authorizeToolCall(call: ToolRelayCall): void;
|
|
107
|
+
authorizeAnyToolCall(): void;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
|
|
111
|
+
const emit: Emit = msg => turn.emit(msg as BridgeEvent);
|
|
112
|
+
|
|
113
|
+
// Cross-process resume: the host carries the threadId we returned on detach.
|
|
114
|
+
// Seed `threadState.id` so the codex SDK call below takes the `resumeThread`
|
|
115
|
+
// branch.
|
|
116
|
+
if (
|
|
117
|
+
typeof start.resumeThreadId === 'string' &&
|
|
118
|
+
start.resumeThreadId.length > 0
|
|
119
|
+
) {
|
|
120
|
+
threadState.id = start.resumeThreadId;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/*
|
|
124
|
+
* Known limitation: codex CLI does not currently surface MCP tools to the
|
|
125
|
+
* model in `codex exec --experimental-json` mode (the path the
|
|
126
|
+
* `@openai/codex-sdk` uses). The MCP handshake completes and `tools/list`
|
|
127
|
+
* returns the host tool, but codex never registers it as a model-callable
|
|
128
|
+
* function. Built-in MCP-resource accessors (`list_mcp_resources` etc.) are
|
|
129
|
+
* exposed; tools are not. Tracked upstream at
|
|
130
|
+
* https://github.com/openai/codex/issues/19425.
|
|
131
|
+
*
|
|
132
|
+
* Until that's fixed, host tools are made available to the model via a
|
|
133
|
+
* separate CLI-relay workaround (see `./cli-relay.ts`). The MCP server
|
|
134
|
+
* config below is kept so that the day codex starts exposing MCP tools
|
|
135
|
+
* properly, host tools work both ways. Writing the shim here, adding matching
|
|
136
|
+
* prompt guidance in the host adapter, and filtering the shim command below
|
|
137
|
+
* implement the workaround and can be removed once the upstream bug is fixed.
|
|
138
|
+
*/
|
|
139
|
+
const mcpServers: Record<string, unknown> = {};
|
|
140
|
+
let relay: ToolRelay | undefined;
|
|
141
|
+
let cliShimPath: string | undefined;
|
|
142
|
+
if (start.tools && start.tools.length > 0) {
|
|
143
|
+
cliShimPath = `${cliShimDir}/${CLI_SHIM_FILENAME}`;
|
|
144
|
+
relay = await startToolRelay({
|
|
145
|
+
allowedScriptPaths: [cliShimPath, `${bootstrapDir}/host-tool-mcp.mjs`],
|
|
146
|
+
tools: start.tools,
|
|
147
|
+
emit,
|
|
148
|
+
requestToolResult: turn.requestToolResult,
|
|
149
|
+
});
|
|
150
|
+
mcpServers['harness-tools'] = {
|
|
151
|
+
enabled: true,
|
|
152
|
+
command: 'node',
|
|
153
|
+
args: [`${bootstrapDir}/host-tool-mcp.mjs`],
|
|
154
|
+
env: {
|
|
155
|
+
TOOL_SCHEMAS: JSON.stringify(
|
|
156
|
+
start.tools.map(t => ({
|
|
157
|
+
name: t.name,
|
|
158
|
+
description: t.description,
|
|
159
|
+
inputSchema: t.inputSchema,
|
|
160
|
+
})),
|
|
161
|
+
),
|
|
162
|
+
TOOL_RELAY_URL: `http://127.0.0.1:${relay.port}`,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
// Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
|
|
166
|
+
await mkdir(cliShimDir, { recursive: true });
|
|
167
|
+
await writeFile(
|
|
168
|
+
cliShimPath,
|
|
169
|
+
buildCliShimScript({ relayPort: relay.port }),
|
|
170
|
+
'utf8',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const codexConfig: Record<string, unknown> = {};
|
|
175
|
+
if (Object.keys(mcpServers).length > 0) codexConfig.mcp_servers = mcpServers;
|
|
176
|
+
|
|
177
|
+
const gatewayBaseUrl = procEnv.AI_GATEWAY_BASE_URL;
|
|
178
|
+
const hasGatewayAuth = Boolean(procEnv.AI_GATEWAY_API_KEY || gatewayBaseUrl);
|
|
179
|
+
if (hasGatewayAuth && !gatewayBaseUrl) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
'AI Gateway auth was selected but AI_GATEWAY_BASE_URL is missing from the Codex bridge environment.',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const apiBaseUrl = hasGatewayAuth ? gatewayBaseUrl : procEnv.OPENAI_BASE_URL;
|
|
185
|
+
if (apiBaseUrl) {
|
|
186
|
+
codexConfig.preferred_auth_method = 'apikey';
|
|
187
|
+
codexConfig.model_provider = 'agent_bridge_openai';
|
|
188
|
+
codexConfig.model_providers = {
|
|
189
|
+
agent_bridge_openai: {
|
|
190
|
+
name: procEnv.CODEX_MODEL_PROVIDER_NAME || 'Agent Bridge OpenAI',
|
|
191
|
+
base_url: apiBaseUrl,
|
|
192
|
+
env_key: 'CODEX_API_KEY',
|
|
193
|
+
wire_api: 'responses',
|
|
194
|
+
supports_websockets: false,
|
|
195
|
+
...(hasGatewayAuth && HARNESS_CLIENT_APP
|
|
196
|
+
? {
|
|
197
|
+
http_headers: {
|
|
198
|
+
'User-Agent': HARNESS_CLIENT_APP,
|
|
199
|
+
'x-client-app': HARNESS_CLIENT_APP,
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
: {}),
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const usesConfiguredModelProvider =
|
|
207
|
+
typeof codexConfig.model_provider === 'string';
|
|
208
|
+
|
|
209
|
+
const codex = new codexSdk.Codex({
|
|
210
|
+
...(procEnv.CODEX_API_KEY ? { apiKey: procEnv.CODEX_API_KEY } : {}),
|
|
211
|
+
...(!usesConfiguredModelProvider && apiBaseUrl
|
|
212
|
+
? { baseUrl: apiBaseUrl }
|
|
213
|
+
: {}),
|
|
214
|
+
env: Object.fromEntries(
|
|
215
|
+
Object.entries(procEnv).filter(
|
|
216
|
+
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
...(Object.keys(codexConfig).length > 0 ? { config: codexConfig } : {}),
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const threadOptions = {
|
|
223
|
+
...(start.model ? { model: start.model } : {}),
|
|
224
|
+
sandboxMode: 'danger-full-access',
|
|
225
|
+
approvalPolicy: 'never',
|
|
226
|
+
workingDirectory: workdir,
|
|
227
|
+
skipGitRepoCheck: true,
|
|
228
|
+
...(start.reasoningEffort
|
|
229
|
+
? { modelReasoningEffort: start.reasoningEffort }
|
|
230
|
+
: {}),
|
|
231
|
+
webSearchMode: start.webSearch ? 'live' : 'disabled',
|
|
232
|
+
};
|
|
233
|
+
const thread = threadState.id
|
|
234
|
+
? codex.resumeThread(threadState.id, threadOptions)
|
|
235
|
+
: codex.startThread(threadOptions);
|
|
236
|
+
|
|
237
|
+
emit({ type: 'stream-start' });
|
|
238
|
+
|
|
239
|
+
const userMessage = start.prompt;
|
|
240
|
+
let turnUsage: Record<string, unknown> | undefined;
|
|
241
|
+
const textByItem = new Map<string, string>();
|
|
242
|
+
const reasoningByItem = new Map<string, string>();
|
|
243
|
+
const stepTracker = createCodexStepTracker({ send: emit });
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const { events } = await thread.runStreamed(userMessage, {
|
|
247
|
+
signal: turn.abortSignal,
|
|
248
|
+
});
|
|
249
|
+
for await (const event of events as AsyncIterable<CodexEvent>) {
|
|
250
|
+
if (turn.abortSignal.aborted) break;
|
|
251
|
+
if (
|
|
252
|
+
event.type === 'thread.started' &&
|
|
253
|
+
typeof event.thread_id === 'string'
|
|
254
|
+
) {
|
|
255
|
+
threadState.id = event.thread_id;
|
|
256
|
+
// Announce to the host so it can include the id in resume state.
|
|
257
|
+
emit({ type: 'bridge-thread', threadId: event.thread_id });
|
|
258
|
+
}
|
|
259
|
+
// Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
|
|
260
|
+
if (cliShimPath && event.item?.type === 'command_execution') {
|
|
261
|
+
const relayCall =
|
|
262
|
+
typeof event.item.command === 'string'
|
|
263
|
+
? parseToolRelayCommand({
|
|
264
|
+
command: event.item.command,
|
|
265
|
+
cliShimPath,
|
|
266
|
+
})
|
|
267
|
+
: undefined;
|
|
268
|
+
if (event.type === 'item.started' && relay) {
|
|
269
|
+
if (relayCall) {
|
|
270
|
+
relay.authorizeToolCall(relayCall);
|
|
271
|
+
} else if (typeof event.item.command !== 'string') {
|
|
272
|
+
relay.authorizeAnyToolCall();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (relayCall) {
|
|
276
|
+
stepTracker.observeEvent({ event, itemId: event.item.id });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (relay && isHostMcpToolEvent(event)) {
|
|
281
|
+
stepTracker.observeEvent({ event, itemId: event.item?.id });
|
|
282
|
+
const relayCall = relayCallFromCodexMcpEvent(event);
|
|
283
|
+
if (relayCall) relay.authorizeToolCall(relayCall);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
translateAndEmit(event, {
|
|
287
|
+
send: emit,
|
|
288
|
+
textByItem,
|
|
289
|
+
reasoningByItem,
|
|
290
|
+
stepTracker,
|
|
291
|
+
setTurnUsage: u => (turnUsage = u),
|
|
292
|
+
emitWarning: turn.emitWarning,
|
|
293
|
+
emitError: turn.emitError,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
} catch (err) {
|
|
297
|
+
turn.emitError({ error: err, message: 'codex turn failed' });
|
|
298
|
+
return;
|
|
299
|
+
} finally {
|
|
300
|
+
relay?.close();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
emit({
|
|
304
|
+
type: 'finish',
|
|
305
|
+
finishReason: { unified: 'stop', raw: 'stop' },
|
|
306
|
+
totalUsage: turnUsage ?? defaultUsage(),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
void turn.pendingUserMessages; // accepted but only consumed when codex supports streamed user input
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
type CodexItem = {
|
|
313
|
+
type: string;
|
|
314
|
+
id?: string;
|
|
315
|
+
text?: string;
|
|
316
|
+
command?: string;
|
|
317
|
+
exit_code?: number;
|
|
318
|
+
aggregated_output?: string;
|
|
319
|
+
status?: 'in_progress' | 'completed' | 'failed';
|
|
320
|
+
server?: string;
|
|
321
|
+
tool?: string;
|
|
322
|
+
arguments?: unknown;
|
|
323
|
+
result?: { content?: unknown; structured_content?: unknown } | unknown;
|
|
324
|
+
error?: { message?: string };
|
|
325
|
+
query?: string;
|
|
326
|
+
message?: string;
|
|
327
|
+
changes?: ReadonlyArray<{
|
|
328
|
+
path: string;
|
|
329
|
+
kind: 'add' | 'delete' | 'update';
|
|
330
|
+
}>;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
function extractMcpToolCallResult(item: CodexItem): unknown {
|
|
334
|
+
if (
|
|
335
|
+
item.result === undefined ||
|
|
336
|
+
item.result === null ||
|
|
337
|
+
typeof item.result !== 'object'
|
|
338
|
+
) {
|
|
339
|
+
return item.error?.message ? { error: item.error.message } : null;
|
|
340
|
+
}
|
|
341
|
+
const result = item.result as {
|
|
342
|
+
content?: unknown;
|
|
343
|
+
structured_content?: unknown;
|
|
344
|
+
};
|
|
345
|
+
if (
|
|
346
|
+
result.structured_content !== undefined &&
|
|
347
|
+
result.structured_content !== null
|
|
348
|
+
) {
|
|
349
|
+
return result.structured_content;
|
|
350
|
+
}
|
|
351
|
+
return result.content ?? null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
type CodexEvent = {
|
|
355
|
+
type:
|
|
356
|
+
| 'thread.started'
|
|
357
|
+
| 'turn.completed'
|
|
358
|
+
| 'turn.failed'
|
|
359
|
+
| 'error'
|
|
360
|
+
| 'item.started'
|
|
361
|
+
| 'item.updated'
|
|
362
|
+
| 'item.completed';
|
|
363
|
+
item?: CodexItem;
|
|
364
|
+
usage?: Record<string, number>;
|
|
365
|
+
error?: { message: string };
|
|
366
|
+
message?: string;
|
|
367
|
+
thread_id?: string;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
function isHostMcpToolEvent(event: CodexEvent): boolean {
|
|
371
|
+
return (
|
|
372
|
+
event.item?.type === 'mcp_tool_call' &&
|
|
373
|
+
event.item.server === 'harness-tools'
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function relayCallFromCodexMcpEvent(
|
|
378
|
+
event: CodexEvent,
|
|
379
|
+
): ToolRelayCall | undefined {
|
|
380
|
+
if (event.type !== 'item.started') return undefined;
|
|
381
|
+
const toolName = event.item?.tool;
|
|
382
|
+
if (!toolName) return undefined;
|
|
383
|
+
return {
|
|
384
|
+
toolName,
|
|
385
|
+
input: event.item?.arguments ?? {},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function translateAndEmit(
|
|
390
|
+
event: CodexEvent,
|
|
391
|
+
ctx: {
|
|
392
|
+
send: Emit;
|
|
393
|
+
textByItem: Map<string, string>;
|
|
394
|
+
reasoningByItem: Map<string, string>;
|
|
395
|
+
stepTracker: CodexStepTracker;
|
|
396
|
+
setTurnUsage: (u: Record<string, unknown>) => void;
|
|
397
|
+
emitWarning: BridgeTurn['emitWarning'];
|
|
398
|
+
emitError: BridgeTurn['emitError'];
|
|
399
|
+
},
|
|
400
|
+
): void {
|
|
401
|
+
if (event.type === 'turn.completed') {
|
|
402
|
+
if (event.usage) ctx.setTurnUsage(mapUsage(event.usage));
|
|
403
|
+
ctx.stepTracker.finishStep();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (event.type === 'turn.failed') {
|
|
407
|
+
ctx.emitError({
|
|
408
|
+
error: event.error?.message ?? 'codex turn failed',
|
|
409
|
+
message: 'codex turn failed',
|
|
410
|
+
});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (event.type === 'error') {
|
|
414
|
+
ctx.emitError({
|
|
415
|
+
error: event.message ?? 'codex error',
|
|
416
|
+
message: 'codex stream error',
|
|
417
|
+
});
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (!event.item) return;
|
|
421
|
+
const item = event.item;
|
|
422
|
+
const id = item.id ?? randomUUID();
|
|
423
|
+
const observeStep = (): void => {
|
|
424
|
+
ctx.stepTracker.observeEvent({ event, itemId: id });
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
if (item.type === 'agent_message' && typeof item.text === 'string') {
|
|
428
|
+
/*
|
|
429
|
+
* The presence of `id` in `textByItem` — not the `item.started` event —
|
|
430
|
+
* marks the text part as opened. Codex does not guarantee an
|
|
431
|
+
* `item.started` event carrying text precedes the first `item.updated`
|
|
432
|
+
* with text, so keying the `text-start` off the event type can emit a
|
|
433
|
+
* `text-delta` for a part that was never opened. Opening lazily on the
|
|
434
|
+
* first event with text keeps `text-start` before any `text-delta`.
|
|
435
|
+
*/
|
|
436
|
+
if (!ctx.textByItem.has(id)) {
|
|
437
|
+
ctx.send({ type: 'text-start', id });
|
|
438
|
+
ctx.textByItem.set(id, '');
|
|
439
|
+
}
|
|
440
|
+
const last = ctx.textByItem.get(id) ?? '';
|
|
441
|
+
const next = item.text;
|
|
442
|
+
if (next.length > last.length) {
|
|
443
|
+
ctx.send({ type: 'text-delta', id, delta: next.slice(last.length) });
|
|
444
|
+
ctx.textByItem.set(id, next);
|
|
445
|
+
}
|
|
446
|
+
if (event.type === 'item.completed') ctx.send({ type: 'text-end', id });
|
|
447
|
+
observeStep();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (item.type === 'reasoning' && typeof item.text === 'string') {
|
|
452
|
+
if (!ctx.reasoningByItem.has(id)) {
|
|
453
|
+
ctx.send({ type: 'reasoning-start', id });
|
|
454
|
+
ctx.reasoningByItem.set(id, '');
|
|
455
|
+
}
|
|
456
|
+
const last = ctx.reasoningByItem.get(id) ?? '';
|
|
457
|
+
const next = item.text;
|
|
458
|
+
if (next.length > last.length) {
|
|
459
|
+
ctx.send({ type: 'reasoning-delta', id, delta: next.slice(last.length) });
|
|
460
|
+
ctx.reasoningByItem.set(id, next);
|
|
461
|
+
}
|
|
462
|
+
if (event.type === 'item.completed')
|
|
463
|
+
ctx.send({ type: 'reasoning-end', id });
|
|
464
|
+
observeStep();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (item.type === 'command_execution') {
|
|
469
|
+
const nativeName = 'shell';
|
|
470
|
+
if (event.type === 'item.started') {
|
|
471
|
+
ctx.send({
|
|
472
|
+
type: 'tool-call',
|
|
473
|
+
toolCallId: id,
|
|
474
|
+
toolName: toCommonName(nativeName),
|
|
475
|
+
nativeName,
|
|
476
|
+
input: JSON.stringify({ command: item.command ?? '' }),
|
|
477
|
+
providerExecuted: true,
|
|
478
|
+
});
|
|
479
|
+
} else if (event.type === 'item.completed') {
|
|
480
|
+
ctx.send({
|
|
481
|
+
type: 'tool-result',
|
|
482
|
+
toolCallId: id,
|
|
483
|
+
toolName: toCommonName(nativeName),
|
|
484
|
+
result: {
|
|
485
|
+
exitCode: item.exit_code ?? null,
|
|
486
|
+
output: item.aggregated_output ?? '',
|
|
487
|
+
status: item.status ?? 'completed',
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
observeStep();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (item.type === 'mcp_tool_call') {
|
|
496
|
+
const isHostTool = item.server === 'harness-tools';
|
|
497
|
+
if (event.type === 'item.started') {
|
|
498
|
+
ctx.send({
|
|
499
|
+
type: 'tool-call',
|
|
500
|
+
toolCallId: id,
|
|
501
|
+
toolName: item.tool ?? 'unknown',
|
|
502
|
+
...(isHostTool ? {} : { nativeName: item.tool ?? 'unknown' }),
|
|
503
|
+
input: JSON.stringify(item.arguments ?? {}),
|
|
504
|
+
providerExecuted: !isHostTool,
|
|
505
|
+
});
|
|
506
|
+
} else if (event.type === 'item.completed') {
|
|
507
|
+
ctx.send({
|
|
508
|
+
type: 'tool-result',
|
|
509
|
+
toolCallId: id,
|
|
510
|
+
toolName: item.tool ?? 'unknown',
|
|
511
|
+
result: extractMcpToolCallResult(item),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
observeStep();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (item.type === 'web_search') {
|
|
519
|
+
const nativeName = 'web_search';
|
|
520
|
+
if (event.type === 'item.started') {
|
|
521
|
+
ctx.send({
|
|
522
|
+
type: 'tool-call',
|
|
523
|
+
toolCallId: id,
|
|
524
|
+
toolName: toCommonName(nativeName),
|
|
525
|
+
nativeName,
|
|
526
|
+
input: JSON.stringify({ query: item.query ?? '' }),
|
|
527
|
+
providerExecuted: true,
|
|
528
|
+
});
|
|
529
|
+
} else if (event.type === 'item.completed') {
|
|
530
|
+
ctx.send({
|
|
531
|
+
type: 'tool-result',
|
|
532
|
+
toolCallId: id,
|
|
533
|
+
toolName: toCommonName(nativeName),
|
|
534
|
+
result: item.result ?? null,
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
observeStep();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (item.type === 'file_change' && event.type === 'item.completed') {
|
|
542
|
+
for (const change of item.changes ?? []) {
|
|
543
|
+
ctx.send({
|
|
544
|
+
type: 'file-change',
|
|
545
|
+
event:
|
|
546
|
+
change.kind === 'add'
|
|
547
|
+
? 'create'
|
|
548
|
+
: change.kind === 'delete'
|
|
549
|
+
? 'delete'
|
|
550
|
+
: 'modify',
|
|
551
|
+
path: change.path,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
observeStep();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (item.type === 'error' && event.type === 'item.completed') {
|
|
559
|
+
const message =
|
|
560
|
+
typeof item.message === 'string' && item.message.trim()
|
|
561
|
+
? item.message
|
|
562
|
+
: 'codex reported a non-fatal error item';
|
|
563
|
+
ctx.emitWarning({ message });
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function mapUsage(usage: Record<string, number>): Record<string, unknown> {
|
|
569
|
+
const input = usage.input_tokens ?? 0;
|
|
570
|
+
const cacheRead = usage.cached_input_tokens ?? 0;
|
|
571
|
+
return {
|
|
572
|
+
inputTokens: {
|
|
573
|
+
total: input,
|
|
574
|
+
noCache: Math.max(0, input - cacheRead),
|
|
575
|
+
cacheRead,
|
|
576
|
+
cacheWrite: 0,
|
|
577
|
+
},
|
|
578
|
+
outputTokens: {
|
|
579
|
+
total: usage.output_tokens ?? 0,
|
|
580
|
+
text: usage.output_tokens ?? 0,
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Tool relay — HTTP server on 127.0.0.1:0. The MCP stdio shim spawned by
|
|
587
|
+
* codex POSTs each tool invocation here; the relay forwards the call to the
|
|
588
|
+
* host (via the shared runtime's `emit`), awaits the matching `tool-result`
|
|
589
|
+
* (via `requestToolResult`), and responds with `{ result }`.
|
|
590
|
+
*/
|
|
591
|
+
async function startToolRelay({
|
|
592
|
+
allowedScriptPaths,
|
|
593
|
+
tools,
|
|
594
|
+
emit,
|
|
595
|
+
requestToolResult,
|
|
596
|
+
}: {
|
|
597
|
+
allowedScriptPaths: ReadonlyArray<string>;
|
|
598
|
+
tools: ReadonlyArray<{ name: string }>;
|
|
599
|
+
emit: Emit;
|
|
600
|
+
requestToolResult: (
|
|
601
|
+
toolCallId: string,
|
|
602
|
+
) => Promise<{ output: unknown; isError?: boolean }>;
|
|
603
|
+
}): Promise<ToolRelay> {
|
|
604
|
+
const toolNames = new Set(tools.map(t => t.name));
|
|
605
|
+
const allowedScriptPathSet = new Set(allowedScriptPaths);
|
|
606
|
+
const authorizer = new ToolRelayAuthorizer();
|
|
607
|
+
const pendingCalls = new ToolRelayPendingCalls();
|
|
608
|
+
|
|
609
|
+
const server = createServer(async (req, res) => {
|
|
610
|
+
try {
|
|
611
|
+
if (req.method !== 'POST' || req.url !== '/') {
|
|
612
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
613
|
+
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const chunks: Buffer[] = [];
|
|
617
|
+
for await (const chunk of req) {
|
|
618
|
+
chunks.push(chunk as Buffer);
|
|
619
|
+
}
|
|
620
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
621
|
+
const { requestId, toolName, input } = JSON.parse(body) as {
|
|
622
|
+
requestId: string;
|
|
623
|
+
toolName: string;
|
|
624
|
+
input: unknown;
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
if (!toolNames.has(toolName)) {
|
|
628
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
629
|
+
res.end(
|
|
630
|
+
JSON.stringify({ error: `Tool "${toolName}" is not available` }),
|
|
631
|
+
);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const relayCall = { toolName, input };
|
|
635
|
+
const authorized =
|
|
636
|
+
authorizer.consumeToolCall(relayCall) ||
|
|
637
|
+
(await isToolRelayRequestFromAllowedProcess({
|
|
638
|
+
socket: req.socket,
|
|
639
|
+
allowedScriptPaths: allowedScriptPathSet,
|
|
640
|
+
}));
|
|
641
|
+
if (!authorized) {
|
|
642
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
643
|
+
res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const { result } = pendingCalls.begin({
|
|
648
|
+
call: relayCall,
|
|
649
|
+
run: async () => {
|
|
650
|
+
emit({
|
|
651
|
+
type: 'tool-call',
|
|
652
|
+
toolCallId: requestId,
|
|
653
|
+
toolName,
|
|
654
|
+
input: JSON.stringify(input ?? {}),
|
|
655
|
+
providerExecuted: false,
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const toolResult = await requestToolResult(requestId);
|
|
659
|
+
emit({
|
|
660
|
+
type: 'tool-result',
|
|
661
|
+
toolCallId: requestId,
|
|
662
|
+
toolName,
|
|
663
|
+
result: toolResult.output ?? null,
|
|
664
|
+
isError: !!toolResult.isError,
|
|
665
|
+
});
|
|
666
|
+
return toolResult;
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
const { output } = await result;
|
|
670
|
+
|
|
671
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
672
|
+
res.end(JSON.stringify({ result: output }));
|
|
673
|
+
} catch (error) {
|
|
674
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
675
|
+
res.end(
|
|
676
|
+
JSON.stringify({
|
|
677
|
+
error: error instanceof Error ? error.message : String(error),
|
|
678
|
+
}),
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
await new Promise<void>(resolve =>
|
|
684
|
+
server.listen(0, '127.0.0.1', () => resolve()),
|
|
685
|
+
);
|
|
686
|
+
const address = server.address();
|
|
687
|
+
if (!address || typeof address === 'string') {
|
|
688
|
+
throw new Error('tool relay did not expose a numeric port');
|
|
689
|
+
}
|
|
690
|
+
return {
|
|
691
|
+
port: address.port,
|
|
692
|
+
close: () => closeServer(server),
|
|
693
|
+
authorizeToolCall: call => authorizer.authorizeToolCall(call),
|
|
694
|
+
authorizeAnyToolCall: () => authorizer.authorizeAnyToolCall(),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function closeServer(server: Server): void {
|
|
699
|
+
try {
|
|
700
|
+
server.close();
|
|
701
|
+
} catch {}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function parseArgs(args: string[]): {
|
|
705
|
+
workdir?: string;
|
|
706
|
+
bridgeStateDir?: string;
|
|
707
|
+
bootstrapDir?: string;
|
|
708
|
+
cliShimDir?: string;
|
|
709
|
+
} {
|
|
710
|
+
const out: {
|
|
711
|
+
workdir?: string;
|
|
712
|
+
bridgeStateDir?: string;
|
|
713
|
+
bootstrapDir?: string;
|
|
714
|
+
cliShimDir?: string;
|
|
715
|
+
} = {};
|
|
716
|
+
for (let i = 0; i < args.length; i++) {
|
|
717
|
+
if (args[i] === '--workdir' && i + 1 < args.length) {
|
|
718
|
+
out.workdir = args[++i];
|
|
719
|
+
} else if (args[i] === '--bridge-state-dir' && i + 1 < args.length) {
|
|
720
|
+
out.bridgeStateDir = args[++i];
|
|
721
|
+
} else if (args[i] === '--bootstrap-dir' && i + 1 < args.length) {
|
|
722
|
+
out.bootstrapDir = args[++i];
|
|
723
|
+
} else if (args[i] === '--cli-shim-dir' && i + 1 < args.length) {
|
|
724
|
+
out.cliShimDir = args[++i];
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return out;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function emitFatal(message: string): never {
|
|
731
|
+
stdout.write(JSON.stringify({ type: 'bridge-fatal', message }) + '\n');
|
|
732
|
+
process.exit(1);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function requireArg({
|
|
736
|
+
value,
|
|
737
|
+
name,
|
|
738
|
+
}: {
|
|
739
|
+
value: string | undefined;
|
|
740
|
+
name: string;
|
|
741
|
+
}): string {
|
|
742
|
+
if (!value) {
|
|
743
|
+
emitFatal(`Missing ${name} argument.`);
|
|
744
|
+
}
|
|
745
|
+
return value;
|
|
746
|
+
}
|