@ai-sdk/harness-codex 0.0.0 → 1.0.0-beta.10

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,1098 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ commonTool,
7
+ HarnessCapabilityUnsupportedError,
8
+ harnessV1DiagnosticFromBridgeFrame,
9
+ type HarnessV1,
10
+ type HarnessV1Bootstrap,
11
+ type HarnessV1DebugConfig,
12
+ type HarnessV1BuiltinTool,
13
+ type HarnessV1ContinueTurnState,
14
+ type HarnessV1Prompt,
15
+ type HarnessV1PromptControl,
16
+ type HarnessV1ResumeSessionState,
17
+ type HarnessV1NetworkSandboxSession,
18
+ type HarnessV1PermissionMode,
19
+ type HarnessV1Session,
20
+ type HarnessV1Skill,
21
+ type HarnessV1StreamPart,
22
+ } from '@ai-sdk/harness';
23
+ import {
24
+ classifyDiskLog,
25
+ markBridgeStarting,
26
+ SandboxChannel,
27
+ waitForBridgeReady,
28
+ } from '@ai-sdk/harness/utils';
29
+ import {
30
+ type Experimental_SandboxProcess,
31
+ type Experimental_SandboxSession,
32
+ } from '@ai-sdk/provider-utils';
33
+ import { WebSocket } from 'ws';
34
+ import { z } from 'zod';
35
+ import { resolveCodexEnv, type CodexAuthOptions } from './codex-auth';
36
+ import {
37
+ outboundMessageSchema,
38
+ type InboundMessage,
39
+ type OutboundMessage,
40
+ } from './codex-bridge-protocol';
41
+
42
+ type CodexChannel = SandboxChannel<OutboundMessage, InboundMessage>;
43
+ type CodexRespawnStrategy = 'replay' | 'rerun';
44
+
45
+ type WriteSkillsResult = {
46
+ readonly homeDir: string;
47
+ readonly codexHomeDir: string;
48
+ };
49
+
50
+ /*
51
+ * The model the adapter pins when the consumer configures none. The Codex SDK
52
+ * does not report the model it resolves to at runtime (no model field on any
53
+ * event), and exposes no default-model constant, so we pin the latest
54
+ * codex-specialized model available for the bundled `@openai/codex@0.130.0`
55
+ * (published 2026-05-08): `gpt-5.3-codex` (released 2026-02). Keep this in sync
56
+ * when bumping the codex SDK/binary. Passing it explicitly makes the resolved
57
+ * model deterministic and the telemetry (`gen_ai.request.model`) accurate.
58
+ */
59
+ const DEFAULT_CODEX_MODEL = 'gpt-5.3-codex';
60
+
61
+ export type CodexHarnessSettings = {
62
+ readonly auth?: CodexAuthOptions;
63
+ /**
64
+ * OpenAI model id the underlying `codex` CLI should use. Leaving this unset
65
+ * pins the adapter default (`DEFAULT_CODEX_MODEL`).
66
+ */
67
+ readonly model?: string;
68
+ /**
69
+ * Reasoning effort for reasoning-capable models. Leaving this unset
70
+ * defers to the CLI's default.
71
+ */
72
+ readonly reasoningEffort?: 'low' | 'medium' | 'high';
73
+ /**
74
+ * When `true`, allow the underlying runtime to use live web search.
75
+ */
76
+ readonly webSearch?: boolean;
77
+ /**
78
+ * Override the port the bridge binds inside the sandbox. By default the
79
+ * adapter uses the first port the sandbox declares via `sandbox.ports`.
80
+ * Only set this if the sandbox declares multiple ports and the first one
81
+ * is reserved for something else.
82
+ */
83
+ readonly port?: number;
84
+ /** Maximum milliseconds to wait for the bridge to advertise its port. Defaults to 120000. */
85
+ readonly startupTimeoutMs?: number;
86
+ };
87
+
88
+ /*
89
+ * Every native tool the Codex CLI can invoke as a model-callable tool,
90
+ * declared as a `ToolSet` keyed by what the bridge emits as `toolName` on
91
+ * the wire (`commonName ?? nativeName`). Schemas reflect the `ThreadItem`
92
+ * union in `@openai/codex-sdk`'s `dist/index.d.ts`.
93
+ *
94
+ * Codex's other native operations (`apply_patch`, todo planning) surface
95
+ * only as side-effect events (`file_change`, `todo_list`) and are not
96
+ * model-callable tools — they don't appear here.
97
+ */
98
+ const CODEX_BUILTIN_TOOLS = {
99
+ bash: commonTool('bash', {
100
+ nativeName: 'shell',
101
+ toolUseKind: 'bash',
102
+ description: 'Execute a shell command',
103
+ inputSchema: z.object({ command: z.string() }),
104
+ }),
105
+ webSearch: commonTool('webSearch', {
106
+ nativeName: 'web_search',
107
+ toolUseKind: 'readonly',
108
+ description: 'Search the web',
109
+ inputSchema: z.object({ query: z.string() }),
110
+ }),
111
+ } as const satisfies Record<string, HarnessV1BuiltinTool<any, any>>;
112
+
113
+ /*
114
+ * Bootstrap lives in /tmp because it's pure derived state — the harness can
115
+ * reinstall the CLI and bridge files on any fresh sandbox from the recipe.
116
+ * Persistence comes from the sandbox provider's snapshot, not the path.
117
+ *
118
+ * The session work dir (`startOpts.sessionWorkDir`) and the bridge-state dir
119
+ * derived from `sandboxSession.defaultWorkingDirectory` both live under the sandbox's
120
+ * default working directory — the provider's persistent mount — so the
121
+ * workdir's contents (the codex CLI shim and any files the agent edits) and
122
+ * the bridge state files survive both detach -> attach and
123
+ * stop -> snapshot -> resume cycles.
124
+ */
125
+ const BOOTSTRAP_DIR = '/tmp/harness/codex';
126
+
127
+ /**
128
+ * Live bridge coordinates returned by `doDetach()` and `doSuspendTurn()`. A
129
+ * future process uses them to reopen a socket to the still-running bridge
130
+ * (`attach`) instead of re-spawning it. Absent on a `doStop()` payload.
131
+ */
132
+ const bridgeCoordsSchema = z.object({
133
+ port: z.number(),
134
+ token: z.string(),
135
+ lastSeenEventId: z.number(),
136
+ sandboxId: z.string().optional(),
137
+ });
138
+
139
+ /**
140
+ * Schema for the adapter-specific lifecycle `data` payload Codex produces.
141
+ * `threadId` is what `codex.resumeThread(...)` requires for the replay/rerun
142
+ * rungs; the sandbox lookup is handled separately via
143
+ * `provider.resumeSession({ sessionId })`. `bridge` carries live coordinates
144
+ * for cross-process `attach` (present on `doDetach()` and `doSuspendTurn()`
145
+ * payloads).
146
+ */
147
+ const codexResumeStateSchema = z.object({
148
+ threadId: z.string().optional(),
149
+ bridge: bridgeCoordsSchema.optional(),
150
+ });
151
+
152
+ type CodexBridgeCoords = z.infer<typeof bridgeCoordsSchema>;
153
+
154
+ export function createCodex(
155
+ settings: CodexHarnessSettings = {},
156
+ ): HarnessV1<typeof CODEX_BUILTIN_TOOLS> {
157
+ let cachedBootstrap: HarnessV1Bootstrap | undefined;
158
+
159
+ return {
160
+ specificationVersion: 'harness-v1',
161
+ harnessId: 'codex',
162
+ builtinTools: CODEX_BUILTIN_TOOLS,
163
+ supportsBuiltinToolApprovals: false,
164
+ lifecycleStateSchema: codexResumeStateSchema,
165
+ getBootstrap: async () => {
166
+ if (cachedBootstrap != null) return cachedBootstrap;
167
+ const [pkg, lock, bridge, hostToolMcp] = await Promise.all([
168
+ readBridgeAsset('package.json'),
169
+ readBridgeAsset('pnpm-lock.yaml'),
170
+ readBridgeAsset('index.mjs'),
171
+ readBridgeAsset('host-tool-mcp.mjs'),
172
+ ]);
173
+ cachedBootstrap = {
174
+ harnessId: 'codex',
175
+ bootstrapDir: BOOTSTRAP_DIR,
176
+ files: [
177
+ { path: `${BOOTSTRAP_DIR}/package.json`, content: pkg },
178
+ { path: `${BOOTSTRAP_DIR}/pnpm-lock.yaml`, content: lock },
179
+ { path: `${BOOTSTRAP_DIR}/bridge.mjs`, content: bridge },
180
+ {
181
+ path: `${BOOTSTRAP_DIR}/host-tool-mcp.mjs`,
182
+ content: hostToolMcp,
183
+ },
184
+ ],
185
+ commands: [
186
+ { command: `mkdir -p ${BOOTSTRAP_DIR}` },
187
+ {
188
+ command: `pnpm --dir ${BOOTSTRAP_DIR} install --frozen-lockfile --store-dir ${BOOTSTRAP_DIR}/.pnpm-store`,
189
+ },
190
+ ],
191
+ };
192
+ return cachedBootstrap;
193
+ },
194
+ doStart: async startOpts => {
195
+ if (
196
+ startOpts.permissionMode != null &&
197
+ startOpts.permissionMode !== 'allow-all'
198
+ ) {
199
+ throw new HarnessCapabilityUnsupportedError({
200
+ message:
201
+ "Harness 'codex' does not support built-in tool approval requests; use permissionMode: 'allow-all'.",
202
+ harnessId: 'codex',
203
+ });
204
+ }
205
+ const sandboxSession = startOpts.sandboxSession;
206
+ const session = sandboxSession.restricted();
207
+ const sandboxId = sandboxSession.id;
208
+ const lifecycleState = startOpts.continueFrom ?? startOpts.resumeFrom;
209
+ const isResume = lifecycleState != null;
210
+ const isContinue = startOpts.continueFrom != null;
211
+ const resumeData =
212
+ isResume && typeof lifecycleState?.data === 'object'
213
+ ? (lifecycleState.data as {
214
+ threadId?: unknown;
215
+ bridge?: CodexBridgeCoords;
216
+ })
217
+ : undefined;
218
+ const resumeThreadId = resumeData?.threadId;
219
+ const resumeThreadIdString =
220
+ typeof resumeThreadId === 'string' && resumeThreadId.length > 0
221
+ ? resumeThreadId
222
+ : undefined;
223
+ const coords = resumeData?.bridge;
224
+
225
+ const workDir = startOpts.sessionWorkDir;
226
+ const sessionDataDir = `${sandboxSession.defaultWorkingDirectory}/.agent-runs/${startOpts.sessionId}`;
227
+ const bridgeStateDir = `${sessionDataDir}/bridge`;
228
+ const timeoutMs = settings.startupTimeoutMs ?? 120_000;
229
+
230
+ // Normalize each forwarded bridge diagnostics frame into the general
231
+ // `HarnessV1Diagnostic` and report it. The adapter does no telemetry work
232
+ // beyond this transport→emission mapping.
233
+ const report = startOpts.observability?.report;
234
+ const onDiagnostic = report
235
+ ? (frame: Parameters<typeof harnessV1DiagnosticFromBridgeFrame>[0]) =>
236
+ report(
237
+ harnessV1DiagnosticFromBridgeFrame(frame, {
238
+ sessionId: startOpts.sessionId,
239
+ timestamp: Date.now(),
240
+ }),
241
+ )
242
+ : undefined;
243
+
244
+ /*
245
+ * Rung 1 — ATTACH. With live coordinates, reopen a socket to the
246
+ * still-running bridge. Parked between-turn sessions just attach and wait
247
+ * for the next `start`; suspended in-flight turns request replay of
248
+ * everything past the persisted cursor. No spawn, no fresh token. If the
249
+ * bridge is gone the open throws and we fall through to a spawn-based
250
+ * recovery.
251
+ */
252
+ if (coords) {
253
+ try {
254
+ const attachUrl =
255
+ (await sandboxSession.getPortUrl({
256
+ port: coords.port,
257
+ protocol: 'ws',
258
+ })) + `?agent_bridge_token=${encodeURIComponent(coords.token)}`;
259
+ const attachChannel: CodexChannel = new SandboxChannel({
260
+ connect: () => openWebSocket(attachUrl),
261
+ outboundSchema: outboundMessageSchema,
262
+ initialLastSeenEventId: coords.lastSeenEventId,
263
+ onDiagnostic,
264
+ });
265
+ await attachChannel.open(isContinue ? { resume: true } : undefined);
266
+ return createSession({
267
+ sessionId: startOpts.sessionId,
268
+ channel: attachChannel,
269
+ // The live bridge was spawned by another process; no process handle.
270
+ proc: undefined,
271
+ model: settings.model ?? DEFAULT_CODEX_MODEL,
272
+ reasoningEffort: settings.reasoningEffort,
273
+ webSearch: settings.webSearch,
274
+ resumeThreadId: resumeThreadIdString,
275
+ isResume: true,
276
+ seedResumeThreadOnFirstPrompt: false,
277
+ rerunContinue: false,
278
+ bridgePort: coords.port,
279
+ bridgeToken: coords.token,
280
+ sandboxId,
281
+ debug: startOpts.observability?.debug,
282
+ permissionMode: startOpts.permissionMode,
283
+ });
284
+ } catch {
285
+ // Bridge no longer reachable — recover by respawning below.
286
+ }
287
+ }
288
+
289
+ /*
290
+ * Rungs 2/3 — REPLAY vs RERUN. Respawn the bridge. `replay` is only sound
291
+ * for `continueFrom`: those coordinates include the cursor the on-disk
292
+ * log is replayed *from*. `resumeFrom` is a between-turn resume; even when
293
+ * it carries bridge coordinates, replaying the previous turn would
294
+ * re-deliver stale events into the next turn. Those resumes always `rerun`
295
+ * via `codex.resumeThread(threadId)` when attach is unavailable.
296
+ */
297
+ let respawnStrategy: CodexRespawnStrategy | undefined = isResume
298
+ ? 'rerun'
299
+ : undefined;
300
+ if (coords && isContinue) {
301
+ const logRaw = await Promise.resolve(
302
+ session.readTextFile({
303
+ path: `${bridgeStateDir}/event-log.ndjson`,
304
+ abortSignal: startOpts.abortSignal,
305
+ }),
306
+ ).catch(() => null);
307
+ if ((await classifyDiskLog(logRaw)) === 'replay') {
308
+ respawnStrategy = 'replay';
309
+ }
310
+ }
311
+
312
+ const port = resolveBridgePort(sandboxSession, settings.port);
313
+ const token = randomBytes(32).toString('hex');
314
+ const codexSkillSetup =
315
+ startOpts.skills && startOpts.skills.length > 0
316
+ ? await writeSkills({
317
+ sandbox: session,
318
+ skills: startOpts.skills,
319
+ abortSignal: startOpts.abortSignal,
320
+ })
321
+ : undefined;
322
+ const env = {
323
+ ...resolveCodexEnv(settings.auth),
324
+ BRIDGE_CHANNEL_TOKEN: token,
325
+ BRIDGE_WS_PORT: String(port),
326
+ ...(codexSkillSetup
327
+ ? {
328
+ HOME: codexSkillSetup.homeDir,
329
+ CODEX_HOME: codexSkillSetup.codexHomeDir,
330
+ }
331
+ : {}),
332
+ ...(respawnStrategy === 'replay'
333
+ ? { BRIDGE_REPLAY_FROM_DISK: '1' }
334
+ : {}),
335
+ };
336
+
337
+ if (respawnStrategy === undefined) {
338
+ await session.run({
339
+ command: `mkdir -p ${workDir} ${bridgeStateDir}`,
340
+ abortSignal: startOpts.abortSignal,
341
+ });
342
+ }
343
+
344
+ await markBridgeStarting({
345
+ sandbox: session,
346
+ bridgeStateDir,
347
+ bridgeType: 'codex',
348
+ abortSignal: startOpts.abortSignal,
349
+ });
350
+
351
+ const proc = await session.spawn({
352
+ command: `node ${BOOTSTRAP_DIR}/bridge.mjs --workdir ${workDir} --bridge-state-dir ${bridgeStateDir} --bootstrap-dir ${BOOTSTRAP_DIR}`,
353
+ env,
354
+ abortSignal: startOpts.abortSignal,
355
+ });
356
+
357
+ const { port: boundPort } = await waitForBridgeReady({
358
+ proc,
359
+ sandbox: session,
360
+ bridgeStateDir,
361
+ bridgeType: 'codex',
362
+ timeoutMs,
363
+ abortSignal: startOpts.abortSignal,
364
+ createTimeoutError: () =>
365
+ new Error('codex bridge did not become ready in time.'),
366
+ createExitError: () =>
367
+ new Error('codex bridge exited before becoming ready.'),
368
+ });
369
+ void drainRest(proc.stdout);
370
+ /*
371
+ * Bridge stderr is the only diagnostic channel for what happens
372
+ * inside the sandbox once the bridge is running (uncaught
373
+ * exceptions, Codex SDK errors, network failures). Forward it
374
+ * line-by-line to the host console so a mid-turn bridge crash can
375
+ * be inspected from `pnpm dev` logs without redeploying. The
376
+ * bridge itself writes nothing to stderr in steady state, so this
377
+ * is silent on the happy path.
378
+ */
379
+ void forwardBridgeStderr(proc.stderr);
380
+
381
+ const wsUrl =
382
+ (await sandboxSession.getPortUrl({
383
+ port: boundPort,
384
+ protocol: 'ws',
385
+ })) + `?agent_bridge_token=${encodeURIComponent(token)}`;
386
+
387
+ const channel: CodexChannel = new SandboxChannel({
388
+ connect: () => openWebSocket(wsUrl),
389
+ outboundSchema: outboundMessageSchema,
390
+ onDiagnostic,
391
+ // In replay mode the respawned bridge reloaded the finished turn from
392
+ // disk; seed the cursor and resume so it streams the tail (incl.
393
+ // `finish`).
394
+ ...(respawnStrategy === 'replay'
395
+ ? { initialLastSeenEventId: coords?.lastSeenEventId ?? 0 }
396
+ : {}),
397
+ });
398
+ await channel.open(
399
+ respawnStrategy === 'replay' ? { resume: true } : undefined,
400
+ );
401
+
402
+ return createSession({
403
+ sessionId: startOpts.sessionId,
404
+ channel,
405
+ proc,
406
+ model: settings.model ?? DEFAULT_CODEX_MODEL,
407
+ reasoningEffort: settings.reasoningEffort,
408
+ webSearch: settings.webSearch,
409
+ resumeThreadId: resumeThreadIdString,
410
+ isResume: respawnStrategy !== undefined,
411
+ seedResumeThreadOnFirstPrompt: respawnStrategy !== undefined,
412
+ rerunContinue: respawnStrategy === 'rerun',
413
+ bridgePort: boundPort,
414
+ bridgeToken: token,
415
+ sandboxId,
416
+ debug: startOpts.observability?.debug,
417
+ permissionMode: startOpts.permissionMode,
418
+ });
419
+ },
420
+ };
421
+ }
422
+
423
+ function resolveBridgePort(
424
+ sandboxSession: HarnessV1NetworkSandboxSession,
425
+ override: number | undefined,
426
+ ): number {
427
+ if (override !== undefined) return override;
428
+ if (sandboxSession.ports.length > 0) return sandboxSession.ports[0];
429
+ throw new HarnessCapabilityUnsupportedError({
430
+ harnessId: 'codex',
431
+ message:
432
+ 'The codex harness needs a TCP port exposed by the sandbox. ' +
433
+ 'Create the sandbox with `ports: [<port>]` or pass `createCodex({ port })`.',
434
+ });
435
+ }
436
+
437
+ async function readBridgeAsset(name: string): Promise<string> {
438
+ const candidates = [
439
+ new URL(`./bridge/${name}`, import.meta.url),
440
+ new URL(`../bridge/${name}`, import.meta.url),
441
+ ];
442
+ let lastErr: unknown;
443
+ for (const url of candidates) {
444
+ try {
445
+ return await readFile(fileURLToPath(url), 'utf8');
446
+ } catch (err) {
447
+ const code = (err as NodeJS.ErrnoException).code;
448
+ if (code !== 'ENOENT') throw err;
449
+ lastErr = err;
450
+ }
451
+ }
452
+ throw lastErr ?? new Error(`bridge asset not found: ${name}`);
453
+ }
454
+
455
+ async function writeSkills({
456
+ sandbox,
457
+ skills,
458
+ abortSignal,
459
+ }: {
460
+ sandbox: Experimental_SandboxSession;
461
+ skills: ReadonlyArray<HarnessV1Skill>;
462
+ abortSignal?: AbortSignal;
463
+ }): Promise<WriteSkillsResult> {
464
+ for (const skill of skills) {
465
+ safeCodexSkillName(skill.name);
466
+ for (const file of skill.files ?? []) {
467
+ safeCodexSkillFilePath({
468
+ skillName: skill.name,
469
+ filePath: file.path,
470
+ });
471
+ }
472
+ }
473
+
474
+ const homeDir = await resolveSandboxHomeDir({ sandbox, abortSignal });
475
+ const codexHomeDir = path.posix.join(homeDir, '.codex');
476
+ await sandbox.run({
477
+ command: `mkdir -p ${shellQuote(codexHomeDir)}`,
478
+ abortSignal,
479
+ });
480
+
481
+ const rootDir = path.posix.join(homeDir, '.agents', 'skills');
482
+ await sandbox.run({
483
+ command: `mkdir -p ${shellQuote(rootDir)}`,
484
+ abortSignal,
485
+ });
486
+
487
+ for (const skill of skills) {
488
+ const name = safeCodexSkillName(skill.name);
489
+ const skillDir = path.posix.join(rootDir, name);
490
+ const content = `---\nname: ${skill.name}\ndescription: ${skill.description}\n---\n\n${skill.content}`;
491
+
492
+ await sandbox.writeTextFile({
493
+ path: path.posix.join(skillDir, 'SKILL.md'),
494
+ content,
495
+ abortSignal,
496
+ });
497
+
498
+ for (const file of skill.files ?? []) {
499
+ const filePath = safeCodexSkillFilePath({
500
+ skillName: skill.name,
501
+ filePath: file.path,
502
+ });
503
+ await sandbox.writeTextFile({
504
+ path: path.posix.join(skillDir, filePath),
505
+ content: file.content,
506
+ abortSignal,
507
+ });
508
+ }
509
+ }
510
+
511
+ return {
512
+ homeDir,
513
+ codexHomeDir,
514
+ };
515
+ }
516
+
517
+ async function resolveSandboxHomeDir({
518
+ sandbox,
519
+ abortSignal,
520
+ }: {
521
+ sandbox: Experimental_SandboxSession;
522
+ abortSignal?: AbortSignal;
523
+ }): Promise<string> {
524
+ const result = await sandbox.run({
525
+ command: 'printf "%s" "$HOME"',
526
+ abortSignal,
527
+ });
528
+ const homeDir = result.stdout.trim();
529
+ if (result.exitCode !== 0 || !homeDir || !path.posix.isAbsolute(homeDir)) {
530
+ throw new Error(
531
+ `Unable to resolve sandbox HOME directory: ${result.stderr || result.stdout}`,
532
+ );
533
+ }
534
+ return homeDir;
535
+ }
536
+
537
+ function safeCodexSkillName(name: string): string {
538
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name === '.' || name === '..') {
539
+ throw new Error(`Invalid Codex skill name: ${name}`);
540
+ }
541
+ return name;
542
+ }
543
+
544
+ function safeCodexSkillFilePath({
545
+ skillName,
546
+ filePath,
547
+ }: {
548
+ skillName: string;
549
+ filePath: string;
550
+ }): string {
551
+ const normalized = path.posix.normalize(filePath);
552
+ if (
553
+ normalized === '.' ||
554
+ normalized.startsWith('../') ||
555
+ path.posix.isAbsolute(normalized)
556
+ ) {
557
+ throw new Error(
558
+ `Invalid Codex skill file path for ${skillName}: ${filePath}`,
559
+ );
560
+ }
561
+ return normalized;
562
+ }
563
+
564
+ function shellQuote(value: string): string {
565
+ return `'${value.replace(/'/g, `'\\''`)}'`;
566
+ }
567
+
568
+ async function forwardBridgeStderr(
569
+ stream: ReadableStream<Uint8Array>,
570
+ ): Promise<void> {
571
+ try {
572
+ const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
573
+ while (true) {
574
+ const { value, done } = await reader.read();
575
+ if (done) return;
576
+ if (value) {
577
+ const trimmed = value.endsWith('\n') ? value.slice(0, -1) : value;
578
+ if (trimmed.length > 0) {
579
+ // eslint-disable-next-line no-console
580
+ console.log(`[bridge stderr] ${trimmed}`);
581
+ }
582
+ }
583
+ }
584
+ } catch {
585
+ // Reader errors are non-fatal — best-effort diagnostic only.
586
+ }
587
+ }
588
+
589
+ async function drainRest(stream: ReadableStream<Uint8Array>): Promise<void> {
590
+ try {
591
+ const reader = stream.pipeThrough(new TextDecoderStream()).getReader();
592
+ while (true) {
593
+ const { done } = await reader.read();
594
+ if (done) return;
595
+ }
596
+ } catch {}
597
+ }
598
+
599
+ function openWebSocket(url: string): Promise<WebSocket> {
600
+ return new Promise((resolve, reject) => {
601
+ const ws = new WebSocket(url);
602
+ const onOpen = () => {
603
+ ws.off('error', onError);
604
+ resolve(ws);
605
+ };
606
+ const onError = (err: Error) => {
607
+ ws.off('open', onOpen);
608
+ reject(err);
609
+ };
610
+ ws.once('open', onOpen);
611
+ ws.once('error', onError);
612
+ });
613
+ }
614
+
615
+ function createSession({
616
+ sessionId,
617
+ channel,
618
+ proc,
619
+ model,
620
+ reasoningEffort,
621
+ webSearch,
622
+ resumeThreadId,
623
+ isResume,
624
+ seedResumeThreadOnFirstPrompt,
625
+ rerunContinue,
626
+ bridgePort,
627
+ bridgeToken,
628
+ sandboxId,
629
+ debug,
630
+ permissionMode,
631
+ }: {
632
+ sessionId: string;
633
+ channel: CodexChannel;
634
+ /** Undefined on `attach` — the live bridge was spawned by another process. */
635
+ proc: Experimental_SandboxProcess | undefined;
636
+ model: string | undefined;
637
+ reasoningEffort: 'low' | 'medium' | 'high' | undefined;
638
+ webSearch: boolean | undefined;
639
+ resumeThreadId: string | undefined;
640
+ isResume: boolean;
641
+ seedResumeThreadOnFirstPrompt: boolean;
642
+ rerunContinue: boolean;
643
+ bridgePort: number;
644
+ bridgeToken: string;
645
+ sandboxId: string;
646
+ debug: HarnessV1DebugConfig | undefined;
647
+ permissionMode: HarnessV1PermissionMode | undefined;
648
+ }): HarnessV1Session {
649
+ let stopped = false;
650
+ let stopPromise: Promise<void> | undefined;
651
+ /*
652
+ * Send the persisted threadId on the first prompt only when the bridge was
653
+ * respawned (rerun/replay) so it takes the `codex.resumeThread(...)` branch.
654
+ * An `attach`ed bridge already holds its threadState in memory and continues
655
+ * on its own, so it needs no seed.
656
+ */
657
+ let pendingResumeThreadId = seedResumeThreadOnFirstPrompt
658
+ ? resumeThreadId
659
+ : undefined;
660
+ /*
661
+ * Instructions are prepended to the first user message of a fresh session
662
+ * only. A resumed session (attach/replay/rerun) already carried them in its
663
+ * original first message (preserved in the persisted thread), so it starts
664
+ * "applied".
665
+ */
666
+ let instructionsApplied = isResume;
667
+
668
+ /*
669
+ * Latest codex thread id, cached from the bridge's `bridge-thread`
670
+ * announcements. Seeded from lifecycle state so `doDetach()` and `doStop()`
671
+ * can include a thread id even before this process has run a turn.
672
+ */
673
+ let latestThreadId = resumeThreadId;
674
+ channel.on('bridge-thread', msg => {
675
+ latestThreadId = msg.threadId;
676
+ });
677
+
678
+ /*
679
+ * Wire the channel into one turn's worth of events and return the control
680
+ * surface. Shared by `doPromptTurn` (which sends a `start` afterwards) and
681
+ * `doContinueTurn` (which attaches to an already-running/replayed turn, or sends
682
+ * a rerun `start`). The only difference between the two entry points is the
683
+ * `start` message, not the listener/abort/settle plumbing.
684
+ */
685
+ const wireTurn = (turnOpts: {
686
+ emit: (event: HarnessV1StreamPart) => void;
687
+ abortSignal?: AbortSignal;
688
+ }): HarnessV1PromptControl => {
689
+ let pendingResolve: (() => void) | undefined;
690
+ let pendingReject: ((err: unknown) => void) | undefined;
691
+ const done = new Promise<void>((resolve, reject) => {
692
+ pendingResolve = resolve;
693
+ pendingReject = reject;
694
+ });
695
+
696
+ const unsubs: Array<() => void> = [];
697
+ const forward = (event: HarnessV1StreamPart) => {
698
+ try {
699
+ turnOpts.emit(event);
700
+ } catch {}
701
+ };
702
+
703
+ const eventTypes = [
704
+ 'stream-start',
705
+ 'text-start',
706
+ 'text-delta',
707
+ 'text-end',
708
+ 'reasoning-start',
709
+ 'reasoning-delta',
710
+ 'reasoning-end',
711
+ 'tool-call',
712
+ 'tool-approval-request',
713
+ 'tool-result',
714
+ 'file-change',
715
+ 'finish-step',
716
+ 'raw',
717
+ ] as const;
718
+ let isSettled = false;
719
+ const settleSuccess = () => {
720
+ if (isSettled) return;
721
+ isSettled = true;
722
+ for (const u of unsubs) u();
723
+ pendingResolve!();
724
+ };
725
+ const settleError = (err: unknown) => {
726
+ if (isSettled) return;
727
+ isSettled = true;
728
+ for (const u of unsubs) u();
729
+ pendingReject!(err);
730
+ };
731
+
732
+ for (const type of eventTypes) {
733
+ unsubs.push(
734
+ channel.on(type, msg => {
735
+ forward(msg);
736
+ }),
737
+ );
738
+ }
739
+ unsubs.push(
740
+ channel.on('finish', msg => {
741
+ forward(msg);
742
+ settleSuccess();
743
+ }),
744
+ );
745
+ unsubs.push(
746
+ channel.on('error', msg => {
747
+ forward(msg);
748
+ settleError(msg.error);
749
+ }),
750
+ );
751
+
752
+ /*
753
+ * A `'suspended'` close is a graceful slice-boundary freeze the host
754
+ * initiated (`doSuspendTurn`): the turn keeps running in the bridge and its
755
+ * tail is replayed to the next process, so wind this turn down cleanly
756
+ * rather than failing it. Any other close mid-turn is an unexpected drop.
757
+ */
758
+ const onClose = (_code?: number, reason?: string) => {
759
+ if (isSettled) return;
760
+ if (reason === 'suspended') {
761
+ settleSuccess();
762
+ return;
763
+ }
764
+ settleError(new Error('codex bridge closed before the turn finished.'));
765
+ };
766
+ channel.onClose(onClose);
767
+
768
+ const onAbort = () => {
769
+ if (isSettled) return;
770
+ try {
771
+ channel.send({ type: 'abort' });
772
+ } catch {}
773
+ settleError(
774
+ turnOpts.abortSignal?.reason ??
775
+ new DOMException('Aborted', 'AbortError'),
776
+ );
777
+ };
778
+ if (turnOpts.abortSignal) {
779
+ if (turnOpts.abortSignal.aborted) {
780
+ onAbort();
781
+ } else {
782
+ turnOpts.abortSignal.addEventListener('abort', onAbort, {
783
+ once: true,
784
+ });
785
+ }
786
+ }
787
+
788
+ return {
789
+ submitToolResult: async input => {
790
+ channel.send({
791
+ type: 'tool-result',
792
+ toolCallId: input.toolCallId,
793
+ output: input.output,
794
+ isError: input.isError,
795
+ });
796
+ },
797
+ submitToolApproval: async input => {
798
+ channel.send({
799
+ type: 'tool-approval-response',
800
+ approvalId: input.approvalId,
801
+ approved: input.approved,
802
+ reason: input.reason,
803
+ });
804
+ },
805
+ submitUserMessage: async text => {
806
+ channel.send({ type: 'user-message', text });
807
+ },
808
+ done,
809
+ };
810
+ };
811
+
812
+ return {
813
+ sessionId,
814
+ isResume,
815
+ modelId: model,
816
+ doPromptTurn: async promptOpts => {
817
+ const control = wireTurn({
818
+ emit: promptOpts.emit,
819
+ abortSignal: promptOpts.abortSignal,
820
+ });
821
+
822
+ const applyInstructions =
823
+ !instructionsApplied && !!promptOpts.instructions;
824
+ instructionsApplied = true;
825
+
826
+ const startMessage = {
827
+ type: 'start' as const,
828
+ prompt: extractUserText(promptOpts.prompt),
829
+ ...(applyInstructions ? { instructions: promptOpts.instructions } : {}),
830
+ tools: (promptOpts.tools ?? []).map(t => ({
831
+ name: t.name,
832
+ description: t.description,
833
+ inputSchema: t.inputSchema,
834
+ })),
835
+ model,
836
+ reasoningEffort,
837
+ webSearch,
838
+ ...(permissionMode ? { permissionMode } : {}),
839
+ ...(pendingResumeThreadId
840
+ ? { resumeThreadId: pendingResumeThreadId }
841
+ : {}),
842
+ ...(debug ? { debug } : {}),
843
+ };
844
+ pendingResumeThreadId = undefined;
845
+ channel.send(startMessage);
846
+
847
+ return control;
848
+ },
849
+ doContinueTurn: async continueOpts => {
850
+ const control = wireTurn({
851
+ emit: continueOpts.emit,
852
+ abortSignal: continueOpts.abortSignal,
853
+ });
854
+
855
+ /*
856
+ * attach / replay: the still-running (or disk-replayed) turn streams into
857
+ * the wired listeners — `doStart` opened the channel with `{ resume: true }`
858
+ * so the bridge replays everything past the persisted cursor (including a
859
+ * `finish` if the turn ended during the gap). No `start` is sent: issuing
860
+ * one would clear the bridge's replay log and begin a new turn. Lossless.
861
+ *
862
+ * rerun: the bridge was respawned with no in-flight turn to attach to, so
863
+ * re-drive codex's own thread via `resumeThreadId`. Lossy — work in flight
864
+ * at the interruption is recomputed. This is the rare bridge-died
865
+ * fallback; the common slice path is `attach`.
866
+ */
867
+ if (rerunContinue) {
868
+ const threadId = pendingResumeThreadId ?? latestThreadId;
869
+ pendingResumeThreadId = undefined;
870
+ channel.send({
871
+ type: 'start' as const,
872
+ /*
873
+ * A continuation nudge rather than an empty prompt: `resumeThreadId`
874
+ * rehydrates the prior thread, and this is the new user turn that
875
+ * drives it forward. Keeping it non-empty avoids handing the runtime
876
+ * an empty user message (and mirrors the claude-code adapter, where an
877
+ * empty text block trips the Anthropic API's `cache_control` rule).
878
+ */
879
+ prompt: 'Continue.',
880
+ tools: (continueOpts.tools ?? []).map(t => ({
881
+ name: t.name,
882
+ description: t.description,
883
+ inputSchema: t.inputSchema,
884
+ })),
885
+ model,
886
+ reasoningEffort,
887
+ webSearch,
888
+ ...(permissionMode ? { permissionMode } : {}),
889
+ ...(threadId ? { resumeThreadId: threadId } : {}),
890
+ ...(debug ? { debug } : {}),
891
+ });
892
+ }
893
+
894
+ return control;
895
+ },
896
+ doCompact: async () => {
897
+ /*
898
+ * Codex compacts its context automatically inside the core turn loop
899
+ * (~90% of the model context window), but the `codex exec` transport this
900
+ * adapter drives exposes no manual compaction trigger and emits no
901
+ * compaction event. Manual `compact()` is therefore unsupported; Codex's
902
+ * own auto-compaction continues to run regardless.
903
+ */
904
+ throw new HarnessCapabilityUnsupportedError({
905
+ message:
906
+ "Harness 'codex' does not support manual compaction; Codex auto-compacts its context internally.",
907
+ harnessId: 'codex',
908
+ });
909
+ },
910
+ doDetach: async () => {
911
+ if (stopped) {
912
+ throw new Error(
913
+ `codex session ${sessionId} is already stopped; cannot detach.`,
914
+ );
915
+ }
916
+ stopped = true;
917
+ const lastSeenEventId = await channel.suspend();
918
+ const payload: HarnessV1ResumeSessionState = {
919
+ type: 'resume-session',
920
+ harnessId: 'codex',
921
+ specificationVersion: 'harness-v1',
922
+ data: {
923
+ ...(latestThreadId ? { threadId: latestThreadId } : {}),
924
+ bridge: {
925
+ port: bridgePort,
926
+ token: bridgeToken,
927
+ lastSeenEventId,
928
+ sandboxId,
929
+ },
930
+ },
931
+ };
932
+ return payload;
933
+ },
934
+ doDestroy: async () => {
935
+ if (stopped) return stopPromise;
936
+ stopped = true;
937
+ stopPromise = (async () => {
938
+ // Tell the channel we are tearing down so the bridge's post-shutdown
939
+ // socket close finalises instead of triggering a reconnect.
940
+ channel.beginClose();
941
+ try {
942
+ if (!channel.isClosed()) {
943
+ channel.send({ type: 'shutdown' });
944
+ }
945
+ } catch {}
946
+ let stopTimer: ReturnType<typeof setTimeout> | undefined;
947
+ try {
948
+ if (proc) {
949
+ await Promise.race([
950
+ proc.wait(),
951
+ new Promise<void>(resolve => {
952
+ stopTimer = setTimeout(resolve, 5000);
953
+ stopTimer.unref?.();
954
+ }),
955
+ ]);
956
+ }
957
+ } finally {
958
+ if (stopTimer) clearTimeout(stopTimer);
959
+ try {
960
+ await proc?.kill();
961
+ } catch {}
962
+ channel.close();
963
+ }
964
+ })();
965
+ return stopPromise;
966
+ },
967
+ doStop: async () => {
968
+ if (stopped) {
969
+ throw new Error(
970
+ `codex session ${sessionId} is already stopped; cannot stop.`,
971
+ );
972
+ }
973
+ stopped = true;
974
+ /*
975
+ * If the bridge's channel already closed (e.g. mid-turn WS drop)
976
+ * there is no one to ack a `detach` message. Synthesize an empty
977
+ * payload — the workdir is still captured by the sandbox snapshot
978
+ * during the subsequent `sandboxSession.stop()`, so the next turn can
979
+ * resume the filesystem state. The trade-off: we lose
980
+ * `threadId`, so the codex CLI starts a fresh thread on the
981
+ * preserved workdir rather than resuming the prior conversation
982
+ * inside Codex's runtime. Ability to continue beats throwing.
983
+ */
984
+ // Tell the channel we are tearing down so the bridge's post-detach
985
+ // socket close finalises instead of triggering a reconnect.
986
+ channel.beginClose();
987
+ const data: unknown = channel.isClosed()
988
+ ? {}
989
+ : await new Promise<unknown>((resolve, reject) => {
990
+ const timer = setTimeout(() => {
991
+ unsub();
992
+ reject(
993
+ new Error(
994
+ `codex session ${sessionId} did not reply to detach within 5s.`,
995
+ ),
996
+ );
997
+ }, 5000);
998
+ timer.unref?.();
999
+ const unsub = channel.on('bridge-detach', msg => {
1000
+ clearTimeout(timer);
1001
+ unsub();
1002
+ resolve(msg.data);
1003
+ });
1004
+ try {
1005
+ channel.send({ type: 'detach' });
1006
+ } catch (err) {
1007
+ clearTimeout(timer);
1008
+ unsub();
1009
+ reject(err);
1010
+ }
1011
+ });
1012
+
1013
+ let stopTimer: ReturnType<typeof setTimeout> | undefined;
1014
+ try {
1015
+ if (proc) {
1016
+ await Promise.race([
1017
+ proc.wait(),
1018
+ new Promise<void>(resolve => {
1019
+ stopTimer = setTimeout(resolve, 5000);
1020
+ stopTimer.unref?.();
1021
+ }),
1022
+ ]);
1023
+ }
1024
+ } finally {
1025
+ if (stopTimer) clearTimeout(stopTimer);
1026
+ try {
1027
+ await proc?.kill();
1028
+ } catch {}
1029
+ channel.close();
1030
+ }
1031
+
1032
+ const payload: HarnessV1ResumeSessionState = {
1033
+ type: 'resume-session',
1034
+ harnessId: 'codex',
1035
+ specificationVersion: 'harness-v1',
1036
+ data: (data ?? {}) as HarnessV1ResumeSessionState['data'],
1037
+ };
1038
+ return payload;
1039
+ },
1040
+ doSuspendTurn: async () => {
1041
+ if (stopped) {
1042
+ throw new Error(
1043
+ `codex session ${sessionId} is stopped; cannot suspend.`,
1044
+ );
1045
+ }
1046
+ stopped = true;
1047
+ /*
1048
+ * Gracefully freeze the active turn at a precise cursor. `channel.suspend`
1049
+ * stops processing inbound frames (the cursor stops advancing exactly at
1050
+ * the last delivered event), drains what was already dispatched, then
1051
+ * closes the host socket with reason `'suspended'` — which `wireTurn`'s
1052
+ * `onClose` treats as a clean turn end. The bridge keeps the turn running
1053
+ * and accumulates events past the cursor for the next slice to replay. The
1054
+ * sandbox process is deliberately left alive (no `shutdown`/`detach`).
1055
+ */
1056
+ const lastSeenEventId = await channel.suspend();
1057
+ const payload: HarnessV1ContinueTurnState = {
1058
+ type: 'continue-turn',
1059
+ harnessId: 'codex',
1060
+ specificationVersion: 'harness-v1',
1061
+ data: {
1062
+ ...(latestThreadId ? { threadId: latestThreadId } : {}),
1063
+ bridge: {
1064
+ port: bridgePort,
1065
+ token: bridgeToken,
1066
+ lastSeenEventId,
1067
+ sandboxId,
1068
+ },
1069
+ },
1070
+ };
1071
+ return payload;
1072
+ },
1073
+ };
1074
+ }
1075
+
1076
+ /*
1077
+ * Reduce a `HarnessV1Prompt` to the plain user text the bridge forwards
1078
+ * to the Codex SDK. File and image parts on the message are not yet
1079
+ * supported by the underlying runtime — throw rather than silently drop
1080
+ * them so callers learn about the gap instead of seeing mysteriously
1081
+ * truncated prompts.
1082
+ */
1083
+ function extractUserText(prompt: HarnessV1Prompt): string {
1084
+ if (typeof prompt === 'string') return prompt;
1085
+ const { content } = prompt;
1086
+ if (typeof content === 'string') return content;
1087
+ const parts: string[] = [];
1088
+ for (const part of content) {
1089
+ if (part.type !== 'text') {
1090
+ throw new HarnessCapabilityUnsupportedError({
1091
+ harnessId: 'codex',
1092
+ message: `The codex harness does not yet support user message parts of type '${part.type}'. Pass a string or a user message whose content contains only text parts.`,
1093
+ });
1094
+ }
1095
+ parts.push(part.text);
1096
+ }
1097
+ return parts.join('\n\n');
1098
+ }