@ai-sdk/harness 1.0.122 → 1.0.123

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.122",
3
+ "version": "1.0.123",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -45,8 +45,8 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@ai-sdk/provider": "4.0.18",
48
- "@ai-sdk/provider-utils": "5.0.46",
49
- "ai": "7.0.112"
48
+ "@ai-sdk/provider-utils": "5.0.47",
49
+ "ai": "7.0.113"
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.112",
61
+ "@ai-sdk/otel": "1.0.113",
62
62
  "@opentelemetry/sdk-trace-base": "2.7.1",
63
63
  "@types/node": "22.19.19",
64
64
  "@types/ws": "^8.5.13",
@@ -1,10 +1,11 @@
1
1
  import { HarnessCapabilityUnsupportedError } from '../errors/harness-capability-unsupported-error';
2
- import type {
3
- HarnessV1,
4
- HarnessV1BuiltinToolFiltering,
5
- HarnessV1JSONSchema,
6
- HarnessV1NetworkSandboxSession,
7
- HarnessV1ResponseFormat,
2
+ import {
3
+ harnessStateDirectoryPath,
4
+ type HarnessV1,
5
+ type HarnessV1BuiltinToolFiltering,
6
+ type HarnessV1JSONSchema,
7
+ type HarnessV1NetworkSandboxSession,
8
+ type HarnessV1ResponseFormat,
8
9
  } from '../v1';
9
10
  import {
10
11
  asArray,
@@ -68,6 +69,7 @@ import {
68
69
  } from './internal/permission-mode';
69
70
  import { resolveHarnessAgentToolFiltering } from './internal/tool-filtering';
70
71
  import { resolveSandboxDefaultWorkingDirectory } from '../utils/resolve-sandbox-default-working-directory';
72
+ import { resolveSandboxHomeDir } from '../utils/sandbox-home-dir';
71
73
  import { getRestrictedSandboxSession } from '../utils/get-restricted-sandbox-session';
72
74
  import type { HarnessAgentLifecycleCallbacks } from './internal/turn-telemetry';
73
75
 
@@ -402,7 +404,14 @@ export class HarnessAgent<
402
404
  session: toolSafeSandboxSession,
403
405
  recipe,
404
406
  identity: recipeIdentity,
405
- defaultWorkingDirectory,
407
+ // Harness infrastructure always lives under the sandbox's own
408
+ // HOME, never the session's working directory.
409
+ stateDirectory: harnessStateDirectoryPath({
410
+ sandboxHomeDir: await resolveSandboxHomeDir({
411
+ sandbox: toolSafeSandboxSession,
412
+ abortSignal,
413
+ }),
414
+ }),
406
415
  abortSignal,
407
416
  });
408
417
  } catch (err) {
@@ -455,8 +464,14 @@ export class HarnessAgent<
455
464
  session: resumedSandboxSession.restricted(),
456
465
  recipe,
457
466
  identity: recipeIdentity,
458
- defaultWorkingDirectory:
459
- resumedSandboxSession.defaultWorkingDirectory,
467
+ // Harness infrastructure always lives under the sandbox's own
468
+ // HOME, never the working directory.
469
+ stateDirectory: harnessStateDirectoryPath({
470
+ sandboxHomeDir: await resolveSandboxHomeDir({
471
+ sandbox: resumedSandboxSession,
472
+ abortSignal,
473
+ }),
474
+ }),
460
475
  abortSignal,
461
476
  });
462
477
  } catch (err) {
@@ -503,8 +518,14 @@ export class HarnessAgent<
503
518
  session: createdSandboxSession.restricted(),
504
519
  recipe: sandboxBootstrapPlan.recipe,
505
520
  identity: sandboxBootstrapPlan.recipeIdentity,
506
- defaultWorkingDirectory:
507
- createdSandboxSession.defaultWorkingDirectory,
521
+ // Harness infrastructure always lives under the sandbox's own
522
+ // HOME, never the working directory.
523
+ stateDirectory: harnessStateDirectoryPath({
524
+ sandboxHomeDir: await resolveSandboxHomeDir({
525
+ sandbox: createdSandboxSession,
526
+ abortSignal,
527
+ }),
528
+ }),
508
529
  abortSignal,
509
530
  });
510
531
  } catch (err) {
@@ -877,7 +898,6 @@ export class HarnessAgent<
877
898
  };
878
899
  }
879
900
  }
880
- return undefined;
881
901
  }
882
902
 
