@ai-sdk/harness 1.0.122 → 1.0.124

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.
@@ -1,7 +1,9 @@
1
1
  import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
2
+ import { harnessStateDirectoryPath } from '../v1';
2
3
  import type { HarnessAgentSandboxConfig } from './harness-agent-settings';
3
4
  import type { HarnessAgentAdapter } from './harness-agent-types';
4
5
  import { resolveSandboxDefaultWorkingDirectory } from '../utils/resolve-sandbox-default-working-directory';
6
+ import { resolveSandboxHomeDir } from '../utils/sandbox-home-dir';
5
7
  import {
6
8
  applyBootstrapRecipe,
7
9
  hashHarnessBootstrap,
@@ -67,7 +69,7 @@ export async function prepareSandboxForHarness(options: {
67
69
  : normalizeSandboxWorkDir(sandboxConfig.workDir);
68
70
  const recipeIdentities: Record<string, string> = {};
69
71
  const skippedHarnessIds: string[] = [];
70
- let defaultWorkingDirectory: string | undefined;
72
+ let stateDirectory: string | undefined;
71
73
 
72
74
  for (const harness of harnesses) {
73
75
  const recipe = await harness.getBootstrap?.({
@@ -80,20 +82,27 @@ export async function prepareSandboxForHarness(options: {
80
82
 
81
83
  const recipeIdentity = await hashHarnessBootstrap(recipe);
82
84
  recipeIdentities[harness.harnessId] = recipeIdentity;
83
- defaultWorkingDirectory ??= await resolveSandboxDefaultWorkingDirectory({
84
- sandboxSession: options.session,
85
- abortSignal: options.abortSignal,
85
+ // Harness infrastructure always lives under the sandbox's own HOME,
86
+ // never the working directory.
87
+ stateDirectory ??= harnessStateDirectoryPath({
88
+ sandboxHomeDir: await resolveSandboxHomeDir({
89
+ sandbox: options.session,
90
+ abortSignal: options.abortSignal,
91
+ }),
86
92
  });
87
93
  await applyBootstrapRecipe({
88
94
  session: options.session,
89
95
  recipe,
90
96
  identity: recipeIdentity,
91
- defaultWorkingDirectory,
97
+ stateDirectory,
92
98
  abortSignal: options.abortSignal,
93
99
  });
94
100
  }
95
101
 
96
102
  if (sandboxConfig.onBootstrap != null) {
103
+ const defaultWorkingDirectory = await resolveSandboxDefaultWorkingDirectory(
104
+ { sandboxSession: options.session, abortSignal: options.abortSignal },
105
+ );
97
106
  await runSandboxBootstrap({
98
107
  session: options.session,
99
108
  workDir,
@@ -934,7 +934,6 @@ export async function runBridge<TStart extends { type: 'start' }>(
934
934
  const data = (await onStop?.()) ?? {};
935
935
  sendControl(ws, { type: 'bridge-stop', data });
936
936
  drainThenExit(ws, 1000, 'stop');
937
- return;
938
937
  }
939
938
  }
940
939
  };
@@ -83,6 +83,11 @@ type Listener<TOut extends { type: string }, T extends EventTypeOf<TOut>> = (
83
83
  event: Extract<TOut, { type: T }>,
84
84
  ) => void;
85
85
 
86
+ type BufferedEvent<TOut extends { type: string }> = {
87
+ message: TOut;
88
+ listeners?: ReadonlyArray<Listener<TOut, EventTypeOf<TOut>>>;
89
+ };
90
+
86
91
  /*
87
92
  * The agent and utilities entrypoints bundle this module separately. A global
88
93
  * symbol lets the agent recognize metadata attached by the channel's bundle
@@ -172,11 +177,18 @@ async function awaitWebSocketConnection({
172
177
  /**
173
178
  * Host-side typed wrapper around the bridge WebSocket connection.
174
179
  *
175
- * Buffers inbound messages until a listener for their type is registered, so
176
- * callers that subscribe asynchronously do not miss early frames. Inbound
177
- * dispatch is serialised through a promise chain so a `close` event that
178
- * arrives on the same microtask as the final `finish` message does not fire
179
- * close handlers until the message has been dispatched.
180
+ * Buffers inbound messages in arrival order while listeners attach, so callers
181
+ * do not miss or reorder early frames. Listener registration drains the
182
+ * ordered prefix synchronously. Selective listeners are given through the
183
+ * current task (including its microtasks) to attach before unhandled event
184
+ * types are retained independently, so they cannot block subscribed event
185
+ * types indefinitely. A listener that claims an already-buffered event still
186
+ * receives it if it unsubscribes before that ordered drain completes. Use
187
+ * {@link SandboxChannel.beginListenerAttachment} to define an explicit
188
+ * attachment boundary that spans asynchronous work. Inbound dispatch is
189
+ * serialised through a promise chain so a `close` event that arrives on the
190
+ * same microtask as the final `finish` message does not fire close handlers
191
+ * until the message has been dispatched.
180
192
  *
181
193
  * Survives transient disconnects. The bridge keeps running and
182
194
  * accumulates events in an in-memory log keyed by a monotonic `seq`; on an
@@ -193,7 +205,12 @@ export class SandboxChannel<
193
205
  EventTypeOf<TOut>,
194
206
  Set<Listener<TOut, EventTypeOf<TOut>>>
195
207
  >();
196
- private readonly buffered = new Map<EventTypeOf<TOut>, TOut[]>();
208
+ private readonly buffered: BufferedEvent<TOut>[] = [];
209
+ private readonly bufferedByType = new Map<EventTypeOf<TOut>, TOut[]>();
210
+ private bufferedOffset = 0;
211
+ private flushingBuffered = false;
212
+ private bufferedFlushScheduled = false;
213
+ private listenerAttachmentDepth = 0;
197
214
  private readonly onCloseHandlers = new Set<
198
215
  (code: number, reason: string) => void
199
216
  >();
@@ -309,19 +326,46 @@ export class SandboxChannel<
309
326
  }
310
327
  set.add(listener as unknown as Listener<TOut, EventTypeOf<TOut>>);
311
328
 
312
- const buffered = this.buffered.get(type);
313
- if (buffered) {
314
- this.buffered.delete(type);
315
- for (const event of buffered) {
316
- listener(event as Extract<TOut, { type: T }>);
317
- }
329
+ this.captureBufferedListeners(type);
330
+ if (this.listenerAttachmentDepth > 0) {
331
+ return () => {
332
+ set!.delete(listener as unknown as Listener<TOut, EventTypeOf<TOut>>);
333
+ };
318
334
  }
319
335
 
336
+ this.flushBufferedType(type);
337
+ this.flushBuffered();
338
+ this.scheduleSelectiveBufferedFlush();
339
+
320
340
  return () => {
321
341
  set!.delete(listener as unknown as Listener<TOut, EventTypeOf<TOut>>);
322
342
  };
323
343
  }
324
344
 
345
+ /**
346
+ * Hold buffered event delivery while a consumer attaches a related set of
347
+ * listeners, including across asynchronous boundaries. Call the returned
348
+ * function once registration is complete. Buffered events are then replayed
349
+ * in arrival order; event types that remain unhandled are retained
350
+ * independently so they do not block subscribed types.
351
+ */
352
+ beginListenerAttachment(): () => void {
353
+ this.listenerAttachmentDepth++;
354
+ let finished = false;
355
+ return () => {
356
+ if (finished) return;
357
+ finished = true;
358
+ this.listenerAttachmentDepth--;
359
+ if (this.listenerAttachmentDepth > 0) return;
360
+
361
+ for (const type of this.listeners.keys()) {
362
+ this.flushBufferedType(type);
363
+ }
364
+ this.flushBuffered();
365
+ this.scheduleSelectiveBufferedFlush();
366
+ };
367
+ }
368
+
325
369
  onClose(handler: (code: number, reason: string) => void): void {
326
370
  this.onCloseHandlers.add(handler);
327
371
  }
@@ -645,13 +689,18 @@ export class SandboxChannel<
645
689
  }
646
690
  const type = message.type as EventTypeOf<TOut>;
647
691
  const set = this.listeners.get(type);
648
- if (!set || set.size === 0) {
649
- let bucket = this.buffered.get(type);
650
- if (!bucket) {
651
- bucket = [];
652
- this.buffered.set(type, bucket);
653
- }
654
- bucket.push(message);
692
+ if (
693
+ this.listenerAttachmentDepth > 0 ||
694
+ this.bufferedOffset < this.buffered.length ||
695
+ !set ||
696
+ set.size === 0
697
+ ) {
698
+ this.buffered.push({
699
+ message,
700
+ ...(set != null && set.size > 0 ? { listeners: Array.from(set) } : {}),
701
+ });
702
+ this.flushBuffered();
703
+ this.scheduleSelectiveBufferedFlush();
655
704
  return;
656
705
  }
657
706
  for (const listener of set) {
@@ -659,6 +708,110 @@ export class SandboxChannel<
659
708
  }
660
709
  }
661
710
 
711
+ private flushBuffered({
712
+ selective = false,
713
+ }: { selective?: boolean } = {}): void {
714
+ if (this.flushingBuffered || this.listenerAttachmentDepth > 0) return;
715
+ this.flushingBuffered = true;
716
+ try {
717
+ while (this.bufferedOffset < this.buffered.length) {
718
+ const bufferedEvent = this.buffered[this.bufferedOffset];
719
+ const message = bufferedEvent.message;
720
+ const type = message.type as EventTypeOf<TOut>;
721
+ const set = this.listeners.get(type);
722
+ const listeners =
723
+ bufferedEvent.listeners ??
724
+ (set != null && set.size > 0 ? Array.from(set) : undefined);
725
+ if (!listeners || listeners.length === 0) {
726
+ if (!selective) return;
727
+ this.bufferedOffset++;
728
+ const buffered = this.bufferedByType.get(type);
729
+ if (buffered) {
730
+ buffered.push(message);
731
+ } else {
732
+ this.bufferedByType.set(type, [message]);
733
+ }
734
+ continue;
735
+ }
736
+
737
+ this.bufferedOffset++;
738
+ for (const listener of listeners) {
739
+ listener(message as Extract<TOut, { type: EventTypeOf<TOut> }>);
740
+ }
741
+ }
742
+ } finally {
743
+ if (this.bufferedOffset > 0) {
744
+ this.buffered.splice(0, this.bufferedOffset);
745
+ this.bufferedOffset = 0;
746
+ }
747
+ this.flushingBuffered = false;
748
+ }
749
+ }
750
+
751
+ private captureBufferedListeners(type: EventTypeOf<TOut>): void {
752
+ const set = this.listeners.get(type);
753
+ if (!set || set.size === 0) return;
754
+
755
+ for (let i = this.bufferedOffset; i < this.buffered.length; i++) {
756
+ const bufferedEvent = this.buffered[i];
757
+ if (
758
+ bufferedEvent.listeners == null &&
759
+ bufferedEvent.message.type === type
760
+ ) {
761
+ bufferedEvent.listeners = Array.from(set);
762
+ }
763
+ }
764
+ }
765
+
766
+ private flushBufferedType(type: EventTypeOf<TOut>): void {
767
+ const buffered = this.bufferedByType.get(type);
768
+ if (!buffered) return;
769
+
770
+ let offset = 0;
771
+ try {
772
+ while (offset < buffered.length) {
773
+ const set = this.listeners.get(type);
774
+ if (!set || set.size === 0) return;
775
+
776
+ const message = buffered[offset++];
777
+ for (const listener of set) {
778
+ listener(message as Extract<TOut, { type: EventTypeOf<TOut> }>);
779
+ }
780
+ }
781
+ } finally {
782
+ if (offset === buffered.length) {
783
+ this.bufferedByType.delete(type);
784
+ } else if (offset > 0) {
785
+ buffered.splice(0, offset);
786
+ }
787
+ }
788
+ }
789
+
790
+ private scheduleSelectiveBufferedFlush(): void {
791
+ if (
792
+ this.bufferedFlushScheduled ||
793
+ this.bufferedOffset >= this.buffered.length ||
794
+ !this.hasListeners() ||
795
+ this.listenerAttachmentDepth > 0
796
+ ) {
797
+ return;
798
+ }
799
+
800
+ this.bufferedFlushScheduled = true;
801
+ setTimeout(() => {
802
+ this.bufferedFlushScheduled = false;
803
+ if (this.listenerAttachmentDepth > 0) return;
804
+ this.flushBuffered({ selective: true });
805
+ }, 0);
806
+ }
807
+
808
+ private hasListeners(): boolean {
809
+ for (const listeners of this.listeners.values()) {
810
+ if (listeners.size > 0) return true;
811
+ }
812
+ return false;
813
+ }
814
+
662
815
  private attachEventCheckpoint(options: {
663
816
  event: TOut;
664
817
  eventId: number;
@@ -207,7 +207,7 @@ function resolveInstructionsFilePath({
207
207
  );
208
208
  }
209
209
  const containsTraversal = instructionsFile
210
- .split(/[\\\/]/)
210
+ .split(/[\\/]/)
211
211
  .some(segment => segment === '..');
212
212
  const normalizedInstructionsFile = path.posix.normalize(
213
213
  instructionsFile.trim(),
@@ -551,7 +551,7 @@ function resolveSkillsRootDir({
551
551
  );
552
552
  }
553
553
  const containsTraversal = skillsDir
554
- .split(/[\\\/]/)
554
+ .split(/[\\/]/)
555
555
  .some(segment => segment === '..');
556
556
  const normalizedSkillsDir = path.posix.normalize(skillsDir.trim());
557
557
  const normalizedNoTrailingSlash = normalizedSkillsDir.replace(/\/+$/, '');
@@ -0,0 +1,28 @@
1
+ import { posix } from 'node:path';
2
+
3
+ export function encodeHarnessPathSegment(value: string): string {
4
+ const encoded = encodeURIComponent(value);
5
+ if (encoded === '') return '%';
6
+ if (encoded === '.') return '%2E';
7
+ if (encoded === '..') return '%2E%2E';
8
+ return encoded;
9
+ }
10
+
11
+ /**
12
+ * Return the per-session harness state path under `.agent-runs`. Session IDs
13
+ * occupy one encoded path segment, so path separators and parent-directory
14
+ * references cannot move session data outside the harness state directory.
15
+ */
16
+ export function harnessSessionDataDirectoryPath({
17
+ stateDirectory,
18
+ sessionId,
19
+ }: {
20
+ stateDirectory: string;
21
+ sessionId: string;
22
+ }): string {
23
+ return posix.join(
24
+ stateDirectory,
25
+ '.agent-runs',
26
+ encodeHarnessPathSegment(sessionId),
27
+ );
28
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * One file to write into the sandbox as part of an adapter's bootstrap recipe.
3
3
  * Absolute paths are used as-is. Relative paths are resolved against the
4
- * sandbox's default working directory. Paths should live under
4
+ * harness state directory (`$HOME/.ai-sdk-harness`). Paths should live under
5
5
  * {@link HarnessV1Bootstrap.bootstrapDir}.
6
6
  */
7
7
  export interface HarnessV1BootstrapFile {
@@ -33,10 +33,10 @@ export interface HarnessV1Bootstrap {
33
33
 
34
34
  /**
35
35
  * Path inside the sandbox where this recipe writes its state. Absolute paths
36
- * are used as-is. Relative paths are resolved against the sandbox's default
37
- * working directory. The marker file lives directly under it. Files
38
- * declared in {@link files} should also use this prefix so an adapter upgrade
39
- * can sweep stale state by clearing the directory.
36
+ * are used as-is. Relative paths are resolved against the harness state
37
+ * directory (`$HOME/.ai-sdk-harness`). The marker file lives directly under
38
+ * it. Files declared in {@link files} should also use this prefix so an
39
+ * adapter upgrade can sweep stale state by clearing the directory.
40
40
  */
41
41
  readonly bootstrapDir: string;
42
42
 
@@ -1,3 +1,4 @@
1
+ import { posix } from 'node:path';
1
2
  import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
2
3
 
3
4
  /**
@@ -9,6 +10,29 @@ export type HarnessV1PortEndpoint = {
9
10
  readonly headers?: Readonly<Record<string, string>>;
10
11
  };
11
12
 
13
+ /**
14
+ * Fixed directory, relative to the sandbox's own HOME, that holds every
15
+ * piece of state the harness machinery generates.
16
+ */
17
+ const HARNESS_V1_STATE_DIRECTORY_NAME = '.ai-sdk-harness';
18
+
19
+ /**
20
+ * Fixed, non-configurable path for harness-generated state (bootstrap files,
21
+ * markers, and `.agent-runs`) under the sandbox's own HOME, never under
22
+ * {@link HarnessV1NetworkSandboxSession.defaultWorkingDirectory}. Resolve HOME
23
+ * with `resolveSandboxHomeDir` when it is not already known. This works from
24
+ * provider creation hooks that only have a plain sandbox session. The
25
+ * framework also calls this with a symbolic `$HOME` when hashing bootstrap
26
+ * recipes, so changes to the state path invalidate existing templates.
27
+ */
28
+ export function harnessStateDirectoryPath({
29
+ sandboxHomeDir,
30
+ }: {
31
+ sandboxHomeDir: string;
32
+ }): string {
33
+ return posix.join(sandboxHomeDir, HARNESS_V1_STATE_DIRECTORY_NAME);
34
+ }
35
+
12
36
  /**
13
37
  * Network sandbox session returned by `HarnessV1SandboxProvider.createSession()`. The
14
38
  * harness keeps this for the lifetime of a session. It is itself a
package/src/v1/index.ts CHANGED
@@ -63,6 +63,8 @@ export type {
63
63
  HarnessV1RequestTransformation,
64
64
  HarnessV1RequestTransformationSources,
65
65
  } from './harness-v1-network-sandbox-session';
66
+ export { harnessStateDirectoryPath } from './harness-v1-network-sandbox-session';
67
+ export { harnessSessionDataDirectoryPath } from './harness-session-data-directory-path';
66
68
  export type { HarnessV1Skill } from './harness-v1-skill';
67
69
  export type { HarnessV1StreamPart } from './harness-v1-stream-part';
68
70
  export {