@ai-sdk/harness 1.0.163 → 1.0.164

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness",
3
- "version": "1.0.163",
3
+ "version": "1.0.164",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -44,9 +44,9 @@
44
44
  }
45
45
  },
46
46
  "dependencies": {
47
- "@ai-sdk/provider": "4.0.33",
48
- "@ai-sdk/provider-utils": "5.0.67",
49
- "ai": "7.0.152"
47
+ "@ai-sdk/provider": "4.0.34",
48
+ "@ai-sdk/provider-utils": "5.0.68",
49
+ "ai": "7.0.153"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "ws": "^8.21.0",
@@ -58,7 +58,7 @@
58
58
  }
59
59
  },
60
60
  "devDependencies": {
61
- "@ai-sdk/otel": "1.0.152",
61
+ "@ai-sdk/otel": "1.0.153",
62
62
  "@opentelemetry/sdk-trace-base": "2.7.1",
63
63
  "@types/node": "22.19.19",
64
64
  "@types/ws": "^8.5.13",
@@ -0,0 +1,106 @@
1
+ import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
2
+ import { HarnessCapabilityUnsupportedError } from '../errors/harness-capability-unsupported-error';
3
+ import type {
4
+ HarnessV1MintBridgeTokenCallback,
5
+ HarnessV1NetworkSandboxSession,
6
+ HarnessV1PortEndpoint,
7
+ } from '../v1';
8
+ import { withBridgeToken } from './bridge-token';
9
+
10
+ /**
11
+ * Validates the bridge connection settings against the sandbox session before
12
+ * any bridge work starts.
13
+ *
14
+ * Basic sandbox sessions cannot expose ports, so they need both an explicit
15
+ * `port` and `portEndpoint` setting. A custom `mintBridgeToken` callback needs
16
+ * a sandbox id to mint the token for.
17
+ */
18
+ export function validateBridgeSandboxSettings({
19
+ sandboxSession,
20
+ sandboxId,
21
+ portOverride,
22
+ portEndpointOverride,
23
+ mintBridgeToken,
24
+ }: {
25
+ sandboxSession: HarnessV1NetworkSandboxSession | SandboxSession;
26
+ sandboxId: string | undefined;
27
+ portOverride: number | undefined;
28
+ portEndpointOverride: HarnessV1PortEndpoint | undefined;
29
+ mintBridgeToken: HarnessV1MintBridgeTokenCallback | undefined;
30
+ }): void {
31
+ if (!('getPortEndpoint' in sandboxSession)) {
32
+ if (portOverride == null) {
33
+ throw new HarnessCapabilityUnsupportedError({
34
+ message:
35
+ 'The harness requires an explicit `port` setting when using a basic sandbox session.',
36
+ });
37
+ }
38
+ if (portEndpointOverride == null) {
39
+ throw createMissingPortEndpointError();
40
+ }
41
+ }
42
+ if (mintBridgeToken != null && sandboxId == null) {
43
+ throw new HarnessCapabilityUnsupportedError({
44
+ message:
45
+ 'The harness cannot use `mintBridgeToken` with a sandbox session that does not expose an id.',
46
+ });
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Resolves the port the bridge listens on inside the sandbox: the `port`
52
+ * setting if present, otherwise the first port the sandbox exposes.
53
+ */
54
+ export function resolveBridgePort({
55
+ sandboxSession,
56
+ portOverride,
57
+ }: {
58
+ sandboxSession: HarnessV1NetworkSandboxSession | SandboxSession;
59
+ portOverride: number | undefined;
60
+ }): number {
61
+ if (portOverride !== undefined) return portOverride;
62
+ if ('ports' in sandboxSession && sandboxSession.ports.length > 0) {
63
+ return sandboxSession.ports[0];
64
+ }
65
+ throw new HarnessCapabilityUnsupportedError({
66
+ message:
67
+ 'The harness needs a TCP port exposed by the sandbox. ' +
68
+ 'Create the sandbox with `ports: [<port>]` or configure the harness `port` setting.',
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Resolves the host-reachable WebSocket endpoint for a bridge port and
74
+ * authenticates it with the bridge token.
75
+ *
76
+ * A `portEndpoint` setting takes precedence and is used as-is regardless of
77
+ * `port`, so it must point at the port the bridge actually listens on.
78
+ */
79
+ export async function resolveBridgeEndpoint({
80
+ sandboxSession,
81
+ port,
82
+ portEndpointOverride,
83
+ token,
84
+ }: {
85
+ sandboxSession: HarnessV1NetworkSandboxSession | SandboxSession;
86
+ port: number;
87
+ portEndpointOverride: HarnessV1PortEndpoint | undefined;
88
+ token: string;
89
+ }): Promise<HarnessV1PortEndpoint> {
90
+ let endpoint: HarnessV1PortEndpoint;
91
+ if (portEndpointOverride != null) {
92
+ endpoint = portEndpointOverride;
93
+ } else if ('getPortEndpoint' in sandboxSession) {
94
+ endpoint = await sandboxSession.getPortEndpoint({ port, protocol: 'ws' });
95
+ } else {
96
+ throw createMissingPortEndpointError();
97
+ }
98
+ return withBridgeToken({ endpoint, token });
99
+ }
100
+
101
+ function createMissingPortEndpointError(): HarnessCapabilityUnsupportedError {
102
+ return new HarnessCapabilityUnsupportedError({
103
+ message:
104
+ 'The harness requires an explicit `portEndpoint` setting when using a basic sandbox session.',
105
+ });
106
+ }
@@ -0,0 +1,286 @@
1
+ import { safeParseJSON } from '@ai-sdk/provider-utils';
2
+ import type { WebSocket } from 'ws';
3
+ import type { z } from 'zod/v4';
4
+ import { HarnessError } from '../errors/harness-error';
5
+ import type { HarnessV1PortEndpoint } from '../v1';
6
+ import { harnessV1BridgeHelloSchema } from '../v1/harness-v1-bridge-protocol';
7
+ import type { SandboxChannelConnectOptions } from './sandbox-channel';
8
+ import { sleep } from './sleep';
9
+
10
+ export type BridgeHello = z.infer<typeof harnessV1BridgeHelloSchema>;
11
+
12
+ export interface CreateBridgeWebSocketConnectOptions {
13
+ /** Bridge endpoint, already authenticated with the bridge token. */
14
+ endpoint: HarnessV1PortEndpoint;
15
+
16
+ /**
17
+ * `WebSocket` class from the `ws` package. Passed in by the adapter because
18
+ * `ws` is an optional peer dependency of `@ai-sdk/harness`.
19
+ */
20
+ webSocketConstructor: typeof WebSocket;
21
+
22
+ /**
23
+ * Retry budget in milliseconds for the channel's initial open (connect
24
+ * reason `'open'`). Omit for a single attempt. Reconnects always make a
25
+ * single attempt, because the channel retries them itself.
26
+ */
27
+ openRetryTimeoutMs?: number;
28
+
29
+ /**
30
+ * Abort signal that additionally cancels the channel's initial open, e.g.
31
+ * the abort signal of the harness start call. Not applied to reconnects.
32
+ */
33
+ startAbortSignal?: AbortSignal;
34
+
35
+ /** Called with the `bridge-hello` frame after every successful connect. */
36
+ onHello?: (hello: BridgeHello) => void;
37
+ }
38
+
39
+ const OPEN_TIMEOUT_MS = 10_000;
40
+ const HELLO_TIMEOUT_MS = 5_000;
41
+ const UNAUTHORIZED_CLOSE_CODE = 1008;
42
+
43
+ /**
44
+ * Creates the `connect` function for a bridge `SandboxChannel`.
45
+ *
46
+ * A connection only counts as established once the socket is open and the
47
+ * bridge has sent `bridge-hello`. Some sandbox runtimes complete the host-side
48
+ * WebSocket upgrade before the connection reaches the bridge process, and
49
+ * frames sent in that gap are dropped. The bridge sends `bridge-hello` the
50
+ * instant it accepts a connection, and sends nothing else until the host sends
51
+ * `start` or `resume`, so no frame is lost between the handshake and the
52
+ * channel wiring the socket.
53
+ */
54
+ export function createBridgeWebSocketConnect({
55
+ endpoint,
56
+ webSocketConstructor,
57
+ openRetryTimeoutMs,
58
+ startAbortSignal,
59
+ onHello,
60
+ }: CreateBridgeWebSocketConnectOptions): (
61
+ options: SandboxChannelConnectOptions,
62
+ ) => Promise<WebSocket> {
63
+ return async ({ abortSignal, reason }) => {
64
+ const isInitialOpen = reason === 'open';
65
+ const connectAbortSignal =
66
+ isInitialOpen && startAbortSignal != null
67
+ ? AbortSignal.any([abortSignal, startAbortSignal])
68
+ : abortSignal;
69
+
70
+ const { ws, hello } =
71
+ isInitialOpen && openRetryTimeoutMs != null
72
+ ? await openBridgeWebSocketWithRetry({
73
+ endpoint,
74
+ webSocketConstructor,
75
+ abortSignal: connectAbortSignal,
76
+ retryTimeoutMs: openRetryTimeoutMs,
77
+ })
78
+ : await openBridgeWebSocket({
79
+ endpoint,
80
+ webSocketConstructor,
81
+ abortSignal: connectAbortSignal,
82
+ openTimeoutMs: OPEN_TIMEOUT_MS,
83
+ getHelloTimeoutMs: () => HELLO_TIMEOUT_MS,
84
+ });
85
+ onHello?.(hello);
86
+ return ws;
87
+ };
88
+ }
89
+
90
+ class BridgeWebSocketUnauthorizedError extends Error {}
91
+
92
+ async function openBridgeWebSocketWithRetry({
93
+ endpoint,
94
+ webSocketConstructor,
95
+ abortSignal,
96
+ retryTimeoutMs,
97
+ }: {
98
+ endpoint: HarnessV1PortEndpoint;
99
+ webSocketConstructor: typeof WebSocket;
100
+ abortSignal: AbortSignal;
101
+ retryTimeoutMs: number;
102
+ }): Promise<{ ws: WebSocket; hello: BridgeHello }> {
103
+ const deadline = Date.now() + retryTimeoutMs;
104
+ let attempt = 0;
105
+ let lastError: unknown;
106
+
107
+ while (!abortSignal.aborted && Date.now() < deadline) {
108
+ attempt++;
109
+ try {
110
+ const remaining = Math.max(1, deadline - Date.now());
111
+ return await openBridgeWebSocket({
112
+ endpoint,
113
+ webSocketConstructor,
114
+ abortSignal,
115
+ openTimeoutMs: Math.min(OPEN_TIMEOUT_MS, remaining),
116
+ getHelloTimeoutMs: () =>
117
+ Math.min(HELLO_TIMEOUT_MS, Math.max(1, deadline - Date.now())),
118
+ });
119
+ } catch (error) {
120
+ if (abortSignal.aborted) throw getAbortReason({ abortSignal });
121
+ if (error instanceof BridgeWebSocketUnauthorizedError) throw error;
122
+ lastError = error;
123
+ const remaining = deadline - Date.now();
124
+ if (remaining <= 0) break;
125
+ await sleep({
126
+ ms: Math.min(250 * attempt, 1_000, remaining),
127
+ abortSignal,
128
+ });
129
+ }
130
+ }
131
+
132
+ if (abortSignal.aborted) throw getAbortReason({ abortSignal });
133
+
134
+ throw new HarnessError({
135
+ message: `The harness bridge did not complete the WebSocket handshake within ${retryTimeoutMs}ms after ${attempt} attempt(s).`,
136
+ cause: lastError,
137
+ });
138
+ }
139
+
140
+ function openBridgeWebSocket({
141
+ endpoint,
142
+ webSocketConstructor,
143
+ abortSignal,
144
+ openTimeoutMs,
145
+ getHelloTimeoutMs,
146
+ }: {
147
+ endpoint: HarnessV1PortEndpoint;
148
+ webSocketConstructor: typeof WebSocket;
149
+ abortSignal: AbortSignal;
150
+ openTimeoutMs: number;
151
+ getHelloTimeoutMs: () => number;
152
+ }): Promise<{ ws: WebSocket; hello: BridgeHello }> {
153
+ return new Promise((resolve, reject) => {
154
+ if (abortSignal.aborted) {
155
+ reject(getAbortReason({ abortSignal }));
156
+ return;
157
+ }
158
+
159
+ const ws = new webSocketConstructor(endpoint.url, {
160
+ headers: endpoint.headers == null ? undefined : { ...endpoint.headers },
161
+ });
162
+ let opened = false;
163
+ let hello: BridgeHello | undefined;
164
+ let settled = false;
165
+ let openTimer: ReturnType<typeof setTimeout> | undefined;
166
+ let helloTimer: ReturnType<typeof setTimeout> | undefined;
167
+
168
+ const cleanup = () => {
169
+ clearTimeout(openTimer);
170
+ clearTimeout(helloTimer);
171
+ ws.off('open', onOpen);
172
+ ws.off('message', onMessage);
173
+ ws.off('close', onClose);
174
+ ws.off('error', onError);
175
+ abortSignal.removeEventListener('abort', onAbort);
176
+ };
177
+ const fail = (error: unknown) => {
178
+ if (settled) return;
179
+ settled = true;
180
+ cleanup();
181
+ /*
182
+ * Terminating a socket that is still connecting makes `ws` emit an
183
+ * `error` event. Keep a no-op listener so that event cannot surface as
184
+ * an unhandled error and the original failure reason is preserved.
185
+ */
186
+ ws.on('error', () => {});
187
+ try {
188
+ ws.terminate();
189
+ } catch {
190
+ try {
191
+ ws.close();
192
+ } catch {
193
+ // best-effort
194
+ }
195
+ }
196
+ reject(error);
197
+ };
198
+ const tryResolve = () => {
199
+ if (settled || !opened || hello == null) return;
200
+ settled = true;
201
+ cleanup();
202
+ resolve({ ws, hello });
203
+ };
204
+ const onOpen = () => {
205
+ opened = true;
206
+ clearTimeout(openTimer);
207
+ const helloTimeoutMs = getHelloTimeoutMs();
208
+ helloTimer = setTimeout(
209
+ () =>
210
+ fail(
211
+ new Error(
212
+ `The harness bridge did not send bridge-hello within ${helloTimeoutMs}ms.`,
213
+ ),
214
+ ),
215
+ helloTimeoutMs,
216
+ );
217
+ unrefTimer(helloTimer);
218
+ tryResolve();
219
+ };
220
+ const onMessage = (raw: unknown) => {
221
+ void (async () => {
222
+ const parsed = await safeParseJSON({
223
+ text: webSocketMessageToString(raw),
224
+ schema: harnessV1BridgeHelloSchema,
225
+ });
226
+ if (!parsed.success || settled) return;
227
+ hello = parsed.value;
228
+ tryResolve();
229
+ })();
230
+ };
231
+ const onClose = (code: number) => {
232
+ fail(
233
+ code === UNAUTHORIZED_CLOSE_CODE
234
+ ? new BridgeWebSocketUnauthorizedError(
235
+ `The harness bridge rejected the WebSocket connection as unauthorized (code ${code}).`,
236
+ )
237
+ : new Error(
238
+ `The harness bridge closed the WebSocket (code ${code}) before sending bridge-hello.`,
239
+ ),
240
+ );
241
+ };
242
+ const onError = (error: Error) => fail(error);
243
+ const onAbort = () => fail(getAbortReason({ abortSignal }));
244
+
245
+ ws.on('open', onOpen);
246
+ ws.on('message', onMessage);
247
+ ws.on('close', onClose);
248
+ ws.on('error', onError);
249
+ abortSignal.addEventListener('abort', onAbort, { once: true });
250
+ openTimer = setTimeout(
251
+ () =>
252
+ fail(
253
+ new Error(
254
+ `The harness bridge WebSocket did not open within ${openTimeoutMs}ms.`,
255
+ ),
256
+ ),
257
+ openTimeoutMs,
258
+ );
259
+ unrefTimer(openTimer);
260
+ });
261
+ }
262
+
263
+ function getAbortReason({
264
+ abortSignal,
265
+ }: {
266
+ abortSignal: AbortSignal;
267
+ }): unknown {
268
+ return abortSignal.reason ?? new Error('WebSocket connection aborted');
269
+ }
270
+
271
+ function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
272
+ (timer as { unref?: () => void }).unref?.();
273
+ }
274
+
275
+ function webSocketMessageToString(raw: unknown): string {
276
+ if (typeof raw === 'string') return raw;
277
+ if (Buffer.isBuffer(raw)) return raw.toString('utf8');
278
+ if (Array.isArray(raw)) return Buffer.concat(raw).toString('utf8');
279
+ if (raw instanceof ArrayBuffer) return Buffer.from(raw).toString('utf8');
280
+ if (ArrayBuffer.isView(raw)) {
281
+ return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString(
282
+ 'utf8',
283
+ );
284
+ }
285
+ return String(raw);
286
+ }
@@ -58,7 +58,17 @@ export {
58
58
  type WaitForBridgeReadyOptions,
59
59
  type WaitForBridgeReadyResult,
60
60
  } from './bridge-ready';
61
- export { createBridgeToken, withBridgeToken } from './bridge-token';
61
+ export { createBridgeToken } from './bridge-token';
62
+ export {
63
+ resolveBridgeEndpoint,
64
+ resolveBridgePort,
65
+ validateBridgeSandboxSettings,
66
+ } from './bridge-endpoint';
67
+ export {
68
+ createBridgeWebSocketConnect,
69
+ type BridgeHello,
70
+ type CreateBridgeWebSocketConnectOptions,
71
+ } from './bridge-web-socket';
62
72
  export { createReadBridgeAsset } from './bridge-asset';
63
73
  export {
64
74
  createBridgeErrorHandler,
@@ -36,6 +36,13 @@ export interface SandboxChannelReconnectOptions {
36
36
 
37
37
  export interface SandboxChannelConnectOptions {
38
38
  readonly abortSignal: AbortSignal;
39
+
40
+ /**
41
+ * Why the channel is connecting: `'open'` for the initial
42
+ * {@link SandboxChannel.open} call, `'reconnect'` for an attempt to recover
43
+ * from a transient disconnect.
44
+ */
45
+ readonly reason: 'open' | 'reconnect';
39
46
  }
40
47
 
41
48
  export interface SandboxChannelOptions<TOut> {
@@ -296,6 +303,7 @@ export class SandboxChannel<
296
303
  try {
297
304
  const ws = await this.connect({
298
305
  abortSignal: abortController.signal,
306
+ reason: 'open',
299
307
  });
300
308
  this.wire(ws);
301
309
  this.ws = ws;
@@ -528,6 +536,7 @@ export class SandboxChannel<
528
536
  try {
529
537
  const ws = await this.connect({
530
538
  abortSignal: abortController.signal,
539
+ reason: 'reconnect',
531
540
  });
532
541
  if (this.terminal || this.closing) {
533
542
  closeWebSocket({ ws });
@@ -590,10 +599,11 @@ export class SandboxChannel<
590
599
 
591
600
  private connect({
592
601
  abortSignal,
602
+ reason,
593
603
  }: SandboxChannelConnectOptions): Promise<WebSocket> {
594
604
  return awaitWebSocketConnection({
595
605
  connection: Promise.resolve().then(() =>
596
- this.connectThunk({ abortSignal }),
606
+ this.connectThunk({ abortSignal, reason }),
597
607
  ),
598
608
  abortSignal,
599
609
  });