883
903
  /*
@@ -1,6 +1,6 @@
1
1
  import { posix } from 'node:path';
2
2
  import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
3
- import type { HarnessV1Bootstrap } from '../../v1';
3
+ import { harnessStateDirectoryPath, type HarnessV1Bootstrap } from '../../v1';
4
4
 
5
5
  /**
6
6
  * Version of the bootstrap recipe shape itself. Bump to force every existing
@@ -10,7 +10,9 @@ export const BOOTSTRAP_SCHEMA_VERSION = 1;
10
10
 
11
11
  /**
12
12
  * Deterministic 16-char hex identity derived from the recipe's content
13
- * (harnessId, bootstrapDir, file paths + contents, commands, schema version).
13
+ * (harness state directory, harnessId, bootstrapDir, file paths + contents,
14
+ * commands, schema version). The sandbox's concrete HOME is unavailable until
15
+ * creation, so the directory is hashed with a symbolic HOME prefix.
14
16
  * Two adapters with equivalent recipes produce the same identity; any
15
17
  * content change produces a different identity.
16
18
  *
@@ -27,6 +29,7 @@ export async function hashHarnessBootstrap(
27
29
  chunks.push(encoder.encode('\0'));
28
30
  };
29
31
 
32
+ pushString(harnessStateDirectoryPath({ sandboxHomeDir: '$HOME' }));
30
33
  pushString(recipe.harnessId);
31
34
  pushString(recipe.bootstrapDir);
32
35
 
@@ -66,16 +69,16 @@ export async function hashHarnessBootstrap(
66
69
  export function bootstrapMarkerPath({
67
70
  recipe,
68
71
  identity,
69
- defaultWorkingDirectory,
72
+ stateDirectory,
70
73
  }: {
71
74
  recipe: HarnessV1Bootstrap;
72
75
  identity: string;
73
- defaultWorkingDirectory: string;
76
+ stateDirectory: string;
74
77
  }): string {
75
78
  return posix.join(
76
79
  resolveBootstrapPath({
77
80
  path: recipe.bootstrapDir,
78
- defaultWorkingDirectory,
81
+ stateDirectory,
79
82
  }),
80
83
  `.bootstrap-${identity}.ok`,
81
84
  );
@@ -95,19 +98,19 @@ export async function applyBootstrapRecipe({
95
98
  session,
96
99
  recipe,
97
100
  identity,
98
- defaultWorkingDirectory,
101
+ stateDirectory,
99
102
  abortSignal,
100
103
  }: {
101
104
  session: SandboxSession;
102
105
  recipe: HarnessV1Bootstrap;
103
106
  identity: string;
104
- defaultWorkingDirectory: string;
107
+ stateDirectory: string;
105
108
  abortSignal?: AbortSignal;
106
109
  }): Promise<void> {
107
110
  const markerPath = bootstrapMarkerPath({
108
111
  recipe,
109
112
  identity,
110
- defaultWorkingDirectory,
113
+ stateDirectory,
111
114
  });
112
115
 
113
116
  const existingMarker = await session.readTextFile({
@@ -120,11 +123,10 @@ export async function applyBootstrapRecipe({
120
123
 
121
124
  const bootstrapDir = resolveBootstrapPath({
122
125
  path: recipe.bootstrapDir,
123
- defaultWorkingDirectory,
126
+ stateDirectory,
124
127
  });
125
128
  const mkdirResult = await session.run({
126
129
  command: 'mkdir -p "$BOOTSTRAP_DIR"',
127
- workingDirectory: defaultWorkingDirectory,
128
130
  env: { BOOTSTRAP_DIR: bootstrapDir },
129
131
  abortSignal,
130
132
  });
@@ -138,7 +140,7 @@ export async function applyBootstrapRecipe({
138
140
  await session.writeTextFile({
139
141
  path: resolveBootstrapPath({
140
142
  path: file.path,
141
- defaultWorkingDirectory,
143
+ stateDirectory,
142
144
  }),
143
145
  content: file.content,
144
146
  abortSignal,
@@ -167,12 +169,10 @@ export async function applyBootstrapRecipe({
167
169
 
168
170
  function resolveBootstrapPath({
169
171
  path,
170
- defaultWorkingDirectory,
172
+ stateDirectory,
171
173
  }: {
172
174
  path: string;
173
- defaultWorkingDirectory: string;
175
+ stateDirectory: string;
174
176
  }): string {
175
- return posix.isAbsolute(path)
176
- ? path
177
- : posix.resolve(defaultWorkingDirectory, path);
177
+ return posix.isAbsolute(path) ? path : posix.resolve(stateDirectory, path);
178
178
  }
@@ -853,10 +853,8 @@ export class HarnessStreamTextResult<
853
853
  this.currentStepContent.push({
854
854
  ...(part as object),
855
855
  } as ContentPart<TOOLS>);
856
- return;
857
856
  default:
858
- // Boundary, raw, error, and finish parts are not ContentParts.
859
- return;
857
+ // Boundary, raw, error, and finish parts are not ContentParts.
860
858
  }
861
859
  }
862
860
  }
@@ -1,7 +1,9 @@
1
1
  import { posix } from 'node:path';
2
2
  import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
3
- import type { HarnessV1Bootstrap } from '../../v1';
3
+ import { harnessStateDirectoryPath, type HarnessV1Bootstrap } from '../../v1';
4
+ import { encodeHarnessPathSegment } from '../../v1/harness-session-data-directory-path';
4
5
  import { resolveSandboxDefaultWorkingDirectory } from '../../utils/resolve-sandbox-default-working-directory';
6
+ import { resolveSandboxHomeDir } from '../../utils/sandbox-home-dir';
5
7
  import type { HarnessAgentSandboxConfig } from '../harness-agent-settings';
6
8
  import { applyBootstrapRecipe, hashHarnessBootstrap } from './bootstrap-recipe';
7
9
 
@@ -78,7 +80,9 @@ export function resolveSessionWorkDir({
78
80
  }): string {
79
81
  return joinSandboxPath({
80
82
  base: defaultWorkingDirectory,
81
- path: workDir ?? `${harnessId}-${sessionId}`,
83
+ path:
84
+ workDir ??
85
+ `${encodeHarnessPathSegment(harnessId)}-${encodeHarnessPathSegment(sessionId)}`,
82
86
  });
83
87
  }
84
88
 
@@ -147,25 +151,35 @@ export async function runSandboxBootstrap({
147
151
  }): Promise<void> {
148
152
  if (recipe == null && onBootstrap == null) return;
149
153
 
150
- const resolvedDefaultWorkingDirectory =
151
- defaultWorkingDirectory ??
152
- (await resolveSandboxDefaultWorkingDirectory({
153
- sandboxSession: session,
154
- abortSignal,
155
- }));
156
-
157
154
  if (recipe != null && recipeIdentity != null) {
158
155
  await applyBootstrapRecipe({
159
156
  session,
160
157
  recipe,
161
158
  identity: recipeIdentity,
162
- defaultWorkingDirectory: resolvedDefaultWorkingDirectory,
159
+ // Harness infrastructure always lives under the sandbox's own HOME,
160
+ // never the working directory resolved below for the caller's own
161
+ // `onBootstrap` hook. Resolved directly from `session` — this runs
162
+ // from a provider's `onFirstCreate`, before a
163
+ // `HarnessV1NetworkSandboxSession` even exists.
164
+ stateDirectory: harnessStateDirectoryPath({
165
+ sandboxHomeDir: await resolveSandboxHomeDir({
166
+ sandbox: session,
167
+ abortSignal,
168
+ }),
169
+ }),
163
170
  abortSignal,
164
171
  });
165
172
  }
166
173
 
167
174
  if (onBootstrap == null) return;
168
175
 
176
+ const resolvedDefaultWorkingDirectory =
177
+ defaultWorkingDirectory ??
178
+ (await resolveSandboxDefaultWorkingDirectory({
179
+ sandboxSession: session,
180
+ abortSignal,
181
+ }));
182
+
169
183
  const bootstrapWorkDir =
170
184
  workDir == null
171
185
  ? resolvedDefaultWorkingDirectory
@@ -1,4 +1,9 @@
1
- import type { HarnessV1Bootstrap, HarnessV1SandboxProvider } from '../v1';
1
+ import {
2
+ harnessStateDirectoryPath,
3
+ type HarnessV1Bootstrap,
4
+ type HarnessV1SandboxProvider,
5
+ } from '../v1';
6
+ import { resolveSandboxHomeDir } from '../utils/sandbox-home-dir';
2
7
  import type { HarnessAgentAdapter } from './harness-agent-types';
3
8
  import type { HarnessAgentSandboxConfig } from './harness-agent-settings';
4
9
  import { applyBootstrapRecipe } from './internal/bootstrap-recipe';
@@ -58,11 +63,19 @@ export async function prepareHarnessSandboxTemplate(options: {
58
63
  // general harness utility, not for a concrete agent.
59
64
  try {
60
65
  if (bootstrapPlan.recipe != null && bootstrapPlan.recipeIdentity != null) {
66
+ const restrictedSession = sandboxSession.restricted();
61
67
  await applyBootstrapRecipe({
62
- session: sandboxSession.restricted(),
68
+ session: restrictedSession,
63
69
  recipe: bootstrapPlan.recipe,
64
70
  identity: bootstrapPlan.recipeIdentity,
65
- defaultWorkingDirectory: sandboxSession.defaultWorkingDirectory,
71
+ // Harness infrastructure always lives under the sandbox's own HOME,
72
+ // never the working directory.
73
+ stateDirectory: harnessStateDirectoryPath({
74
+ sandboxHomeDir: await resolveSandboxHomeDir({
75
+ sandbox: restrictedSession,
76
+ abortSignal,
77
+ }),
78
+ }),
66
79
  abortSignal,
67
80
  });
68
81
  }
@@ -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
+ }