@ai-sdk/harness-codex 1.0.10 → 1.0.12

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.
@@ -5,10 +5,10 @@
5
5
  // This file supplies only the Codex-specific turn driver.
6
6
  //
7
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`.
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
12
 
13
13
  import {
14
14
  runBridge,
@@ -18,15 +18,20 @@ import {
18
18
  import type { HarnessV1BuiltinToolName } from '@ai-sdk/harness';
19
19
  import type { StartMessage } from '../codex-bridge-protocol';
20
20
  import { randomUUID } from 'node:crypto';
21
- import { writeFile } from 'node:fs/promises';
21
+ import { mkdir, writeFile } from 'node:fs/promises';
22
22
  import { createServer, type Server } from 'node:http';
23
23
  // Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
24
24
  import {
25
25
  CLI_SHIM_FILENAME,
26
26
  buildCliShimScript,
27
- composeToolUsageInstructions,
28
- isToolRelayCommand,
27
+ parseToolRelayCommand,
29
28
  } from './cli-relay';
29
+ import {
30
+ ToolRelayAuthorizer,
31
+ ToolRelayPendingCalls,
32
+ isToolRelayRequestFromAllowedProcess,
33
+ type ToolRelayCall,
34
+ } from './tool-relay-auth';
30
35
  import { argv, env as procEnv, stdout } from 'node:process';
31
36
 
32
37
  /*
@@ -63,14 +68,15 @@ function toCommonName(nativeName: string): HarnessV1BuiltinToolName | string {
63
68
  }
64
69
 
65
70
  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
- }
71
+ const workdir = requireArg({ value: args.workdir, name: '--workdir' });
72
+ const bridgeStateDir = requireArg({
73
+ value: args.bridgeStateDir,
74
+ name: '--bridge-state-dir',
75
+ });
76
+ const cliShimDir = requireArg({
77
+ value: args.cliShimDir,
78
+ name: '--cli-shim-dir',
79
+ });
74
80
  const bootstrapDir = args.bootstrapDir ?? workdir;
75
81
 
76
82
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -88,6 +94,12 @@ await runBridge<StartMessage>({
88
94
  });
89
95
 
90
96
  type Emit = (msg: Record<string, unknown>) => void;
97
+ type ToolRelay = {
98
+ port: number;
99
+ close(): void;
100
+ authorizeToolCall(call: ToolRelayCall): void;
101
+ authorizeAnyToolCall(): void;
102
+ };
91
103
 
92
104
  async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
93
105
  const emit: Emit = msg => turn.emit(msg as BridgeEvent);
@@ -114,18 +126,17 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
114
126
  * Until that's fixed, host tools are made available to the model via a
115
127
  * separate CLI-relay workaround (see `./cli-relay.ts`). The MCP server
116
128
  * 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.
129
+ * properly, host tools work both ways. Writing the shim here, adding matching
130
+ * prompt guidance in the host adapter, and filtering the shim command below
131
+ * implement the workaround and can be removed once the upstream bug is fixed.
121
132
  */
122
133
  const mcpServers: Record<string, unknown> = {};
123
- let relay: { port: number; close(): void } | undefined;
134
+ let relay: ToolRelay | undefined;
124
135
  let cliShimPath: string | undefined;
125
136
  if (start.tools && start.tools.length > 0) {
126
- const relayToken = randomUUID();
137
+ cliShimPath = `${cliShimDir}/${CLI_SHIM_FILENAME}`;
127
138
  relay = await startToolRelay({
128
- relayToken,
139
+ allowedScriptPaths: [cliShimPath, `${bootstrapDir}/host-tool-mcp.mjs`],
129
140
  tools: start.tools,
130
141
  emit,
131
142
  requestToolResult: turn.requestToolResult,
@@ -143,14 +154,13 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
143
154
  })),
144
155
  ),
145
156
  TOOL_RELAY_URL: `http://127.0.0.1:${relay.port}`,
146
- TOOL_RELAY_TOKEN: relayToken,
147
157
  },
148
158
  };
149
159
  // Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
150
- cliShimPath = `${workdir}/${CLI_SHIM_FILENAME}`;
160
+ await mkdir(cliShimDir, { recursive: true });
151
161
  await writeFile(
152
162
  cliShimPath,
153
- buildCliShimScript({ relayPort: relay.port, relayToken }),
163
+ buildCliShimScript({ relayPort: relay.port }),
154
164
  'utf8',
155
165
  );
