@ai-sdk/harness-codex 1.0.11 → 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,
@@ -24,8 +24,14 @@ import { createServer, type Server } from 'node:http';
24
24
  import {
25
25
  CLI_SHIM_FILENAME,
26
26
  buildCliShimScript,
27
- isToolRelayCommand,
27
+ parseToolRelayCommand,
28
28
  } from './cli-relay';
29
+ import {
30
+ ToolRelayAuthorizer,
31
+ ToolRelayPendingCalls,
32
+ isToolRelayRequestFromAllowedProcess,
33
+ type ToolRelayCall,
34
+ } from './tool-relay-auth';
29
35
  import { argv, env as procEnv, stdout } from 'node:process';
30
36
 
31
37
  /*
@@ -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);
@@ -119,12 +131,12 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
119
131
  * implement the workaround and can be removed once the upstream bug is fixed.
120
132
  */
121
133
  const mcpServers: Record<string, unknown> = {};
122
- let relay: { port: number; close(): void } | undefined;
134
+ let relay: ToolRelay | undefined;
123
135
  let cliShimPath: string | undefined;
124
136
  if (start.tools && start.tools.length > 0) {
125
- const relayToken = randomUUID();
137
+ cliShimPath = `${cliShimDir}/${CLI_SHIM_FILENAME}`;
126
138
  relay = await startToolRelay({
127
- relayToken,
139
+ allowedScriptPaths: [cliShimPath, `${bootstrapDir}/host-tool-mcp.mjs`],
128
140
  tools: start.tools,
129
141
  emit,
130
142
  requestToolResult: turn.requestToolResult,
@@ -142,15 +154,13 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
142
154
  })),
143
155
  ),
144
156
  TOOL_RELAY_URL: `http://127.0.0.1:${relay.port}`,
145
- TOOL_RELAY_TOKEN: relayToken,
146
157
  },
147
158
  };
148
159
  // Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
149
- cliShimPath = `${cliShimDir}/${CLI_SHIM_FILENAME}`;
150
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
  }
@@ -232,12 +242,28 @@ async function runTurn(start: StartMessage, turn: BridgeTurn): Promise<void> {
232
242
  emit({ type: 'bridge-thread', threadId: event.thread_id });
233
243
  }
234
244
  // Temporary workaround for upstream codex MCP-tool bug — see ./cli-relay.ts
235
- if (
236
- cliShimPath &&
237
- event.item?.type === 'command_execution' &&
238
- typeof event.item.command === 'string' &&
239
- isToolRelayCommand({ command: event.item.command, cliShimPath })
240
- ) {
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);
241
267
  continue;
242
268
  }
243
269
  translateAndEmit(event, {
@@ -321,6 +347,25 @@ type CodexEvent = {
321
347
  thread_id?: string;
322
348
  };
323
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
+
324
369
  function translateAndEmit(
325
370
  event: CodexEvent,
326
371
  ctx: {
@@ -513,34 +558,32 @@ function defaultUsage(): Record<string, unknown> {
513
558
  }
514
559
 
515
560
  /**
516
- * Tool relay — HTTP server on 127.0.0.1:0 with bearer-token auth. The MCP
517
- * stdio shim spawned by codex POSTs each tool invocation here; the relay
518
- * forwards the call to the host (via the shared runtime's `emit`), awaits the
519
- * matching `tool-result` (via `requestToolResult`), and responds with
520
- * `{ 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 }`.
521
565
  */
522
566
  async function startToolRelay({
523
- relayToken,
567
+ allowedScriptPaths,
524
568
  tools,
525
569
  emit,
526
570
  requestToolResult,
527
571
  }: {
528
- relayToken: string;
572
+ allowedScriptPaths: ReadonlyArray<string>;
529
573
  tools: ReadonlyArray<{ name: string }>;
530
574
  emit: Emit;
531
575
  requestToolResult: (
532
576
  toolCallId: string,
533
577
  ) => Promise<{ output: unknown; isError?: boolean }>;
534
- }): Promise<{ port: number; close(): void }> {
578
+ }): Promise<ToolRelay> {
535
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();
536
583
 
537
584
  const server = createServer(async (req, res) => {
538
585
  try {
539
- if (
540
- req.method !== 'POST' ||
541
- req.url !== '/' ||
542
- req.headers.authorization !== `Bearer ${relayToken}`
543
- ) {
586
+ if (req.method !== 'POST' || req.url !== '/') {
544
587
  res.writeHead(401, { 'Content-Type': 'application/json' });
545
588
  res.end(JSON.stringify({ error: 'unauthorized tool relay request' }));
546
589
  return;
@@ -563,23 +606,42 @@ async function startToolRelay({
563
606
  );
564
607
  return;
565
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
+ }
566
621
 
567
- emit({
568
- type: 'tool-call',
569
- toolCallId: requestId,
570
- toolName,
571
- input: JSON.stringify(input ?? {}),
572
- providerExecuted: false,
573
- });
574
-
575
- const { output, isError } = await requestToolResult(requestId);
576
- emit({
577
- type: 'tool-result',
578
- toolCallId: requestId,
579
- toolName,
580
- result: output ?? null,
581
- 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
+ },
582
643
  });
644
+ const { output } = await result;
583
645
 
584
646
  res.writeHead(200, { 'Content-Type': 'application/json' });
585
647
  res.end(JSON.stringify({ result: output }));
@@ -603,6 +665,8 @@ async function startToolRelay({
603
665
  return {
604
666
  port: address.port,
605
667
  close: () => closeServer(server),
668
+ authorizeToolCall: call => authorizer.authorizeToolCall(call),
669
+ authorizeAnyToolCall: () => authorizer.authorizeAnyToolCall(),
606
670
  };
607
671
  }
608
672
 
@@ -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
+ }