@ai-sdk/harness-opencode 1.0.78 → 1.0.80

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.
@@ -21,12 +21,15 @@ import {
21
21
  } from '@ai-sdk/harness';
22
22
  import {
23
23
  classifyDiskLog,
24
+ experimental_createBridgeUserMessageSubmitter,
24
25
  createBridgeErrorHandler,
25
26
  createBridgeStartupError,
26
27
  drainBridgeProcessStream,
27
28
  forwardBridgeProcessStream,
29
+ getRestrictedSandboxSession,
28
30
  markBridgeStarting,
29
31
  maskSandboxCredentials,
32
+ resolveSandboxDefaultWorkingDirectory,
30
33
  resolveSandboxHomeDir,
31
34
  SandboxChannel,
32
35
  shellQuote,
@@ -35,9 +38,10 @@ import {
35
38
  writeSkills as writeHarnessSkills,
36
39
  } from '@ai-sdk/harness/utils';
37
40
  import {
41
+ safeParseJSON,
38
42
  tool,
39
43
  type Experimental_SandboxProcess,
40
- type Experimental_SandboxSession,
44
+ type Experimental_SandboxSession as SandboxSession,
41
45
  } from '@ai-sdk/provider-utils';
42
46
  import { WebSocket } from 'ws';
43
47
  import { z } from 'zod/v4';
@@ -88,6 +92,11 @@ export type OpenCodeHarnessSettings = {
88
92
  */
89
93
  readonly reasoningVariant?: string;
90
94
  readonly port?: number;
95
+ /**
96
+ * Override the host endpoint used to connect to the sandbox bridge. Required
97
+ * together with `port` when using a basic sandbox session.
98
+ */
99
+ readonly portEndpoint?: HarnessV1PortEndpoint;
91
100
  readonly startupTimeoutMs?: number;
92
101
  /**
93
102
  * Creates the authentication token used by the sandbox bridge. Defaults to
@@ -233,6 +242,26 @@ export function createOpenCode(
233
242
  getBootstrap: getOpenCodeBootstrap,
234
243
  doStart: async startOpts => {
235
244
  const sandboxSession = startOpts.sandboxSession;
245
+ const toolSafeSandboxSession =
246
+ getRestrictedSandboxSession(sandboxSession);
247
+ const sandboxId = 'id' in sandboxSession ? sandboxSession.id : undefined;
248
+ validateBasicSandboxSettings({
249
+ sandboxSession,
250
+ port: settings.port,
251
+ portEndpoint: settings.portEndpoint,
252
+ });
253
+ if (settings.mintBridgeToken != null && sandboxId == null) {
254
+ throw new HarnessCapabilityUnsupportedError({
255
+ harnessId: 'opencode',
256
+ message:
257
+ 'The OpenCode harness cannot use `mintBridgeToken` with a sandbox session that does not expose an id.',
258
+ });
259
+ }
260
+ const defaultWorkingDirectory =
261
+ await resolveSandboxDefaultWorkingDirectory({
262
+ sandboxSession,
263
+ abortSignal: startOpts.abortSignal,
264
+ });
236
265
  const authenticationMode = resolveOpenCodeAuthenticationMode({
237
266
  auth: settings.auth,
238
267
  model: settings.model,
@@ -244,7 +273,10 @@ export function createOpenCode(
244
273
  provider: settings.provider,
245
274
  });
246
275
  let sandboxAuthEnvironment = resolvedAuthEnvironment;
247
- if (sandboxSession.addRequestTransformations != null) {
276
+ if (
277
+ 'addRequestTransformations' in sandboxSession &&
278
+ sandboxSession.addRequestTransformations != null
279
+ ) {
248
280
  const requestTransformations = createOpenCodeRequestTransformations(
249
281
  resolvedAuthEnvironment,
250
282
  authenticationMode,
@@ -262,10 +294,8 @@ export function createOpenCode(
262
294
  } else {
263
295
  warnCredentialBrokeringUnavailable();
264
296
  }
265
- const session = sandboxSession.restricted();
266
- const sandboxId = sandboxSession.id;
267
297
  const bootstrapDir = path.posix.resolve(
268
- sandboxSession.defaultWorkingDirectory,
298
+ defaultWorkingDirectory,
269
299
  BOOTSTRAP_DIR,
270
300
  );
271
301
  const lifecycleState = startOpts.continueFrom ?? startOpts.resumeFrom;
@@ -286,7 +316,7 @@ export function createOpenCode(
286
316
  const coords = resumeData?.bridge;
287
317
 
288
318
  const workDir = startOpts.sessionWorkDir;
289
- const sessionDataDir = `${sandboxSession.defaultWorkingDirectory}/.agent-runs/${startOpts.sessionId}`;
319
+ const sessionDataDir = `${defaultWorkingDirectory}/.agent-runs/${startOpts.sessionId}`;
290
320
  const bridgeStateDir = `${sessionDataDir}/bridge`;
291
321
  const timeoutMs = settings.startupTimeoutMs ?? 120_000;
292
322
  const model = splitOpenCodeModel(settings.model, settings.provider).model;
@@ -308,16 +338,25 @@ export function createOpenCode(
308
338
 
309
339
  if (coords) {
310
340
  try {
311
- const endpoint = await sandboxSession.getPortEndpoint({
341
+ const endpoint = await resolveBridgeEndpoint({
342
+ sandboxSession,
343
+ override: settings.portEndpoint,
312
344
  port: coords.port,
313
- protocol: 'ws',
314
345
  });
315
346
  const attachEndpoint = withBridgeToken({
316
347
  endpoint,
317
348
  token: coords.token,
318
349
  });
350
+ let supportsUserMessageResponses = false;
319
351
  const attachChannel: OpenCodeChannel = new SandboxChannel({
320
- connect: () => openWebSocket(attachEndpoint),
352
+ connect: () =>
353
+ openWebSocket({
354
+ endpoint: attachEndpoint,
355
+ helloTimeoutMs: Math.min(timeoutMs, 5_000),
356
+ onHello: supported => {
357
+ supportsUserMessageResponses = supported;
358
+ },
359
+ }),
321
360
  outboundSchema: outboundMessageSchema,
322
361
  initialLastSeenEventId: coords.lastSeenEventId,
323
362
  onDiagnostic,
@@ -342,6 +381,7 @@ export function createOpenCode(
342
381
  debug: startOpts.observability?.debug,
343
382
  permissionMode: startOpts.permissionMode,
344
383
  builtinToolFiltering: startOpts.builtinToolFiltering,
384
+ supportsUserMessageResponses: () => supportsUserMessageResponses,
345
385
  });
346
386
  } catch {}
347
387
  }
@@ -351,7 +391,7 @@ export function createOpenCode(
351
391
  : undefined;
352
392
  if (coords && isContinue) {
353
393
  const logRaw = await Promise.resolve(
354
- session.readTextFile({
394
+ toolSafeSandboxSession.readTextFile({
355
395
  path: `${bridgeStateDir}/event-log.ndjson`,
356
396
  abortSignal: startOpts.abortSignal,
357
397
  }),
@@ -361,13 +401,16 @@ export function createOpenCode(
361
401
  }
362
402
  }
363
403
 
364
- const port = resolveBridgePort(sandboxSession, settings.port);
404
+ const port = resolveBridgePort({
405
+ sandboxSession,
406
+ override: settings.port,
407
+ });
365
408
  const token =
366
409
  settings.mintBridgeToken == null
367
410
  ? randomBytes(32).toString('hex')
368
- : settings.mintBridgeToken(sandboxId);
411
+ : settings.mintBridgeToken(sandboxId!);
369
412
  const sandboxHomeDir = await resolveSandboxHomeDir({
370
- sandbox: session,
413
+ sandbox: toolSafeSandboxSession,
371
414
  abortSignal: startOpts.abortSignal,
372
415
  });
373
416
  const xdgConfigHome = `${sandboxHomeDir}/.config`;
@@ -377,7 +420,7 @@ export function createOpenCode(
377
420
  const skillSetup =
378
421
  startOpts.skills && startOpts.skills.length > 0
379
422
  ? await writeOpenCodeSkills({
380
- sandbox: session,
423
+ sandbox: toolSafeSandboxSession,
381
424
  skills: startOpts.skills,
382
425
  homeDir: sandboxHomeDir,
383
426
  abortSignal: startOpts.abortSignal,
@@ -400,20 +443,20 @@ export function createOpenCode(
400
443
  };
401
444
 
402
445
  if (respawnStrategy === undefined) {
403
- await session.run({
446
+ await toolSafeSandboxSession.run({
404
447
  command: `mkdir -p ${shellQuote(workDir)} ${shellQuote(bridgeStateDir)} ${shellQuote(xdgConfigHome)} ${shellQuote(xdgCacheHome)} ${shellQuote(xdgDataHome)} ${shellQuote(xdgStateHome)}`,
405
448
  abortSignal: startOpts.abortSignal,
406
449
  });
407
450
  }
408
451
 
409
452
  await markBridgeStarting({
410
- sandbox: session,
453
+ sandbox: toolSafeSandboxSession,
411
454
  bridgeStateDir,
412
455
  bridgeType: 'opencode',
413
456
  abortSignal: startOpts.abortSignal,
414
457
  });
415
458
 
416
- const proc = await session.spawn({
459
+ const proc = await toolSafeSandboxSession.spawn({
417
460
  command: `node ${shellQuote(`${bootstrapDir}/bridge.mjs`)} --workdir ${shellQuote(workDir)} --bridge-state-dir ${shellQuote(bridgeStateDir)} --bootstrap-dir ${shellQuote(bootstrapDir)}${skillSetup ? ` --skills-dir ${shellQuote(skillSetup.skillsDir)}` : ''}`,
418
461
  env,
419
462
  abortSignal: startOpts.abortSignal,
@@ -428,7 +471,7 @@ export function createOpenCode(
428
471
 
429
472
  const { port: boundPort } = await waitForBridgeReady({
430
473
  proc,
431
- sandbox: session,
474
+ sandbox: toolSafeSandboxSession,
432
475
  bridgeStateDir,
433
476
  bridgeType: 'opencode',
434
477
  timeoutMs,
@@ -452,14 +495,23 @@ export function createOpenCode(
452
495
  });
453
496
  void drainBridgeProcessStream(proc.stdout);
454
497
 
455
- const endpoint = await sandboxSession.getPortEndpoint({
498
+ const endpoint = await resolveBridgeEndpoint({
499
+ sandboxSession,
500
+ override: settings.portEndpoint,
456
501
  port: boundPort,
457
- protocol: 'ws',
458
502
  });
459
503
  const bridgeEndpoint = withBridgeToken({ endpoint, token });
504
+ let supportsUserMessageResponses = false;
460
505
 
461
506
  const channel: OpenCodeChannel = new SandboxChannel({
462
- connect: () => openWebSocket(bridgeEndpoint),
507
+ connect: () =>
508
+ openWebSocket({
509
+ endpoint: bridgeEndpoint,
510
+ helloTimeoutMs: Math.min(timeoutMs, 5_000),
511
+ onHello: supported => {
512
+ supportsUserMessageResponses = supported;
513
+ },
514
+ }),
463
515
  outboundSchema: outboundMessageSchema,
464
516
  onDiagnostic,
465
517
  onBridgeError,
@@ -489,17 +541,23 @@ export function createOpenCode(
489
541
  debug: startOpts.observability?.debug,
490
542
  permissionMode: startOpts.permissionMode,
491
543
  builtinToolFiltering: startOpts.builtinToolFiltering,
544
+ supportsUserMessageResponses: () => supportsUserMessageResponses,
492
545
  });
493
546
  },
494
547
  };
495
548
  }
496
549
 
497
- function resolveBridgePort(
498
- sandboxSession: HarnessV1NetworkSandboxSession,
499
- override: number | undefined,
500
- ): number {
550
+ function resolveBridgePort({
551
+ sandboxSession,
552
+ override,
553
+ }: {
554
+ sandboxSession: HarnessV1NetworkSandboxSession | SandboxSession;
555
+ override: number | undefined;
556
+ }): number {
501
557
  if (override !== undefined) return override;
502
- if (sandboxSession.ports.length > 0) return sandboxSession.ports[0];
558
+ if ('ports' in sandboxSession && sandboxSession.ports.length > 0) {
559
+ return sandboxSession.ports[0];
560
+ }
503
561
  throw new HarnessCapabilityUnsupportedError({
504
562
  harnessId: 'opencode',
505
563
  message:
@@ -508,13 +566,59 @@ function resolveBridgePort(
508
566
  });
509
567
  }
510
568
 
569
+ function validateBasicSandboxSettings({
570
+ sandboxSession,
571
+ port,
572
+ portEndpoint,
573
+ }: {
574
+ sandboxSession: HarnessV1NetworkSandboxSession | SandboxSession;
575
+ port: number | undefined;
576
+ portEndpoint: HarnessV1PortEndpoint | undefined;
577
+ }): void {
578
+ if ('getPortEndpoint' in sandboxSession) return;
579
+ if (port == null) {
580
+ throw new HarnessCapabilityUnsupportedError({
581
+ harnessId: 'opencode',
582
+ message:
583
+ 'The OpenCode harness requires an explicit `port` when using a basic sandbox session.',
584
+ });
585
+ }
586
+ if (portEndpoint == null) {
587
+ throw new HarnessCapabilityUnsupportedError({
588
+ harnessId: 'opencode',
589
+ message:
590
+ 'The OpenCode harness requires an explicit `portEndpoint` when using a basic sandbox session.',
591
+ });
592
+ }
593
+ }
594
+
595
+ async function resolveBridgeEndpoint({
596
+ sandboxSession,
597
+ override,
598
+ port,
599
+ }: {
600
+ sandboxSession: HarnessV1NetworkSandboxSession | SandboxSession;
601
+ override: HarnessV1PortEndpoint | undefined;
602
+ port: number;
603
+ }): Promise<HarnessV1PortEndpoint> {
604
+ if (override != null) return override;
605
+ if ('getPortEndpoint' in sandboxSession) {
606
+ return sandboxSession.getPortEndpoint({ port, protocol: 'ws' });
607
+ }
608
+ throw new HarnessCapabilityUnsupportedError({
609
+ harnessId: 'opencode',
610
+ message:
611
+ 'The OpenCode harness requires an explicit `portEndpoint` when using a basic sandbox session.',
612
+ });
613
+ }
614
+
511
615
  async function writeOpenCodeSkills({
512
616
  sandbox,
513
617
  skills,
514
618
  homeDir,
515
619
  abortSignal,
516
620
  }: {
517
- sandbox: Experimental_SandboxSession;
621
+ sandbox: SandboxSession;
518
622
  skills: ReadonlyArray<HarnessV1Skill>;
519
623
  homeDir: string;
520
624
  abortSignal?: AbortSignal;
@@ -535,26 +639,105 @@ async function writeOpenCodeSkills({
535
639
  }
536
640
 
537
641
  function openWebSocket({
538
- url,
539
- headers,
540
- }: HarnessV1PortEndpoint): Promise<WebSocket> {
642
+ endpoint,
643
+ helloTimeoutMs,
644
+ onHello,
645
+ }: {
646
+ endpoint: HarnessV1PortEndpoint;
647
+ helloTimeoutMs: number;
648
+ onHello(supportsUserMessageResponses: boolean): void;
649
+ }): Promise<WebSocket> {
541
650
  return new Promise((resolve, reject) => {
542
- const ws = new WebSocket(url, {
543
- headers: headers == null ? undefined : { ...headers },
651
+ const ws = new WebSocket(endpoint.url, {
652
+ headers: endpoint.headers == null ? undefined : { ...endpoint.headers },
544
653
  });
545
- const onOpen = () => {
654
+ let opened = false;
655
+ let receivedHello = false;
656
+ let settled = false;
657
+ const cleanup = () => {
658
+ clearTimeout(helloTimer);
659
+ ws.off('open', onOpen);
660
+ ws.off('message', onMessage);
661
+ ws.off('close', onClose);
546
662
  ws.off('error', onError);
547
- resolve(ws);
548
663
  };
664
+ const settle = (error?: unknown) => {
665
+ if (settled) return;
666
+ settled = true;
667
+ cleanup();
668
+ if (error == null) {
669
+ resolve(ws);
670
+ } else {
671
+ reject(error);
672
+ }
673
+ };
674
+ const tryResolve = () => {
675
+ if (opened && receivedHello) settle();
676
+ };
677
+ const onOpen = () => {
678
+ opened = true;
679
+ tryResolve();
680
+ };
681
+ const onMessage = (raw: unknown) => {
682
+ void (async () => {
683
+ const parsed = await safeParseJSON({
684
+ text: webSocketMessageToString(raw),
685
+ });
686
+ if (!parsed.success) return;
687
+ const value = parsed.value;
688
+ if (
689
+ typeof value !== 'object' ||
690
+ value == null ||
691
+ Array.isArray(value) ||
692
+ (value as { type?: unknown }).type !== 'bridge-hello'
693
+ ) {
694
+ return;
695
+ }
696
+ const capabilities = (
697
+ value as {
698
+ capabilities?: { experimental_userMessageResponses?: unknown };
699
+ }
700
+ ).capabilities;
701
+ onHello(capabilities?.experimental_userMessageResponses === true);
702
+ receivedHello = true;
703
+ tryResolve();
704
+ })();
705
+ };
706
+ const onClose = () =>
707
+ settle(new Error('OpenCode bridge closed before sending bridge-hello.'));
549
708
  const onError = (err: Error) => {
550
- ws.off('open', onOpen);
551
- reject(err);
709
+ settle(err);
552
710
  };
553
- ws.once('open', onOpen);
554
- ws.once('error', onError);
711
+ ws.on('open', onOpen);
712
+ ws.on('message', onMessage);
713
+ ws.on('close', onClose);
714
+ ws.on('error', onError);
715
+ const helloTimer = setTimeout(
716
+ () =>
717
+ settle(
718
+ new Error(
719
+ `OpenCode bridge did not send bridge-hello within ${helloTimeoutMs}ms.`,
720
+ ),
721
+ ),
722
+ helloTimeoutMs,
723
+ );
724
+ helloTimer.unref?.();
555
725
  });
556
726
  }
557
727
 
728
+ function webSocketMessageToString(raw: unknown): string {
729
+ if (typeof raw === 'string') return raw;
730
+ if (Buffer.isBuffer(raw)) return raw.toString('utf8');
731
+ if (Array.isArray(raw)) return Buffer.concat(raw).toString('utf8');
732
+ if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString('utf8');
733
+ if (ArrayBuffer.isView(raw)) {
734
+ return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString(
735
+ 'utf8',
736
+ );
737
+ }
738
+ return String(raw);
739
+ }
740
+
558
741
  function withBridgeToken({
559
742
  endpoint,
560
743
  token,
@@ -585,6 +768,7 @@ function createSession({
585
768
  debug,
586
769
  permissionMode,
587
770
  builtinToolFiltering,
771
+ supportsUserMessageResponses,
588
772
  }: {
589
773
  sessionId: string;
590
774
  channel: OpenCodeChannel;
@@ -599,10 +783,11 @@ function createSession({
599
783
  rerunContinue: boolean;
600
784
  bridgePort: number;
601
785
  bridgeToken: string;
602
- sandboxId: string;
786
+ sandboxId: string | undefined;
603
787
  debug: HarnessV1DebugConfig | undefined;
604
788
  permissionMode: HarnessV1PermissionMode | undefined;
605
789
  builtinToolFiltering: HarnessV1BuiltinToolFiltering | undefined;
790
+ supportsUserMessageResponses: () => boolean;
606
791
  }): HarnessV1Session {
607
792
  let stopped = false;
608
793
  let stopPromise: Promise<void> | undefined;
@@ -616,7 +801,6 @@ function createSession({
616
801
  channel.on('bridge-thread', msg => {
617
802
  latestOpenCodeSessionId = msg.threadId;
618
803
  });
619
-
620
804
  const wireTurn = (turnOpts: {
621
805
  emit: (event: HarnessV1StreamPart) => void;
622
806
  abortSignal?: AbortSignal;
@@ -628,6 +812,13 @@ function createSession({
628
812
  pendingResolve = resolve;
629
813
  pendingReject = reject;
630
814
  });
815
+ const userMessageSubmitter = supportsUserMessageResponses()
816
+ ? experimental_createBridgeUserMessageSubmitter({
817
+ send: message => channel.send(message),
818
+ onResponse: listener => channel.on('user-message-response', listener),
819
+ onReconnect: listener => channel.onReconnect(listener),
820
+ })
821
+ : undefined;
631
822
 
632
823
  const unsubs: Array<() => void> = [];
633
824
  const forward = (event: HarnessV1StreamPart) => {
@@ -657,6 +848,7 @@ function createSession({
657
848
  if (isSettled) return;
658
849
  isSettled = true;
659
850
  activeTurn = false;
851
+ userMessageSubmitter?.close();
660
852
  for (const u of unsubs) u();
661
853
  pendingResolve!();
662
854
  };
@@ -664,6 +856,7 @@ function createSession({
664
856
  if (isSettled) return;
665
857
  isSettled = true;
666
858
  activeTurn = false;
859
+ userMessageSubmitter?.close(err);
667
860
  for (const u of unsubs) u();
668
861
  pendingReject!(err);
669
862
  };
@@ -741,9 +934,13 @@ function createSession({
741
934
  reason: input.reason,
742
935
  });
743
936
  },
744
- submitUserMessage: async text => {
745
- channel.send({ type: 'user-message', text });
746
- },
937
+ ...(userMessageSubmitter == null
938
+ ? {}
939
+ : {
940
+ submitUserMessage: async (text: string) => {
941
+ await userMessageSubmitter.submit(text);
942
+ },
943
+ }),
747
944
  done,
748
945
  };
749
946
  };
@@ -885,7 +1082,7 @@ function createSession({
885
1082
  port: bridgePort,
886
1083
  token: bridgeToken,
887
1084
  lastSeenEventId,
888
- sandboxId,
1085
+ ...(sandboxId == null ? {} : { sandboxId }),
889
1086
  },
890
1087
  },
891
1088
  };
@@ -1002,7 +1199,7 @@ function createSession({
1002
1199
  port: bridgePort,
1003
1200
  token: bridgeToken,
1004
1201
  lastSeenEventId,
1005
- sandboxId,
1202
+ ...(sandboxId == null ? {} : { sandboxId }),
1006
1203
  },
1007
1204
  },
1008
1205
  };