@ai-sdk/harness-codex 1.0.0-canary.2 → 1.0.0-canary.4

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.
@@ -20,15 +20,6 @@ export const startMessageSchema = harnessV1BridgeStartBaseSchema.extend({
20
20
  instructions: z.string().optional(),
21
21
  reasoningEffort: z.enum(['low', 'medium', 'high']).optional(),
22
22
  webSearch: z.boolean().optional(),
23
- skills: z
24
- .array(
25
- z.object({
26
- name: z.string(),
27
- description: z.string(),
28
- content: z.string(),
29
- }),
30
- )
31
- .optional(),
32
23
  // Resume signal. When supplied, the bridge calls
33
24
  // `codex.resumeThread(resumeThreadId, …)` instead of starting a fresh thread.
34
25
  // The host sources the id from lifecycle state `data` cached from a prior
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import {
5
6
  commonTool,
@@ -19,16 +20,20 @@ import {
19
20
  type HarnessV1Skill,
20
21
  type HarnessV1StreamPart,
21
22
  } from '@ai-sdk/harness';
22
- import { classifyDiskLog, SandboxChannel } from '@ai-sdk/harness/utils';
23
23
  import {
24
- safeParseJSON,
24
+ classifyDiskLog,
25
+ markBridgeStarting,
26
+ SandboxChannel,
27
+ waitForBridgeReady,
28
+ } from '@ai-sdk/harness/utils';
29
+ import {
25
30
  type Experimental_SandboxProcess,
31
+ type Experimental_SandboxSession,
26
32
  } from '@ai-sdk/provider-utils';
27
33
  import { WebSocket } from 'ws';
28
34
  import { z } from 'zod';
29
35
  import { resolveCodexEnv, type CodexAuthOptions } from './codex-auth';
30
36
  import {
31
- bridgeReadySchema,
32
37
  outboundMessageSchema,
33
38
  type InboundMessage,
34
39
  type OutboundMessage,
@@ -37,6 +42,11 @@ import {
37
42
  type CodexChannel = SandboxChannel<OutboundMessage, InboundMessage>;
38
43
  type CodexRespawnStrategy = 'replay' | 'rerun';
39
44
 
45
+ type WriteSkillsResult = {
46
+ readonly homeDir: string;
47
+ readonly codexHomeDir: string;
48
+ };
49
+
40
50
  /*
41
51
  * The model the adapter pins when the consumer configures none. The Codex SDK
42
52
  * does not report the model it resolves to at runtime (no model field on any
@@ -258,7 +268,6 @@ export function createCodex(
258
268
  channel: attachChannel,
259
269
  // The live bridge was spawned by another process; no process handle.
260
270
  proc: undefined,
261
- skills: startOpts.skills,
262
271
  model: settings.model ?? DEFAULT_CODEX_MODEL,
263
272
  reasoningEffort: settings.reasoningEffort,
264
273
  webSearch: settings.webSearch,
@@ -302,10 +311,24 @@ export function createCodex(
302
311
 
303
312
  const port = resolveBridgePort(sandboxSession, settings.port);
304
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;
305
322
  const env = {
306
323
  ...resolveCodexEnv(settings.auth),
307
324
  BRIDGE_CHANNEL_TOKEN: token,
308
325
  BRIDGE_WS_PORT: String(port),
326
+ ...(codexSkillSetup
327
+ ? {
328
+ HOME: codexSkillSetup.homeDir,
329
+ CODEX_HOME: codexSkillSetup.codexHomeDir,
330
+ }
331
+ : {}),
309
332
  ...(respawnStrategy === 'replay'
310
333
  ? { BRIDGE_REPLAY_FROM_DISK: '1' }
311
334
  : {}),
@@ -318,6 +341,13 @@ export function createCodex(
318
341
  });
319
342
  }
320
343
 
344
+ await markBridgeStarting({
345
+ sandbox: session,
346
+ bridgeStateDir,
347
+ bridgeType: 'codex',
348
+ abortSignal: startOpts.abortSignal,
349
+ });
350
+
321
351
  const proc = await session.spawn({
322
352
  command: `node ${BOOTSTRAP_DIR}/bridge.mjs --workdir ${workDir} --bridge-state-dir ${bridgeStateDir} --bootstrap-dir ${BOOTSTRAP_DIR}`,
323
353
  env,
@@ -326,9 +356,27 @@ export function createCodex(
326
356
 
327
357
  const { port: boundPort } = await waitForBridgeReady({
328
358
  proc,
359
+ sandbox: session,
360
+ bridgeStateDir,
361
+ bridgeType: 'codex',
329
362
  timeoutMs,
330
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.'),
331
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);
332
380
 
333
381
  const wsUrl =
334
382
  (await sandboxSession.getPortUrl({
@@ -355,7 +403,6 @@ export function createCodex(
355
403
  sessionId: startOpts.sessionId,
356
404
  channel,
357
405
  proc,
358
- skills: startOpts.skills,
359
406
  model: settings.model ?? DEFAULT_CODEX_MODEL,
360
407
  reasoningEffort: settings.reasoningEffort,
361
408
  webSearch: settings.webSearch,
@@ -405,86 +452,119 @@ async function readBridgeAsset(name: string): Promise<string> {
405
452
  throw lastErr ?? new Error(`bridge asset not found: ${name}`);
406
453
  }
407
454
 
408
- async function waitForBridgeReady({
409
- proc,
410
- timeoutMs,
455
+ async function writeSkills({
456
+ sandbox,
457
+ skills,
411
458
  abortSignal,
412
459
  }: {
413
- proc: Experimental_SandboxProcess;
414
- timeoutMs: number;
415
- abortSignal: AbortSignal | undefined;
416
- }): Promise<{ port: number }> {
417
- const reader = proc.stdout.pipeThrough(new TextDecoderStream()).getReader();
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
+ }
418
473
 
419
- const decoder = lineDecoder();
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
+ });
420
480
 
421
- const deadline = Date.now() + timeoutMs;
422
- try {
423
- while (true) {
424
- if (abortSignal?.aborted) {
425
- await proc.kill();
426
- throw abortSignal.reason ?? new DOMException('Aborted', 'AbortError');
427
- }
428
- const remaining = deadline - Date.now();
429
- if (remaining <= 0) {
430
- await proc.kill();
431
- throw new Error('codex bridge did not become ready in time.');
432
- }
433
- const { value, done } = (await Promise.race([
434
- reader.read(),
435
- new Promise(resolve =>
436
- setTimeout(
437
- () => resolve({ value: undefined, done: false }),
438
- remaining,
439
- ),
440
- ),
441
- ])) as ReadableStreamReadResult<string>;
442
- if (done) {
443
- throw new Error('codex bridge exited before becoming ready.');
444
- }
445
- if (value === undefined) continue;
446
- for (const line of decoder.push(value)) {
447
- const parsed = await safeParseJSON({
448
- text: line,
449
- schema: bridgeReadySchema,
450
- });
451
- if (parsed.success) return { port: parsed.value.port };
452
- }
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
+ });
453
508
  }
454
- } finally {
455
- reader.releaseLock();
456
- void drainRest(proc.stdout);
457
- /*
458
- * Bridge stderr is the only diagnostic channel for what happens
459
- * inside the sandbox once the bridge is running (uncaught
460
- * exceptions, Codex SDK errors, network failures). Forward it
461
- * line-by-line to the host console so a mid-turn bridge crash can
462
- * be inspected from `pnpm dev` logs without redeploying. The
463
- * bridge itself writes nothing to stderr in steady state, so this
464
- * is silent on the happy path.
465
- */
466
- void forwardBridgeStderr(proc.stderr);
467
509
  }
468
- }
469
510
 
470
- function lineDecoder() {
471
- let buffer = '';
472
511
  return {
473
- push(chunk: string): string[] {
474
- buffer += chunk;
475
- const lines: string[] = [];
476
- let nl: number;
477
- while ((nl = buffer.indexOf('\n')) !== -1) {
478
- const raw = buffer.slice(0, nl);
479
- buffer = buffer.slice(nl + 1);
480
- const line = raw.replace(/\r$/, '').trim();
481
- if (line.length > 0) lines.push(line);
482
- }
483
- return lines;
484
- },
512
+ homeDir,
513
+ codexHomeDir,
485
514
  };
486
515
  }
487
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
+
488
568
  async function forwardBridgeStderr(
489
569
  stream: ReadableStream<Uint8Array>,
490
570
  ): Promise<void> {
@@ -536,7 +616,6 @@ function createSession({
536
616
  sessionId,
537
617
  channel,
538
618
  proc,
539
- skills,
540
619
  model,
541
620
  reasoningEffort,
542
621
  webSearch,
@@ -554,7 +633,6 @@ function createSession({
554
633
  channel: CodexChannel;
555
634
  /** Undefined on `attach` — the live bridge was spawned by another process. */
556
635
  proc: Experimental_SandboxProcess | undefined;
557
- skills: ReadonlyArray<HarnessV1Skill> | undefined;
558
636
  model: string | undefined;
559
637
  reasoningEffort: 'low' | 'medium' | 'high' | undefined;
560
638
  webSearch: boolean | undefined;
@@ -758,15 +836,6 @@ function createSession({
758
836
  reasoningEffort,
759
837
  webSearch,
760
838
  ...(permissionMode ? { permissionMode } : {}),
761
- ...(skills && skills.length > 0
762
- ? {
763
- skills: skills.map(s => ({
764
- name: s.name,
765
- description: s.description,
766
- content: s.content,
767
- })),
768
- }
769
- : {}),
770
839
  ...(pendingResumeThreadId
771
840
  ? { resumeThreadId: pendingResumeThreadId }
772
841
  : {}),