@ai-sdk/harness-codex 1.0.11 → 1.0.13

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
+ }
@@ -23,8 +23,11 @@ import {
23
23
  import {
24
24
  classifyDiskLog,
25
25
  markBridgeStarting,
26
+ resolveSandboxHomeDir,
26
27
  SandboxChannel,
28
+ shellQuote,
27
29
  waitForBridgeReady,
30
+ writeSkills as writeHarnessSkills,
28
31
  } from '@ai-sdk/harness/utils';
29
32
  import {
30
33
  type Experimental_SandboxProcess,
@@ -192,6 +195,13 @@ export function createCodex(
192
195
  return cachedBootstrap;
193
196
  },
194
197
  doStart: async startOpts => {
198
+ if (startOpts.builtinToolFiltering != null) {
199
+ throw new HarnessCapabilityUnsupportedError({
200
+ message:
201
+ "Harness 'codex' does not support built-in tool filtering controls.",
202
+ harnessId: 'codex',
203
+ });
204
+ }
195
205
  if (
196
206
  startOpts.permissionMode != null &&
197
207
  startOpts.permissionMode !== 'allow-all'
@@ -316,7 +326,7 @@ export function createCodex(
316
326
  const token = randomBytes(32).toString('hex');
317
327
  const codexSkillSetup =
318
328
  startOpts.skills && startOpts.skills.length > 0
319
- ? await writeSkills({
329
+ ? await writeCodexSkills({
320
330
  sandbox: session,
321
331
  skills: startOpts.skills,
322
332
  abortSignal: startOpts.abortSignal,
@@ -456,7 +466,7 @@ async function readBridgeAsset(name: string): Promise<string> {
456
466
  throw lastErr ?? new Error(`bridge asset not found: ${name}`);
457
467
  }
458
468
 
459
- async function writeSkills({
469
+ async function writeCodexSkills({
460
470
  sandbox,
461
471
  skills,
462
472
  abortSignal,
@@ -465,16 +475,6 @@ async function writeSkills({
465
475
  skills: ReadonlyArray<HarnessV1Skill>;
466
476
  abortSignal?: AbortSignal;
467
477
  }): Promise<WriteSkillsResult> {
468
- for (const skill of skills) {
469
- safeCodexSkillName(skill.name);
470
- for (const file of skill.files ?? []) {
471
- safeCodexSkillFilePath({
472
- skillName: skill.name,
473
- filePath: file.path,
474
- });
475
- }
476
- }
477
-
478
478
  const homeDir = await resolveSandboxHomeDir({ sandbox, abortSignal });
479
479
  const codexHomeDir = path.posix.join(homeDir, '.codex');
480
480
  await sandbox.run({
@@ -483,92 +483,22 @@ async function writeSkills({
483
483
  });
484
484
 
485
485
  const rootDir = path.posix.join(homeDir, '.agents', 'skills');
486
- await sandbox.run({
487
- command: `mkdir -p ${shellQuote(rootDir)}`,
486
+ await writeHarnessSkills({
487
+ sandbox,
488
+ rootDir,
489
+ skills,
488
490
  abortSignal,
491
+ invalidSkillNameMessage: ({ name }) => `Invalid Codex skill name: ${name}`,
492
+ invalidSkillFilePathMessage: ({ skillName, filePath }) =>
493
+ `Invalid Codex skill file path for ${skillName}: ${filePath}`,
489
494
  });
490
495
 
491
- for (const skill of skills) {
492
- const name = safeCodexSkillName(skill.name);
493
- const skillDir = path.posix.join(rootDir, name);
494
- const content = `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.content}`;
495
-
496
- await sandbox.writeTextFile({
497
- path: path.posix.join(skillDir, 'SKILL.md'),
498
- content,
499
- abortSignal,
500
- });
501
-
502
- for (const file of skill.files ?? []) {
503
- const filePath = safeCodexSkillFilePath({
504
- skillName: skill.name,
505
- filePath: file.path,
506
- });
507
- await sandbox.writeTextFile({
508
- path: path.posix.join(skillDir, filePath),
509
- content: file.content,
510
- abortSignal,
511
- });
512
- }
513
- }
514
-
515
496
  return {
516
497
  homeDir,
517
498
  codexHomeDir,
518
499
  };
519
500
  }
520
501
 
521
- async function resolveSandboxHomeDir({
522
- sandbox,
523
- abortSignal,
524
- }: {
525
- sandbox: Experimental_SandboxSession;
526
- abortSignal?: AbortSignal;
527
- }): Promise<string> {
528
- const result = await sandbox.run({
529
- command: 'printf "%s" "$HOME"',
530
- abortSignal,
531
- });
532
- const homeDir = result.stdout.trim();
533
- if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
534
- throw new Error(
535
- `Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`,
536
- );
537
- }
538
- return homeDir;
539
- }
540
-
541
- function safeCodexSkillName(name: string): string {
542
- if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
543
- throw new Error(`Invalid Codex skill name: ${name}`);
544
- }
545
- return name;
546
- }
547
-
548
- function safeCodexSkillFilePath({
549
- skillName,
550
- filePath,
551
- }: {
552
- skillName: string;
553
- filePath: string;
554
- }): string {
555
- const normalized = path.posix.normalize(filePath);
556
- if (
557
- normalized === '.' ||
558
- normalized.startsWith('../') ||
559
- path.posix.isAbsolute(normalized)
560
- ) {
561
- throw new Error(
562
- `Invalid Codex skill file path for ${skillName}: ${filePath}`,
563
- );
564
- }
565
- return normalized;
566
- }
567
-
568
- function shellQuote(value: string): string {
569
- return `'${value.replace(/'/g, `'\\''`)}'`;
570
- }
571
-
572
502
  async function forwardBridgeStderr(
573
503
  stream: ReadableStream<Uint8Array>,
574
504
  ): Promise<void> {