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