@ai-sdk/harness-codex 1.0.126 → 1.0.129

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,617 @@
1
+ import type { BridgeTurn } from '@ai-sdk/harness/bridge';
2
+ import { createHash } from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+ import type { StartMessage } from '../codex-bridge-protocol';
5
+ import { resolveCodexBuiltinToolPolicy } from './codex-tool-filtering';
6
+ import {
7
+ CodexAppServerClient,
8
+ type CodexAppServerRequest,
9
+ } from './codex-app-server-client';
10
+ import {
11
+ createAppServerEventHandler,
12
+ type AppServerTurnResult,
13
+ } from './create-app-server-event-handler';
14
+ import type { CodexStepTracker } from './codex-step-tracker';
15
+ import type { CodexEvent } from './create-emit-stream-event';
16
+ import { isDeepStrictEqual } from 'node:util';
17
+ import {
18
+ assertTrustedApplyPatchHook,
19
+ createTrustedApplyPatchHook,
20
+ } from './codex-tool-filtering-hook';
21
+
22
+ type Emit = (message: Record<string, unknown>) => void;
23
+
24
+ const CODEX_CLI_PATH = fileURLToPath(
25
+ new URL('./node_modules/@openai/codex/bin/codex.js', import.meta.url),
26
+ );
27
+
28
+ type RunTurnOptions = {
29
+ start: StartMessage;
30
+ turn: BridgeTurn;
31
+ emit: Emit;
32
+ workdir: string;
33
+ threadId: string | undefined;
34
+ codexModel: string | undefined;
35
+ codexConfig: Record<string, unknown>;
36
+ stepTracker: CodexStepTracker;
37
+ emitStreamEvent: (event: CodexEvent) => void;
38
+ };
39
+
40
+ type ActiveTurn = {
41
+ threadId: string | undefined;
42
+ turnId: string | undefined;
43
+ handler: ReturnType<typeof createAppServerEventHandler>;
44
+ dynamicTools: ReturnType<typeof createDynamicTools>;
45
+ options: RunTurnOptions;
46
+ fail(error: unknown): void;
47
+ };
48
+
49
+ export function createCodexAppServerRuntime(): {
50
+ runTurn(options: RunTurnOptions): Promise<void>;
51
+ close(): Promise<void>;
52
+ } {
53
+ let client: CodexAppServerClient | undefined;
54
+ let loadedThreadId: string | undefined;
55
+ let loadedConfig: Record<string, unknown> | undefined;
56
+ let activeTurn: ActiveTurn | undefined;
57
+
58
+ const close = async (): Promise<void> => {
59
+ const previous = client;
60
+ client = undefined;
61
+ loadedThreadId = undefined;
62
+ loadedConfig = undefined;
63
+ await previous?.close();
64
+ };
65
+
66
+ const runTurn = async (options: RunTurnOptions): Promise<void> => {
67
+ const { start, turn, emit, workdir, threadId, codexModel, codexConfig } =
68
+ options;
69
+ if (activeTurn != null) throw new Error('A Codex turn is already active.');
70
+ const toolPolicy = resolveCodexBuiltinToolPolicy({
71
+ builtinToolFiltering: start.builtinToolFiltering,
72
+ webSearch: start.webSearch,
73
+ });
74
+ const trustedHook = toolPolicy.denyApplyPatchWithHook
75
+ ? createTrustedApplyPatchHook({ codexConfig })
76
+ : undefined;
77
+ const nextConfig = {
78
+ ...(trustedHook?.threadConfig ?? codexConfig),
79
+ web_search: toolPolicy.webSearchMode,
80
+ ...(toolPolicy.disabled.bash || toolPolicy.disabled.view_image
81
+ ? {
82
+ features: {
83
+ ...asRecord(codexConfig.features),
84
+ ...(toolPolicy.disabled.bash ? { shell_tool: false } : {}),
85
+ ...(toolPolicy.disabled.view_image ? { view_image: false } : {}),
86
+ },
87
+ }
88
+ : {}),
89
+ };
90
+ const processConfig = {
91
+ thread: nextConfig,
92
+ cliOverrides: trustedHook?.cliOverrides ?? [],
93
+ };
94
+ if (client != null && !isDeepStrictEqual(loadedConfig, processConfig)) {
95
+ await close();
96
+ }
97
+ const handler = createAppServerEventHandler({
98
+ stepTracker: options.stepTracker,
99
+ emitStreamEvent: options.emitStreamEvent,
100
+ emitWarning: turn.emitWarning,
101
+ emitError: turn.emitError,
102
+ });
103
+ const dynamicTools = createDynamicTools(start.tools ?? []);
104
+ let rejectProtocolFailure: (error: unknown) => void = () => {};
105
+ const protocolFailure = new Promise<never>((_, reject) => {
106
+ rejectProtocolFailure = reject;
107
+ });
108
+ const currentTurn: ActiveTurn = {
109
+ threadId,
110
+ turnId: undefined,
111
+ handler,
112
+ dynamicTools,
113
+ options,
114
+ fail: rejectProtocolFailure,
115
+ };
116
+ activeTurn = currentTurn;
117
+ let initialized = false;
118
+ if (client == null) {
119
+ const created = new CodexAppServerClient({
120
+ executable: process.execPath,
121
+ args: createCodexAppServerArgs({
122
+ cliOverrides: trustedHook?.cliOverrides,
123
+ }),
124
+ cwd: workdir,
125
+ env: process.env,
126
+ onNotification: notification => {
127
+ if (client !== created || activeTurn == null) return;
128
+ const params = asRecord(notification.params);
129
+ if (
130
+ notification.method === 'turn/started' &&
131
+ params?.threadId === activeTurn.threadId &&
132
+ activeTurn.turnId == null
133
+ ) {
134
+ const turnId = asRecord(params?.turn)?.id;
135
+ if (typeof turnId === 'string') activeTurn.turnId = turnId;
136
+ }
137
+ activeTurn.handler.handle(notification);
138
+ },
139
+ onRequest: request => {
140
+ const active = activeTurn;
141
+ const params = asRecord(request.params);
142
+ if (
143
+ client !== created ||
144
+ active == null ||
145
+ params?.threadId !== active.threadId ||
146
+ params?.turnId !== active.turnId ||
147
+ active.turnId == null
148
+ ) {
149
+ return Promise.reject(
150
+ new Error(
151
+ 'Codex app-server requested a tool for an inactive turn.',
152
+ ),
153
+ );
154
+ }
155
+ return handleAppServerRequest({
156
+ request,
157
+ dynamicTools: active.dynamicTools,
158
+ emit: active.options.emit,
159
+ requestToolResult: active.options.turn.requestToolResult,
160
+ }).catch(error => {
161
+ active.fail(error);
162
+ throw error;
163
+ });
164
+ },
165
+ onStderr: text => {
166
+ const message = text.trim();
167
+ if (message.length > 0) {
168
+ activeTurn?.options.turn.bridgeLog({
169
+ level: 'debug',
170
+ subsystem: 'codex.app-server.stderr',
171
+ message,
172
+ });
173
+ }
174
+ },
175
+ });
176
+ client = created;
177
+ loadedConfig = processConfig;
178
+ initialized = true;
179
+ }
180
+ const runningClient = client;
181
+ const clientFailure = runningClient.waitUntilFailure();
182
+ const exitFailure = runningClient
183
+ .waitUntilExit()
184
+ .then(({ code, signal }) => {
185
+ throw new Error(
186
+ `Codex app-server exited before the turn completed (code ${code ?? 'null'}, signal ${signal ?? 'null'}).`,
187
+ );
188
+ });
189
+ let removeAbortListener = () => {};
190
+ const abortFailure = new Promise<never>((_, reject) => {
191
+ const onAbort = () => {
192
+ if (currentTurn.threadId != null && currentTurn.turnId != null) {
193
+ void runningClient
194
+ .request({
195
+ method: 'turn/interrupt',
196
+ params: {
197
+ threadId: currentTurn.threadId,
198
+ turnId: currentTurn.turnId,
199
+ },
200
+ })
201
+ .catch(() => {});
202
+ }
203
+ reject(
204
+ turn.abortSignal.reason ?? new DOMException('Aborted', 'AbortError'),
205
+ );
206
+ };
207
+ if (turn.abortSignal.aborted) onAbort();
208
+ else {
209
+ turn.abortSignal.addEventListener('abort', onAbort, { once: true });
210
+ removeAbortListener = () =>
211
+ turn.abortSignal.removeEventListener('abort', onAbort);
212
+ }
213
+ });
214
+ const raceWithProcess = <T>({ operation }: { operation: Promise<T> }) =>
215
+ Promise.race([
216
+ operation,
217
+ abortFailure,
218
+ protocolFailure,
219
+ clientFailure,
220
+ exitFailure,
221
+ ]);
222
+ let stopSteering = () => {};
223
+ const steeringStopped = new Promise<void>(resolve => {
224
+ stopSteering = resolve;
225
+ });
226
+ const userMessageLoop = async () => {
227
+ for await (const message of turn.experimental_userMessages) {
228
+ try {
229
+ const response = await Promise.race([
230
+ raceWithProcess({
231
+ operation: runningClient.request({
232
+ method: 'turn/steer',
233
+ params: {
234
+ threadId: currentTurn.threadId!,
235
+ expectedTurnId: currentTurn.turnId!,
236
+ clientUserMessageId: message.messageId,
237
+ input: [
238
+ { type: 'text', text: message.text, text_elements: [] },
239
+ ],
240
+ },
241
+ }),
242
+ }),
243
+ steeringStopped.then(() => {
244
+ throw new Error(
245
+ 'The Codex turn ended before accepting the user message.',
246
+ );
247
+ }),
248
+ ]);
249
+ if (asRecord(response)?.turnId !== currentTurn.turnId) {
250
+ throw new Error(
251
+ 'Codex app-server turn/steer returned an unexpected turn ID.',
252
+ );
253
+ }
254
+ message.accept();
255
+ } catch (error) {
256
+ message.reject(error);
257
+ }
258
+ }
259
+ };
260
+ let keepClient = false;
261
+ try {
262
+ if (initialized) {
263
+ await raceWithProcess({
264
+ operation: runningClient.initialize({
265
+ clientName: 'ai-sdk-harness-codex',
266
+ clientVersion: '1',
267
+ }),
268
+ });
269
+ }
270
+ if (start.restartThread && loadedThreadId != null) {
271
+ await raceWithProcess({
272
+ operation: runningClient.request({
273
+ method: 'thread/unsubscribe',
274
+ params: { threadId: loadedThreadId },
275
+ }),
276
+ });
277
+ loadedThreadId = undefined;
278
+ }
279
+ if (trustedHook != null) {
280
+ const hooks = await raceWithProcess({
281
+ operation: runningClient.request({
282
+ method: 'hooks/list',
283
+ params: {},
284
+ }),
285
+ });
286
+ assertTrustedApplyPatchHook({ response: hooks, hook: trustedHook });
287
+ }
288
+ const threadMethod = threadId == null ? 'thread/start' : 'thread/resume';
289
+ if (loadedThreadId !== threadId || threadId == null) {
290
+ const threadResponse = await raceWithProcess({
291
+ operation: runningClient.request({
292
+ method: threadMethod,
293
+ params:
294
+ threadId == null
295
+ ? {
296
+ ...createThreadParams({
297
+ start,
298
+ workdir,
299
+ codexModel,
300
+ codexConfig: nextConfig,
301
+ }),
302
+ dynamicTools: dynamicTools.specs,
303
+ experimentalRawEvents: true,
304
+ }
305
+ : {
306
+ threadId,
307
+ ...createThreadParams({
308
+ start,
309
+ workdir,
310
+ codexModel,
311
+ codexConfig: nextConfig,
312
+ }),
313
+ excludeTurns: true,
314
+ },
315
+ }),
316
+ });
317
+ assertCodexThreadPermissions({
318
+ response: threadResponse,
319
+ method: threadMethod,
320
+ });
321
+ currentTurn.threadId = readNestedString({
322
+ value: threadResponse,
323
+ path: ['thread', 'id'],
324
+ method: threadMethod,
325
+ });
326
+ loadedThreadId = currentTurn.threadId;
327
+ }
328
+ handler.announceThread(currentTurn.threadId!);
329
+ emit({ type: 'stream-start' });
330
+ const turnResponse = await raceWithProcess({
331
+ operation: runningClient.request({
332
+ method: 'turn/start',
333
+ params: createTurnParams({
334
+ threadId: currentTurn.threadId!,
335
+ start,
336
+ codexModel,
337
+ disableEnvironments: toolPolicy.disableEnvironments,
338
+ }),
339
+ }),
340
+ });
341
+ currentTurn.turnId = readNestedString({
342
+ value: turnResponse,
343
+ path: ['turn', 'id'],
344
+ method: 'turn/start',
345
+ });
346
+ handler.setTurnId(currentTurn.turnId);
347
+ void userMessageLoop();
348
+ const result = await raceWithProcess({
349
+ operation: handler.waitForCompletion(),
350
+ });
351
+ keepClient = true;
352
+ assertSuccessfulTurn(result);
353
+ } catch (error) {
354
+ if (turn.abortSignal.aborted && currentTurn.turnId != null) {
355
+ let timer: ReturnType<typeof setTimeout> | undefined;
356
+ try {
357
+ keepClient = await Promise.race([
358
+ handler.waitForCompletion().then(() => true),
359
+ new Promise<boolean>(resolve => {
360
+ timer = setTimeout(() => resolve(false), 5_000);
361
+ }),
362
+ clientFailure.then(
363
+ () => false,
364
+ () => false,
365
+ ),
366
+ ]);
367
+ } finally {
368
+ clearTimeout(timer);
369
+ }
370
+ }
371
+ throw error;
372
+ } finally {
373
+ turn.experimental_userMessages.close();
374
+ stopSteering();
375
+ removeAbortListener();
376
+ if (activeTurn === currentTurn) activeTurn = undefined;
377
+ if (!keepClient) await close();
378
+ }
379
+ };
380
+
381
+ return { runTurn, close };
382
+ }
383
+
384
+ export function createCodexAppServerArgs({
385
+ cliOverrides = [],
386
+ }: {
387
+ cliOverrides?: string[];
388
+ } = {}): string[] {
389
+ return [
390
+ CODEX_CLI_PATH,
391
+ '--config',
392
+ 'sandbox_mode="danger-full-access"',
393
+ '--config',
394
+ 'approval_policy="never"',
395
+ ...cliOverrides.flatMap(override => ['--config', override]),
396
+ 'app-server',
397
+ '--stdio',
398
+ ];
399
+ }
400
+
401
+ export function createThreadParams({
402
+ start,
403
+ workdir,
404
+ codexModel,
405
+ codexConfig,
406
+ }: {
407
+ start: StartMessage;
408
+ workdir: string;
409
+ codexModel: string | undefined;
410
+ codexConfig: Record<string, unknown>;
411
+ }): Record<string, unknown> {
412
+ return {
413
+ ...(codexModel == null ? {} : { model: codexModel }),
414
+ cwd: workdir,
415
+ approvalPolicy: 'never',
416
+ sandbox: 'danger-full-access',
417
+ config: {
418
+ ...codexConfig,
419
+ web_search:
420
+ codexConfig.web_search ?? (start.webSearch ? 'live' : 'disabled'),
421
+ },
422
+ };
423
+ }
424
+
425
+ export function createTurnParams({
426
+ threadId,
427
+ start,
428
+ codexModel,
429
+ disableEnvironments = false,
430
+ }: {
431
+ threadId: string;
432
+ start: StartMessage;
433
+ codexModel: string | undefined;
434
+ disableEnvironments?: boolean;
435
+ }): Record<string, unknown> {
436
+ return {
437
+ threadId,
438
+ ...(disableEnvironments ? { environments: [] } : {}),
439
+ input: [{ type: 'text', text: start.prompt, text_elements: [] }],
440
+ approvalPolicy: 'never',
441
+ sandboxPolicy: {
442
+ type: 'externalSandbox',
443
+ networkAccess: 'enabled',
444
+ },
445
+ ...(codexModel == null ? {} : { model: codexModel }),
446
+ ...(start.reasoningEffort == null ? {} : { effort: start.reasoningEffort }),
447
+ ...(start.responseFormat?.type === 'json' &&
448
+ start.responseFormat.schema != null
449
+ ? { outputSchema: start.responseFormat.schema }
450
+ : {}),
451
+ };
452
+ }
453
+
454
+ export function assertCodexThreadPermissions({
455
+ response,
456
+ method,
457
+ }: {
458
+ response: unknown;
459
+ method: string;
460
+ }): void {
461
+ const value = asRecord(response);
462
+ const sandbox = asRecord(value?.sandbox);
463
+ if (
464
+ value?.approvalPolicy !== 'never' ||
465
+ sandbox?.type !== 'dangerFullAccess'
466
+ ) {
467
+ throw new Error(
468
+ `Codex app-server ${method} did not disable approvals and its platform sandbox.`,
469
+ );
470
+ }
471
+ }
472
+
473
+ export function createDynamicTools(
474
+ tools: ReadonlyArray<{
475
+ name: string;
476
+ description?: string;
477
+ inputSchema?: unknown;
478
+ }>,
479
+ ): {
480
+ specs: Array<Record<string, unknown>>;
481
+ originalNameByAlias: ReadonlyMap<string, string>;
482
+ } {
483
+ const validOriginalNames = new Set(
484
+ tools.map(tool => tool.name).filter(isValidDynamicToolName),
485
+ );
486
+ const usedAliases = new Set<string>();
487
+ const originalNameByAlias = new Map<string, string>();
488
+ const specs = tools.map((tool, index) => {
489
+ let alias = tool.name;
490
+ if (!isValidDynamicToolName(alias) || usedAliases.has(alias)) {
491
+ const digest = createHash('sha256')
492
+ .update(tool.name)
493
+ .digest('hex')
494
+ .slice(0, 16);
495
+ const base = `ai_sdk_tool_${digest}`;
496
+ alias = base;
497
+ let suffix = index;
498
+ while (usedAliases.has(alias) || validOriginalNames.has(alias)) {
499
+ alias = `${base}_${suffix++}`;
500
+ }
501
+ }
502
+ usedAliases.add(alias);
503
+ originalNameByAlias.set(alias, tool.name);
504
+ return {
505
+ type: 'function',
506
+ name: alias,
507
+ description: tool.description ?? '',
508
+ inputSchema: tool.inputSchema ?? {},
509
+ };
510
+ });
511
+ return { specs, originalNameByAlias };
512
+ }
513
+
514
+ function isValidDynamicToolName(name: string): boolean {
515
+ return (
516
+ name.length > 0 &&
517
+ name.length <= 128 &&
518
+ /^[a-zA-Z0-9_-]+$/.test(name) &&
519
+ name !== 'mcp' &&
520
+ !name.startsWith('mcp__')
521
+ );
522
+ }
523
+
524
+ export async function handleAppServerRequest({
525
+ request,
526
+ dynamicTools,
527
+ emit,
528
+ requestToolResult,
529
+ }: {
530
+ request: CodexAppServerRequest;
531
+ dynamicTools: ReturnType<typeof createDynamicTools>;
532
+ emit: Emit;
533
+ requestToolResult: BridgeTurn['requestToolResult'];
534
+ }): Promise<unknown> {
535
+ if (request.method !== 'item/tool/call') {
536
+ throw new Error(
537
+ `Codex app-server requested unsupported method '${request.method}'.`,
538
+ );
539
+ }
540
+ const params = asRecord(request.params);
541
+ if (
542
+ params == null ||
543
+ typeof params.callId !== 'string' ||
544
+ typeof params.tool !== 'string'
545
+ ) {
546
+ throw new Error('Codex app-server sent an invalid dynamic tool request.');
547
+ }
548
+ const originalToolName = dynamicTools.originalNameByAlias.get(params.tool);
549
+ if (originalToolName == null) {
550
+ throw new Error(
551
+ `Codex app-server requested unknown dynamic tool '${params.tool}'.`,
552
+ );
553
+ }
554
+ emit({
555
+ type: 'tool-call',
556
+ toolCallId: params.callId,
557
+ toolName: originalToolName,
558
+ input: JSON.stringify(params.arguments ?? {}),
559
+ providerExecuted: false,
560
+ });
561
+ const result = await requestToolResult(params.callId);
562
+ emit({
563
+ type: 'tool-result',
564
+ toolCallId: params.callId,
565
+ toolName: originalToolName,
566
+ result: result.output ?? null,
567
+ isError: result.isError === true,
568
+ });
569
+ return {
570
+ contentItems: [
571
+ { type: 'inputText', text: serializeToolOutput(result.output) },
572
+ ],
573
+ success: result.isError !== true,
574
+ };
575
+ }
576
+
577
+ function serializeToolOutput(output: unknown): string {
578
+ if (typeof output === 'string') return output;
579
+ try {
580
+ return JSON.stringify(output) ?? String(output);
581
+ } catch {
582
+ return String(output);
583
+ }
584
+ }
585
+
586
+ function readNestedString({
587
+ value,
588
+ path,
589
+ method,
590
+ }: {
591
+ value: unknown;
592
+ path: string[];
593
+ method: string;
594
+ }): string {
595
+ let current: unknown = value;
596
+ for (const segment of path) current = asRecord(current)?.[segment];
597
+ if (typeof current !== 'string' || current.length === 0) {
598
+ throw new Error(`Codex app-server ${method} returned an invalid response.`);
599
+ }
600
+ return current;
601
+ }
602
+
603
+ function assertSuccessfulTurn(result: AppServerTurnResult): void {
604
+ if (result.status === 'completed') return;
605
+ if (result.status === 'interrupted') {
606
+ throw new DOMException('Codex turn was interrupted.', 'AbortError');
607
+ }
608
+ throw new Error(
609
+ result.error ?? `Codex turn ended with status '${result.status}'.`,
610
+ );
611
+ }
612
+
613
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
614
+ return value != null && typeof value === 'object' && !Array.isArray(value)
615
+ ? (value as Record<string, unknown>)
616
+ : undefined;
617
+ }
@@ -70,7 +70,9 @@ function isModelStepItem(item: CodexStepTrackerItem): boolean {
70
70
  function isToolStepItem(item: CodexStepTrackerItem): boolean {
71
71
  return (
72
72
  item.type === 'command_execution' ||
73
+ item.type === 'native_tool' ||
73
74
  item.type === 'mcp_tool_call' ||
75
+ item.type === 'dynamic_tool_call' ||
74
76
  item.type === 'web_search' ||
75
77
  item.type === 'file_change' ||
76
78
  item.type === 'todo_list'