156
166
  }
@@ -212,18 +222,7 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
212
222
 
213
223
  emit({ type: 'stream-start' });
214
224
 
215
- const userMessage = composeUserMessage({
216
- text: start.prompt,
217
- instructions: start.instructions,
218
- // Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
219
- toolUsageBlock:
220
- cliShimPath && start.tools && start.tools.length > 0
221
- ? composeToolUsageInstructions({
222
- tools: start.tools,
223
- cliShimPath,
224
- })
225
- : undefined,
226
- });
225
+ const userMessage = start.prompt;
227
226
  let turnUsage: Record<string, unknown> | undefined;
228
227
  const textByItem = new Map<string, string>();
229
228
  const reasoningByItem = new Map<string, string>();
@@ -243,12 +242,28 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
243
242
  emit({ type: 'bridge-thread', threadId: event.thread_id });
244
243
  }
245
244
  // Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
246
- if (
247
- cliShimPath &&
248
- event.item?.type === 'command_execution' &&
249
- typeof event.item.command === 'string' &&
250
- isToolRelayCommand({ command: event.item.command, cliShimPath })
251
- ) {
245
+ if (cliShimPath && event.item?.type === 'command_execution') {
246
+ const relayCall =
247
+ typeof event.item.command === 'string'
248
+ ? parseToolRelayCommand({
249
+ command: event.item.command,
250
+ cliShimPath,
251
+ })
252
+ : undefined;
253
+ if (event.type === 'item.started' && relay) {
254
+ if (relayCall) {
255
+ relay.authorizeToolCall(relayCall);
256
+ } else if (typeof event.item.command !== 'string') {
257
+ relay.authorizeAnyToolCall();
258
+ }
259
+ }
260
+ if (relayCall) {
261
+ continue;
262
+ }
263
+ }
264
+ if (relay && isHostMcpToolEvent(event)) {
265
+ const relayCall = relayCallFromCodexMcpEvent(event);
266
+ if (relayCall) relay.authorizeToolCall(relayCall);
252
267
  continue;
253
268
  }
254
269
  translateAndEmit(event, {
@@ -332,6 +347,25 @@ type CodexEvent = {
332
347
  thread_id?: string;
333
348
  };
334
349
 
350
+ function isHostMcpToolEvent(event: CodexEvent): boolean {
351
+ return (
352
+ event.item?.type === 'mcp_tool_call' &&
353
+ event.item.server === 'harness-tools'
354
+ );
355
+ }
356
+
357
+ function relayCallFromCodexMcpEvent(
358
+ event: CodexEvent,
359
+ ): ToolRelayCall | undefined {
360
+ if (event.type !== 'item.started') return undefined;
361
+ const toolName = event.item?.tool;
362
+ if (!toolName) return undefined;
363
+ return {
364
+ toolName,
365
+ input: event.item?.arguments ?? {},
366
+ };
367
+ }
368
+
335
369
  function translateAndEmit(
336
370
  event: CodexEvent,
337
371
  ctx: {
@@ -523,65 +557,33 @@ function defaultUsage(): Record<string, unknown> {
523
557
  };
524
558
  }
525
559
 
526
- function composeUserMessage({
527
- text,
528
- instructions,
529
- toolUsageBlock,
530
- }: {
531
- text: string;
532
- instructions: string | 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 (toolUsageBlock) blocks.push(toolUsageBlock);
552
- blocks.push(instructions ? `<user-message>\n${text}\n</user-message>` : text);
553
- return blocks.join('\n\n');
554
- }
555
-
556
560
  /**
557
- * Tool relay — HTTP server on 127.0.0.1:0 with bearer-token auth. The MCP
558
- * stdio shim spawned by codex POSTs each tool invocation here; the relay
559
- * forwards the call to the host (via the shared runtime's `emit`), awaits the
560
- * matching `tool-result` (via `requestToolResult`), and responds with
561
- * `{ result }`.
561
+ * Tool relay — HTTP server on 127.0.0.1:0. The MCP stdio shim spawned by
562
+ * codex POSTs each tool invocation here; the relay forwards the call to the
563
+ * host (via the shared runtime's `emit`), awaits the matching `tool-result`
564
+ * (via `requestToolResult`), and responds with `{ result }`.
562
565
  */
563
566
  async function startToolRelay({
564
- relayToken,
567
+ allowedScriptPaths,
565
568
  tools,
566
569
  emit,
567
570
  requestToolResult,
568
571
  }: {
569
- relayToken: string;
572
+ allowedScriptPaths: ReadonlyArray<string>;
570
573
  tools: ReadonlyArray<{ name: string }>;
571
574
  emit: Emit;
572
575
  requestToolResult: (
573
576
  toolCallId: string,
574
577
  ) => Promise<{ output: unknown; isError?: boolean }>;
575
- }): Promise<{ port: number; close(): void }> {
578
+ }): Promise<ToolRelay> {
576
579
  const toolNames = new Set(tools.map(t => t.name));
580
+ const allowedScriptPathSet = new Set(allowedScriptPaths);
581
+ const authorizer = new ToolRelayAuthorizer();
582
+ const pendingCalls = new ToolRelayPendingCalls();
577
583
 
578
584
  const server = createServer(async (req, res) => {
579
585
  try {
580
- if (
581
- req.method !== 'POST' ||
582
- req.url !== '/' ||
583
- req.headers.authorization !== `Bearer ${relayToken}`
584
- ) {
586
+ if (req.method !== 'POST' || req.url !== '/') {
585
587
  res.writeHead(401, { 'Content-Type': 'application/json' });
586
588
  res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
587
589
  return;
@@ -604,23 +606,42 @@ async function startToolRelay({
604
606
  );
605
607
  return;
606
608
  }
609
+ const relayCall = { toolName, input };
610
+ const authorized =
611
+ authorizer.consumeToolCall(relayCall) ||
612
+ (await isToolRelayRequestFromAllowedProcess({
613
+ socket: req.socket,
614
+ allowedScriptPaths: allowedScriptPathSet,
615
+ }));
616
+ if (!authorized) {
617
+ res.writeHead(401, { 'Content-Type': 'application/json' });
618
+ res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
619
+ return;
620
+ }
607
621
 
608
- emit({
609
- type: 'tool-call',
610
- toolCallId: requestId,
611
- toolName,
612
- input: JSON.stringify(input ?? {}),
613
- providerExecuted: false,
614
- });
615
-
616
- const { output, isError } = await requestToolResult(requestId);
617
- emit({
618
- type: 'tool-result',
619
- toolCallId: requestId,
620
- toolName,
621
- result: output ?? null,
622
- isError: !!isError,
622
+ const { result } = pendingCalls.begin({
623
+ call: relayCall,
624
+ run: async () => {
625
+ emit({
626
+ type: 'tool-call',
627
+ toolCallId: requestId,
628
+ toolName,
629
+ input: JSON.stringify(input ?? {}),
630
+ providerExecuted: false,
631
+ });
632
+
633
+ const toolResult = await requestToolResult(requestId);
634
+ emit({
635
+ type: 'tool-result',
636
+ toolCallId: requestId,
637
+ toolName,
638
+ result: toolResult.output ?? null,
639
+ isError: !!toolResult.isError,
640
+ });
641
+ return toolResult;
642
+ },
623
643
  });
644
+ const { output } = await result;
624
645
 
625
646
  res.writeHead(200, { 'Content-Type': 'application/json' });
626
647
  res.end(JSON.stringify({ result: output }));
@@ -644,6 +665,8 @@ async function startToolRelay({
644
665
  return {
645
666
  port: address.port,
646
667
  close: () => closeServer(server),
668
+ authorizeToolCall: call => authorizer.authorizeToolCall(call),
669
+ authorizeAnyToolCall: () => authorizer.authorizeAnyToolCall(),
647
670
  };
648
671
  }
649
672
 
@@ -657,11 +680,13 @@ function parseArgs(args: string[]): {
657
680
  workdir?: string;
658
681
  bridgeStateDir?: string;
659
682
  bootstrapDir?: string;
683
+ cliShimDir?: string;
660
684
  } {
661
685
  const out: {
662
686
  workdir?: string;
663
687
  bridgeStateDir?: string;
664
688
  bootstrapDir?: string;
689
+ cliShimDir?: string;
665
690
  } = {};
666
691
  for (let i = 0; i < args.length; i++) {
667
692
  if (args[i] === '--workdir' && i + 1 < args.length) {
@@ -670,6 +695,8 @@ function parseArgs(args: string[]): {
670
695
  out.bridgeStateDir = args[++i];
671
696
  } else if (args[i] === '--bootstrap-dir' && i + 1 < args.length) {
672
697
  out.bootstrapDir = args[++i];
698
+ } else if (args[i] === '--cli-shim-dir' && i + 1 < args.length) {
699
+ out.cliShimDir = args[++i];
673
700
  }
674
701
  }
675
702
  return out;
@@ -686,3 +713,16 @@ function emitFatal(message: string): never {
686
713
  stdout.write(JSON.stringify({ type: 'bridge-fatal', message }) + '\n');
687
714
  process.exit(1);
688
715
  }
716
+
717
+ function requireArg({
718
+ value,
719
+ name,
720
+ }: {
721
+ value: string | undefined;
722
+ name: string;
723
+ }): string {
724
+ if (!value) {
725
+ emitFatal(`Missing ${name} argument.`);
726
+ }
727
+ return value;
728
+ }
@@ -0,0 +1,195 @@
1
+ import { readdir, readFile, readlink } from 'node:fs/promises';
2
+ import type { Socket } from 'node:net';
3
+
4
+ export type ToolRelayCall = {
5
+ toolName: string;
6
+ input: unknown;
7
+ };
8
+
9
+ export type ToolRelayResult = {
10
+ output: unknown;
11
+ isError?: boolean;
12
+ };
13
+
14
+ export class ToolRelayAuthorizer {
15
+ private readonly ttlMs: number;
16
+ private readonly now: () => number;
17
+ private readonly authorizations: Array<{
18
+ key?: string;
19
+ expiresAt: number;
20
+ }> = [];
21
+
22
+ constructor({
23
+ ttlMs = 10_000,
24
+ now = Date.now,
25
+ }: {
26
+ ttlMs?: number;
27
+ now?: () => number;
28
+ } = {}) {
29
+ this.ttlMs = ttlMs;
30
+ this.now = now;
31
+ }
32
+
33
+ authorizeToolCall(call: ToolRelayCall): void {
34
+ this.pruneExpired();
35
+ this.authorizations.push({
36
+ key: toolRelayCallKey(call),
37
+ expiresAt: this.now() + this.ttlMs,
38
+ });
39
+ }
40
+
41
+ authorizeAnyToolCall(): void {
42
+ this.pruneExpired();
43
+ this.authorizations.push({
44
+ expiresAt: this.now() + this.ttlMs,
45
+ });
46
+ }
47
+
48
+ consumeToolCall(call: ToolRelayCall): boolean {
49
+ this.pruneExpired();
50
+ const key = toolRelayCallKey(call);
51
+ let index = this.authorizations.findIndex(auth => auth.key === key);
52
+ if (index === -1) {
53
+ index = this.authorizations.findIndex(auth => auth.key === undefined);
54
+ }
55
+ if (index === -1) return false;
56
+ this.authorizations.splice(index, 1);
57
+ return true;
58
+ }
59
+
60
+ private pruneExpired(): void {
61
+ const now = this.now();
62
+ for (let i = this.authorizations.length - 1; i >= 0; i--) {
63
+ if (this.authorizations[i].expiresAt <= now) {
64
+ this.authorizations.splice(i, 1);
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ export class ToolRelayPendingCalls {
71
+ private readonly calls = new Map<string, Promise<ToolRelayResult>>();
72
+
73
+ begin({
74
+ call,
75
+ run,
76
+ }: {
77
+ call: ToolRelayCall;
78
+ run: () => Promise<ToolRelayResult>;
79
+ }): { result: Promise<ToolRelayResult>; isNew: boolean } {
80
+ const key = toolRelayCallKey(call);
81
+ const existing = this.calls.get(key);
82
+ if (existing) return { result: existing, isNew: false };
83
+
84
+ const result = run();
85
+ this.calls.set(key, result);
86
+ void result
87
+ .finally(() => {
88
+ if (this.calls.get(key) === result) {
89
+ this.calls.delete(key);
90
+ }
91
+ })
92
+ .catch(() => {});
93
+ return { result, isNew: true };
94
+ }
95
+ }
96
+
97
+ function toolRelayCallKey({ toolName, input }: ToolRelayCall): string {
98
+ return `${toolName}\0${canonicalJson(input ?? {})}`;
99
+ }
100
+
101
+ function canonicalJson(value: unknown): string {
102
+ return JSON.stringify(normalizeJsonValue(value));
103
+ }
104
+
105
+ function normalizeJsonValue(value: unknown): unknown {
106
+ if (Array.isArray(value)) {
107
+ return value.map(normalizeJsonValue);
108
+ }
109
+ if (value && typeof value === 'object') {
110
+ return Object.fromEntries(
111
+ Object.entries(value as Record<string, unknown>)
112
+ .filter(([, entryValue]) => entryValue !== undefined)
113
+ .sort(([left], [right]) => left.localeCompare(right))
114
+ .map(([key, entryValue]) => [key, normalizeJsonValue(entryValue)]),
115
+ );
116
+ }
117
+ return value;
118
+ }
119
+
120
+ export async function isToolRelayRequestFromAllowedProcess({
121
+ socket,
122
+ allowedScriptPaths,
123
+ }: {
124
+ socket: Socket;
125
+ allowedScriptPaths: ReadonlySet<string>;
126
+ }): Promise<boolean> {
127
+ if (process.platform !== 'linux') return false;
128
+ if (!socket.remotePort || !socket.localPort) return false;
129
+
130
+ const inode = await findTcpSocketInode({
131
+ clientPort: socket.remotePort,
132
+ serverPort: socket.localPort,
133
+ });
134
+ if (!inode) return false;
135
+
136
+ const cmdline = await findProcessCmdlineForSocketInode({ inode });
137
+ return cmdline?.some(arg => allowedScriptPaths.has(arg)) ?? false;
138
+ }
139
+
140
+ async function findTcpSocketInode({
141
+ clientPort,
142
+ serverPort,
143
+ }: {
144
+ clientPort: number;
145
+ serverPort: number;
146
+ }): Promise<string | undefined> {
147
+ for (const tablePath of ['/proc/net/tcp', '/proc/net/tcp6']) {
148
+ const table = await readFile(tablePath, 'utf8').catch(() => undefined);
149
+ if (!table) continue;
150
+ for (const line of table.split('\n').slice(1)) {
151
+ const columns = line.trim().split(/\s+/);
152
+ if (columns.length < 10) continue;
153
+ const local = parseProcNetAddress(columns[1]);
154
+ const remote = parseProcNetAddress(columns[2]);
155
+ if (
156
+ local?.port === clientPort &&
157
+ remote?.port === serverPort &&
158
+ columns[9] !== '0'
159
+ ) {
160
+ return columns[9];
161
+ }
162
+ }
163
+ }
164
+ return undefined;
165
+ }
166
+
167
+ function parseProcNetAddress(value: string): { port: number } | undefined {
168
+ const [, portHex] = value.split(':');
169
+ if (!portHex) return undefined;
170
+ return { port: Number.parseInt(portHex, 16) };
171
+ }
172
+
173
+ async function findProcessCmdlineForSocketInode({
174
+ inode,
175
+ }: {
176
+ inode: string;
177
+ }): Promise<string[] | undefined> {
178
+ const procEntries = await readdir('/proc', { withFileTypes: true }).catch(
179
+ () => [],
180
+ );
181
+ for (const entry of procEntries) {
182
+ if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
183
+ const fdDir = `/proc/${entry.name}/fd`;
184
+ const fds = await readdir(fdDir).catch(() => []);
185
+ for (const fd of fds) {
186
+ const target = await readlink(`${fdDir}/${fd}`).catch(() => undefined);
187
+ if (target !== `socket:[${inode}]`) continue;
188
+ const cmdline = await readFile(`/proc/${entry.name}/cmdline`, 'utf8')
189
+ .then(value => value.split('\0').filter(Boolean))
190
+ .catch(() => undefined);
191
+ if (cmdline) return cmdline;
192
+ }
193
+ }
194
+ return undefined;
195
+ }
@@ -17,7 +17,6 @@ export const outboundMessageSchema = harnessV1BridgeOutboundMessageSchema;
17
17
  export type OutboundMessage = z.infer<typeof outboundMessageSchema>;
18
18
 
19
19
  export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
20
- instructions: z.string().optional(),
21
20
  reasoningEffort: z.enum(['low', 'medium', 'high']).optional(),
22
21
  webSearch: z.boolean().optional(),
23
22
  // Resume signal. When supplied, the bridge calls