@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/CHANGELOG.md +18 -0
- package/dist/agent/index.d.ts +5 -5
- package/dist/agent/index.js +128 -49
- package/dist/agent/index.js.map +1 -1
- package/dist/bridge/index.js +0 -1
- package/dist/bridge/index.js.map +1 -1
- package/dist/index.d.ts +28 -6
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/index.d.ts +30 -5
- package/dist/utils/index.js +130 -16
- package/dist/utils/index.js.map +1 -1
- package/package.json +4 -4
- package/src/agent/harness-agent.ts +32 -12
- package/src/agent/internal/bootstrap-recipe.ts +16 -16
- package/src/agent/internal/harness-stream-text-result.ts +1 -3
- package/src/agent/internal/sandbox-bootstrap.ts +24 -10
- package/src/agent/prepare-harness-sandbox-template.ts +16 -3
- package/src/agent/prepare-sandbox-for-harness.ts +14 -5
- package/src/bridge/index.ts +0 -1
- package/src/utils/sandbox-channel.ts +172 -19
- package/src/utils/write-instructions.ts +1 -1
- package/src/utils/write-skills.ts +1 -1
- package/src/v1/harness-session-data-directory-path.ts +28 -0
- package/src/v1/harness-v1-bootstrap.ts +5 -5
- package/src/v1/harness-v1-network-sandbox-session.ts +24 -0
- package/src/v1/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/harness",
|
|
3
|
-
"version": "1.0.
|
|
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.
|
|
49
|
-
"ai": "7.0.
|
|
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.
|
|
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
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
507
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
72
|
+
stateDirectory,
|
|
70
73
|
}: {
|
|
71
74
|
recipe: HarnessV1Bootstrap;
|
|
72
75
|
identity: string;
|
|
73
|
-
|
|
76
|
+
stateDirectory: string;
|
|
74
77
|
}): string {
|
|
75
78
|
return posix.join(
|
|
76
79
|
resolveBootstrapPath({
|
|
77
80
|
path: recipe.bootstrapDir,
|
|
78
|
-
|
|
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
|
-
|
|
101
|
+
stateDirectory,
|
|
99
102
|
abortSignal,
|
|
100
103
|
}: {
|
|
101
104
|
session: SandboxSession;
|
|
102
105
|
recipe: HarnessV1Bootstrap;
|
|
103
106
|
identity: string;
|
|
104
|
-
|
|
107
|
+
stateDirectory: string;
|
|
105
108
|
abortSignal?: AbortSignal;
|
|
106
109
|
}): Promise<void> {
|
|
107
110
|
const markerPath = bootstrapMarkerPath({
|
|
108
111
|
recipe,
|
|
109
112
|
identity,
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
+
stateDirectory,
|
|
171
173
|
}: {
|
|
172
174
|
path: string;
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
68
|
+
session: restrictedSession,
|
|
63
69
|
recipe: bootstrapPlan.recipe,
|
|
64
70
|
identity: bootstrapPlan.recipeIdentity,
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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,
|
package/src/bridge/index.ts
CHANGED
|
@@ -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
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
|
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
|
-
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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 (
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
+
}
|