@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.
@@ -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
+ }