@ai-sdk/harness 1.0.0-canary.8 → 1.0.0
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 +150 -0
- package/README.md +27 -15
- package/dist/agent/index.d.ts +45 -12
- package/dist/agent/index.js +258 -31
- package/dist/agent/index.js.map +1 -1
- package/dist/bridge/index.js +3 -2
- package/dist/bridge/index.js.map +1 -1
- package/dist/utils/index.d.ts +8 -1
- package/dist/utils/index.js +13 -0
- package/dist/utils/index.js.map +1 -1
- package/package.json +6 -6
- package/src/agent/harness-agent-settings.ts +47 -12
- package/src/agent/harness-agent.ts +70 -35
- package/src/agent/internal/bootstrap-recipe.ts +1 -1
- package/src/agent/internal/harness-stream-text-result.ts +12 -1
- package/src/agent/internal/run-prompt.ts +13 -1
- package/src/agent/internal/sandbox-bootstrap.ts +266 -0
- package/src/agent/prewarm.ts +29 -11
- package/src/bridge/index.ts +4 -2
- package/src/utils/ai-gateway-auth.ts +15 -0
- package/src/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-sdk/harness",
|
|
3
|
-
"version": "1.0.0
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -44,12 +44,12 @@
|
|
|
44
44
|
}
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@ai-sdk/provider": "4.0.0
|
|
48
|
-
"@ai-sdk/provider-utils": "5.0.0
|
|
49
|
-
"ai": "7.0.0
|
|
47
|
+
"@ai-sdk/provider": "4.0.0",
|
|
48
|
+
"@ai-sdk/provider-utils": "5.0.0",
|
|
49
|
+
"ai": "7.0.0"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"ws": "^8.
|
|
52
|
+
"ws": "^8.21.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"ws": {
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"@types/ws": "^8.5.13",
|
|
62
62
|
"tsup": "^8.5.1",
|
|
63
63
|
"typescript": "5.8.3",
|
|
64
|
-
"ws": "^8.
|
|
64
|
+
"ws": "^8.21.0",
|
|
65
65
|
"zod": "3.25.76",
|
|
66
66
|
"@vercel/ai-tsconfig": "0.0.0"
|
|
67
67
|
},
|
|
@@ -18,6 +18,48 @@ export type HarnessAgentToolApprovalConfiguration = Readonly<
|
|
|
18
18
|
Record<string, ToolApprovalStatus>
|
|
19
19
|
>;
|
|
20
20
|
|
|
21
|
+
export type HarnessAgentSandboxConfig = {
|
|
22
|
+
/**
|
|
23
|
+
* Optional fixed working directory for all sessions, relative to the
|
|
24
|
+
* sandbox's default working directory. When omitted, sessions keep the
|
|
25
|
+
* existing `<harnessId>-<sessionId>` work directory.
|
|
26
|
+
*/
|
|
27
|
+
readonly workDir?: string;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Caller-controlled identity for `onBootstrap`. Change this whenever the
|
|
31
|
+
* bootstrap side effects should invalidate the reusable sandbox snapshot.
|
|
32
|
+
*/
|
|
33
|
+
readonly bootstrapHash?: string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Called during sandbox template creation after the harness adapter's own
|
|
37
|
+
* bootstrap has run and before snapshot-capable providers publish a snapshot.
|
|
38
|
+
*
|
|
39
|
+
* `bootstrapHash` must be provided with this callback.
|
|
40
|
+
*/
|
|
41
|
+
readonly onBootstrap?: (opts: {
|
|
42
|
+
readonly session: SandboxSession;
|
|
43
|
+
readonly workDir: string;
|
|
44
|
+
readonly abortSignal?: AbortSignal;
|
|
45
|
+
}) => Promise<void>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Called after each sandbox session is acquired and the session work
|
|
49
|
+
* directory exists, before the harness adapter starts. Runs for fresh and
|
|
50
|
+
* resumed sessions.
|
|
51
|
+
*
|
|
52
|
+
* Use this to write per-session config, install lightweight tools, activate
|
|
53
|
+
* licenses, or prepare files in `sessionWorkDir`. Keep it idempotent if the
|
|
54
|
+
* agent may resume sessions.
|
|
55
|
+
*/
|
|
56
|
+
readonly onSession?: (opts: {
|
|
57
|
+
readonly session: SandboxSession;
|
|
58
|
+
readonly sessionWorkDir: string;
|
|
59
|
+
readonly abortSignal?: AbortSignal;
|
|
60
|
+
}) => Promise<void>;
|
|
61
|
+
};
|
|
62
|
+
|
|
21
63
|
/**
|
|
22
64
|
* Construction-time settings for a `HarnessAgent`.
|
|
23
65
|
*
|
|
@@ -92,19 +134,12 @@ export type HarnessAgentSettings<
|
|
|
92
134
|
readonly sandbox: HarnessV1SandboxProvider;
|
|
93
135
|
|
|
94
136
|
/**
|
|
95
|
-
*
|
|
96
|
-
* directory exists, before the harness adapter starts. Runs for fresh and
|
|
97
|
-
* resumed sessions.
|
|
98
|
-
*
|
|
99
|
-
* Use this to write per-session config, install lightweight tools, activate
|
|
100
|
-
* licenses, or prepare files in `sessionWorkDir`. Keep it idempotent if the
|
|
101
|
-
* agent may resume sessions.
|
|
137
|
+
* Sandbox working-directory and lifecycle hook configuration.
|
|
102
138
|
*/
|
|
103
|
-
readonly
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}) => Promise<void>;
|
|
139
|
+
readonly sandboxConfig?: HarnessAgentSandboxConfig;
|
|
140
|
+
|
|
141
|
+
/** @deprecated Use `sandboxConfig.onSession` instead. */
|
|
142
|
+
readonly onSandboxSession?: HarnessAgentSandboxConfig['onSession'];
|
|
108
143
|
|
|
109
144
|
/**
|
|
110
145
|
* Telemetry configuration. The harness drives AI SDK's pluggable
|
|
@@ -20,7 +20,10 @@ import type {
|
|
|
20
20
|
ReasoningOutput,
|
|
21
21
|
StreamTextResult,
|
|
22
22
|
} from 'ai';
|
|
23
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
HarnessAgentSandboxConfig,
|
|
25
|
+
HarnessAgentSettings,
|
|
26
|
+
} from './harness-agent-settings';
|
|
24
27
|
import { HarnessAgentSession } from './harness-agent-session';
|
|
25
28
|
import type {
|
|
26
29
|
HarnessAgentAdapter,
|
|
@@ -34,14 +37,18 @@ import {
|
|
|
34
37
|
collectHarnessAgentToolApprovalContinuations,
|
|
35
38
|
type HarnessAgentToolApprovalContinuation,
|
|
36
39
|
} from './harness-agent-tool-approval-continuation';
|
|
37
|
-
import {
|
|
38
|
-
applyBootstrapRecipe,
|
|
39
|
-
hashBootstrap,
|
|
40
|
-
} from './internal/bootstrap-recipe';
|
|
40
|
+
import { applyBootstrapRecipe } from './internal/bootstrap-recipe';
|
|
41
41
|
import {
|
|
42
42
|
acquireBridgePort,
|
|
43
43
|
releaseBridgePort,
|
|
44
44
|
} from './internal/bridge-port-registry';
|
|
45
|
+
import {
|
|
46
|
+
createSandboxBootstrapPlan,
|
|
47
|
+
ensureSandboxDirectory,
|
|
48
|
+
resolveSessionWorkDir,
|
|
49
|
+
validateSandboxBootstrapSettings,
|
|
50
|
+
type SandboxBootstrapPlan,
|
|
51
|
+
} from './internal/sandbox-bootstrap';
|
|
45
52
|
import { buildObservability } from './internal/resolve-observability';
|
|
46
53
|
import { validateLifecycleStateData } from './internal/lifecycle-state-validation';
|
|
47
54
|
import {
|
|
@@ -130,11 +137,15 @@ export class HarnessAgent<
|
|
|
130
137
|
readonly tools: HarnessAllTools<THarness, TUserTools>;
|
|
131
138
|
|
|
132
139
|
private readonly settings: HarnessAgentSettings<THarness, TUserTools>;
|
|
140
|
+
private readonly sandboxConfig: HarnessAgentSandboxConfig;
|
|
133
141
|
private readonly userTools: TUserTools;
|
|
134
142
|
private readonly permissionMode: HarnessAgentPermissionMode;
|
|
135
143
|
|
|
136
144
|
constructor(settings: HarnessAgentSettings<THarness, TUserTools>) {
|
|
145
|
+
const sandboxConfig = resolveSandboxConfig(settings);
|
|
146
|
+
validateSandboxBootstrapSettings(sandboxConfig);
|
|
137
147
|
this.settings = settings;
|
|
148
|
+
this.sandboxConfig = sandboxConfig;
|
|
138
149
|
this.id = settings.id;
|
|
139
150
|
this.userTools = settings.tools ?? ({} as TUserTools);
|
|
140
151
|
this.permissionMode = resolvePermissionMode({
|
|
@@ -230,19 +241,25 @@ export class HarnessAgent<
|
|
|
230
241
|
validatedResumeFrom != null || effectiveContinueFrom != null;
|
|
231
242
|
|
|
232
243
|
let recipe: HarnessV1Bootstrap | undefined;
|
|
233
|
-
let identity: string | undefined;
|
|
234
|
-
|
|
235
244
|
if (harness.getBootstrap != null) {
|
|
236
245
|
recipe = await harness.getBootstrap({ abortSignal });
|
|
237
|
-
identity = await hashBootstrap(recipe);
|
|
238
246
|
}
|
|
239
247
|
|
|
248
|
+
// Defines the hashes based on both harness bootstrap recipe and
|
|
249
|
+
// consumer-defined onBootstrap callback.
|
|
250
|
+
const sandboxBootstrapPlan = await createSandboxBootstrapPlan({
|
|
251
|
+
recipe,
|
|
252
|
+
settings: this.sandboxConfig,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Acquires the concrete sandbox session, either by starting fresh and then
|
|
256
|
+
// creating a post-bootstrap snapshot, or by reusing a previously created
|
|
257
|
+
// snapshot based on the bootstrap-based hashes.
|
|
240
258
|
const acquiredSandboxSession = await this._acquireSandbox({
|
|
241
259
|
sandboxProvider,
|
|
242
260
|
sessionId,
|
|
243
261
|
isResume: isResumedSession,
|
|
244
|
-
|
|
245
|
-
identity,
|
|
262
|
+
bootstrapPlan: sandboxBootstrapPlan,
|
|
246
263
|
abortSignal,
|
|
247
264
|
});
|
|
248
265
|
|
|
@@ -253,27 +270,37 @@ export class HarnessAgent<
|
|
|
253
270
|
});
|
|
254
271
|
const sandboxSession = leased.sandboxSession;
|
|
255
272
|
const leasedBridgePort = leased.port;
|
|
256
|
-
const sessionWorkDir =
|
|
273
|
+
const sessionWorkDir = resolveSessionWorkDir({
|
|
274
|
+
defaultWorkingDirectory: sandboxSession.defaultWorkingDirectory,
|
|
275
|
+
harnessId: harness.harnessId,
|
|
276
|
+
sessionId,
|
|
277
|
+
workDir: sandboxBootstrapPlan.workDir,
|
|
278
|
+
});
|
|
257
279
|
|
|
258
280
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (
|
|
281
|
+
// In case the sandbox session was created with a custom sandbox, or in
|
|
282
|
+
// case the sandbox provider doesn't respect `onFirstCreate`, we still
|
|
283
|
+
// have to ensure the harness bootstrap recipe has run. In the common
|
|
284
|
+
// scenario, this will be a cheap no-op based on just a marker check.
|
|
285
|
+
if (
|
|
286
|
+
!isResumedSession &&
|
|
287
|
+
sandboxBootstrapPlan.recipe != null &&
|
|
288
|
+
sandboxBootstrapPlan.recipeIdentity != null
|
|
289
|
+
) {
|
|
264
290
|
await applyBootstrapRecipe(
|
|
265
291
|
sandboxSession.restricted(),
|
|
266
|
-
recipe,
|
|
267
|
-
|
|
292
|
+
sandboxBootstrapPlan.recipe,
|
|
293
|
+
sandboxBootstrapPlan.recipeIdentity,
|
|
268
294
|
{ abortSignal },
|
|
269
295
|
);
|
|
270
296
|
}
|
|
271
|
-
await
|
|
272
|
-
|
|
297
|
+
await ensureSandboxDirectory({
|
|
298
|
+
session: sandboxSession,
|
|
299
|
+
workDir: sessionWorkDir,
|
|
273
300
|
abortSignal,
|
|
274
301
|
});
|
|
275
|
-
if (this.
|
|
276
|
-
await this.
|
|
302
|
+
if (this.sandboxConfig.onSession != null) {
|
|
303
|
+
await this.sandboxConfig.onSession({
|
|
277
304
|
session: sandboxSession.restricted(),
|
|
278
305
|
sessionWorkDir,
|
|
279
306
|
abortSignal,
|
|
@@ -501,8 +528,7 @@ export class HarnessAgent<
|
|
|
501
528
|
sandboxProvider: HarnessV1SandboxProvider;
|
|
502
529
|
sessionId: string;
|
|
503
530
|
isResume: boolean;
|
|
504
|
-
|
|
505
|
-
identity: string | undefined;
|
|
531
|
+
bootstrapPlan: SandboxBootstrapPlan;
|
|
506
532
|
abortSignal: AbortSignal | undefined;
|
|
507
533
|
}): Promise<HarnessV1NetworkSandboxSession> {
|
|
508
534
|
const { sandboxProvider } = input;
|
|
@@ -521,17 +547,8 @@ export class HarnessAgent<
|
|
|
521
547
|
return sandboxProvider.createSession({
|
|
522
548
|
sessionId: input.sessionId,
|
|
523
549
|
abortSignal: input.abortSignal,
|
|
524
|
-
identity: input.identity,
|
|
525
|
-
onFirstCreate:
|
|
526
|
-
input.recipe != null && input.identity != null
|
|
527
|
-
? (session, opts) =>
|
|
528
|
-
applyBootstrapRecipe(
|
|
529
|
-
session,
|
|
530
|
-
input.recipe!,
|
|
531
|
-
input.identity!,
|
|
532
|
-
opts,
|
|
533
|
-
)
|
|
534
|
-
: undefined,
|
|
550
|
+
identity: input.bootstrapPlan.identity,
|
|
551
|
+
onFirstCreate: input.bootstrapPlan.onFirstCreate,
|
|
535
552
|
});
|
|
536
553
|
}
|
|
537
554
|
|
|
@@ -756,6 +773,24 @@ class HarnessGenerateTextResult<
|
|
|
756
773
|
}
|
|
757
774
|
}
|
|
758
775
|
|
|
776
|
+
function resolveSandboxConfig(
|
|
777
|
+
settings: Pick<HarnessAgentSettings, 'sandboxConfig' | 'onSandboxSession'>,
|
|
778
|
+
): HarnessAgentSandboxConfig {
|
|
779
|
+
if (settings.onSandboxSession != null) {
|
|
780
|
+
console.warn(
|
|
781
|
+
'HarnessAgent: `onSandboxSession` is deprecated. Use `sandboxConfig.onSession` instead.',
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return {
|
|
786
|
+
...settings.sandboxConfig,
|
|
787
|
+
...(settings.sandboxConfig?.onSession == null &&
|
|
788
|
+
settings.onSandboxSession != null
|
|
789
|
+
? { onSession: settings.onSandboxSession }
|
|
790
|
+
: {}),
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
759
794
|
/*
|
|
760
795
|
* Bridge-port leasing helper. Returns the port-narrowed network sandbox session
|
|
761
796
|
* plus the leased port (or `undefined` when the provider has no port pool). Kept here
|
|
@@ -16,7 +16,7 @@ export const BOOTSTRAP_SCHEMA_VERSION = 1;
|
|
|
16
16
|
* Used by sandbox providers as part of the persistent sandbox name so
|
|
17
17
|
* recipe changes automatically invalidate snapshots.
|
|
18
18
|
*/
|
|
19
|
-
export async function
|
|
19
|
+
export async function hashHarnessBootstrap(
|
|
20
20
|
recipe: HarnessV1Bootstrap,
|
|
21
21
|
): Promise<string> {
|
|
22
22
|
const encoder = new TextEncoder();
|
|
@@ -272,10 +272,21 @@ export class HarnessStreamTextResult<
|
|
|
272
272
|
/**
|
|
273
273
|
* Resolve every delayed promise and close `fullStream`. Idempotent.
|
|
274
274
|
*/
|
|
275
|
-
async finish(
|
|
275
|
+
async finish(input?: {
|
|
276
|
+
finishReason: LanguageModelV4FinishReason;
|
|
277
|
+
totalUsage: LanguageModelV4Usage;
|
|
278
|
+
providerMetadata: ProviderMetadata | undefined;
|
|
279
|
+
}): Promise<void> {
|
|
276
280
|
if (this.settled) return;
|
|
277
281
|
this.settled = true;
|
|
278
282
|
|
|
283
|
+
if (input != null) {
|
|
284
|
+
this.finalFinishReason = input.finishReason.unified;
|
|
285
|
+
this.finalRawFinishReason = input.finishReason.raw;
|
|
286
|
+
this.finalProviderMetadata = input.providerMetadata;
|
|
287
|
+
this.accumulatedUsage = asLanguageModelUsage(input.totalUsage);
|
|
288
|
+
}
|
|
289
|
+
|
|
279
290
|
// Flush any trailing content not yet captured by a finish-step. We
|
|
280
291
|
// construct the step directly here (the public `finishStep` takes V4
|
|
281
292
|
// shapes; we already have AI SDK shapes at this point).
|
|
@@ -152,6 +152,9 @@ export function runPrompt<
|
|
|
152
152
|
]),
|
|
153
153
|
);
|
|
154
154
|
const settledApprovalToolCallIds = new Set<string>();
|
|
155
|
+
let finalFinish:
|
|
156
|
+
| Extract<HarnessV1StreamPart, { type: 'finish' }>
|
|
157
|
+
| undefined;
|
|
155
158
|
|
|
156
159
|
// Accumulate the model's output content per step so telemetry can record
|
|
157
160
|
// `gen_ai.output.messages` and reporters can log what was actually said.
|
|
@@ -495,6 +498,7 @@ export function runPrompt<
|
|
|
495
498
|
}
|
|
496
499
|
|
|
497
500
|
if (value.type === 'finish') {
|
|
501
|
+
finalFinish = value;
|
|
498
502
|
telemetry.end({
|
|
499
503
|
finishReason: value.finishReason,
|
|
500
504
|
usage: value.totalUsage,
|
|
@@ -636,7 +640,15 @@ export function runPrompt<
|
|
|
636
640
|
}
|
|
637
641
|
}
|
|
638
642
|
input.onTurnFinished?.();
|
|
639
|
-
await result.finish(
|
|
643
|
+
await result.finish(
|
|
644
|
+
finalFinish
|
|
645
|
+
? {
|
|
646
|
+
finishReason: finalFinish.finishReason,
|
|
647
|
+
totalUsage: finalFinish.totalUsage,
|
|
648
|
+
providerMetadata: finalFinish.harnessMetadata,
|
|
649
|
+
}
|
|
650
|
+
: undefined,
|
|
651
|
+
);
|
|
640
652
|
} catch (err) {
|
|
641
653
|
telemetry.error(err);
|
|
642
654
|
result.fail(err);
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
|
|
3
|
+
import type { HarnessV1Bootstrap } from '../../v1';
|
|
4
|
+
import type { HarnessAgentSandboxConfig } from '../harness-agent-settings';
|
|
5
|
+
import { applyBootstrapRecipe, hashHarnessBootstrap } from './bootstrap-recipe';
|
|
6
|
+
|
|
7
|
+
const SANDBOX_BOOTSTRAP_IDENTITY_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
type SandboxBootstrapSettings = Omit<HarnessAgentSandboxConfig, 'onSession'>;
|
|
10
|
+
|
|
11
|
+
export type SandboxBootstrapPlan = {
|
|
12
|
+
readonly recipe?: HarnessV1Bootstrap;
|
|
13
|
+
readonly recipeIdentity?: string;
|
|
14
|
+
readonly identity?: string;
|
|
15
|
+
readonly workDir?: string;
|
|
16
|
+
readonly onFirstCreate?: (
|
|
17
|
+
session: SandboxSession,
|
|
18
|
+
opts: { abortSignal?: AbortSignal },
|
|
19
|
+
) => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function validateSandboxBootstrapSettings(
|
|
23
|
+
settings: SandboxBootstrapSettings,
|
|
24
|
+
): void {
|
|
25
|
+
if ((settings.onBootstrap == null) !== (settings.bootstrapHash == null)) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
'HarnessAgent: `sandboxConfig.onBootstrap` and `sandboxConfig.bootstrapHash` must be provided together.',
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (settings.workDir != null) {
|
|
32
|
+
normalizeSandboxWorkDir(settings.workDir);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeSandboxWorkDir(workDir: string): string {
|
|
37
|
+
if (workDir.length === 0) {
|
|
38
|
+
throw new Error('HarnessAgent: `sandboxConfig.workDir` must not be empty.');
|
|
39
|
+
}
|
|
40
|
+
if (workDir.includes('\0')) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'HarnessAgent: `sandboxConfig.workDir` must not contain NUL.',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (workDir.includes('\\')) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'HarnessAgent: `sandboxConfig.workDir` must use POSIX path separators.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (posix.isAbsolute(workDir)) {
|
|
51
|
+
throw new Error('HarnessAgent: `sandboxConfig.workDir` must be relative.');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const normalized = posix.normalize(workDir);
|
|
55
|
+
if (
|
|
56
|
+
normalized === '.' ||
|
|
57
|
+
normalized === '..' ||
|
|
58
|
+
normalized.startsWith('../')
|
|
59
|
+
) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
'HarnessAgent: `sandboxConfig.workDir` must stay inside the sandbox default working directory.',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolveSessionWorkDir({
|
|
68
|
+
defaultWorkingDirectory,
|
|
69
|
+
harnessId,
|
|
70
|
+
sessionId,
|
|
71
|
+
workDir,
|
|
72
|
+
}: {
|
|
73
|
+
readonly defaultWorkingDirectory: string;
|
|
74
|
+
readonly harnessId: string;
|
|
75
|
+
readonly sessionId: string;
|
|
76
|
+
readonly workDir?: string;
|
|
77
|
+
}): string {
|
|
78
|
+
return joinSandboxPath({
|
|
79
|
+
base: defaultWorkingDirectory,
|
|
80
|
+
path: workDir ?? `${harnessId}-${sessionId}`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function createSandboxBootstrapPlan({
|
|
85
|
+
recipe,
|
|
86
|
+
settings,
|
|
87
|
+
}: {
|
|
88
|
+
readonly recipe?: HarnessV1Bootstrap;
|
|
89
|
+
readonly settings: SandboxBootstrapSettings;
|
|
90
|
+
}): Promise<SandboxBootstrapPlan> {
|
|
91
|
+
const workDir =
|
|
92
|
+
settings.workDir == null
|
|
93
|
+
? undefined
|
|
94
|
+
: normalizeSandboxWorkDir(settings.workDir);
|
|
95
|
+
const recipeIdentity =
|
|
96
|
+
recipe == null ? undefined : await hashHarnessBootstrap(recipe);
|
|
97
|
+
const hasCallerBootstrap = settings.onBootstrap != null;
|
|
98
|
+
const needsCombinedIdentity =
|
|
99
|
+
hasCallerBootstrap || (recipeIdentity != null && workDir != null);
|
|
100
|
+
const identity =
|
|
101
|
+
needsCombinedIdentity && (recipeIdentity != null || hasCallerBootstrap)
|
|
102
|
+
? await hashSandboxBootstrapIdentity({
|
|
103
|
+
recipeIdentity,
|
|
104
|
+
bootstrapHash: settings.bootstrapHash,
|
|
105
|
+
workDir,
|
|
106
|
+
})
|
|
107
|
+
: recipeIdentity;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
...(recipe != null ? { recipe } : {}),
|
|
111
|
+
...(recipeIdentity != null ? { recipeIdentity } : {}),
|
|
112
|
+
...(identity != null ? { identity } : {}),
|
|
113
|
+
...(workDir != null ? { workDir } : {}),
|
|
114
|
+
...(recipe != null || settings.onBootstrap != null
|
|
115
|
+
? {
|
|
116
|
+
onFirstCreate: (session, opts) =>
|
|
117
|
+
runSandboxBootstrap({
|
|
118
|
+
session,
|
|
119
|
+
recipe,
|
|
120
|
+
recipeIdentity,
|
|
121
|
+
workDir,
|
|
122
|
+
onBootstrap: settings.onBootstrap,
|
|
123
|
+
abortSignal: opts.abortSignal,
|
|
124
|
+
}),
|
|
125
|
+
}
|
|
126
|
+
: {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runSandboxBootstrap({
|
|
131
|
+
session,
|
|
132
|
+
recipe,
|
|
133
|
+
recipeIdentity,
|
|
134
|
+
workDir,
|
|
135
|
+
onBootstrap,
|
|
136
|
+
abortSignal,
|
|
137
|
+
}: {
|
|
138
|
+
readonly session: SandboxSession;
|
|
139
|
+
readonly recipe?: HarnessV1Bootstrap;
|
|
140
|
+
readonly recipeIdentity?: string;
|
|
141
|
+
readonly workDir?: string;
|
|
142
|
+
readonly onBootstrap?: SandboxBootstrapSettings['onBootstrap'];
|
|
143
|
+
readonly abortSignal?: AbortSignal;
|
|
144
|
+
}): Promise<void> {
|
|
145
|
+
if (recipe != null && recipeIdentity != null) {
|
|
146
|
+
await applyBootstrapRecipe(session, recipe, recipeIdentity, {
|
|
147
|
+
abortSignal,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (onBootstrap == null) return;
|
|
152
|
+
|
|
153
|
+
const defaultWorkingDirectory = await resolveDefaultWorkingDirectory({
|
|
154
|
+
session,
|
|
155
|
+
abortSignal,
|
|
156
|
+
});
|
|
157
|
+
const bootstrapWorkDir =
|
|
158
|
+
workDir == null
|
|
159
|
+
? defaultWorkingDirectory
|
|
160
|
+
: joinSandboxPath({
|
|
161
|
+
base: defaultWorkingDirectory,
|
|
162
|
+
path: workDir,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await ensureSandboxDirectory({
|
|
166
|
+
session,
|
|
167
|
+
workDir: bootstrapWorkDir,
|
|
168
|
+
abortSignal,
|
|
169
|
+
});
|
|
170
|
+
await onBootstrap({ session, workDir: bootstrapWorkDir, abortSignal });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function resolveDefaultWorkingDirectory({
|
|
174
|
+
session,
|
|
175
|
+
abortSignal,
|
|
176
|
+
}: {
|
|
177
|
+
readonly session: SandboxSession;
|
|
178
|
+
readonly abortSignal?: AbortSignal;
|
|
179
|
+
}): Promise<string> {
|
|
180
|
+
const result = await session.run({
|
|
181
|
+
command: 'pwd',
|
|
182
|
+
abortSignal,
|
|
183
|
+
});
|
|
184
|
+
if (result.exitCode !== 0) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Failed to resolve sandbox default working directory (exit ${result.exitCode}): ${result.stderr || result.stdout}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const cwd = result.stdout.trim();
|
|
191
|
+
if (!posix.isAbsolute(cwd)) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Failed to resolve sandbox default working directory: expected an absolute path, got ${JSON.stringify(cwd)}.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return cwd === '/' ? cwd : cwd.replace(/\/+$/, '');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function ensureSandboxDirectory({
|
|
200
|
+
session,
|
|
201
|
+
workDir,
|
|
202
|
+
abortSignal,
|
|
203
|
+
}: {
|
|
204
|
+
readonly session: SandboxSession;
|
|
205
|
+
readonly workDir: string;
|
|
206
|
+
readonly abortSignal?: AbortSignal;
|
|
207
|
+
}): Promise<void> {
|
|
208
|
+
const result = await session.run({
|
|
209
|
+
command: 'mkdir -p "$WORK_DIR"',
|
|
210
|
+
env: { WORK_DIR: workDir },
|
|
211
|
+
abortSignal,
|
|
212
|
+
});
|
|
213
|
+
if (result.exitCode !== 0) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`Failed to create sandbox work directory ${workDir} (exit ${result.exitCode}): ${result.stderr || result.stdout}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function hashSandboxBootstrapIdentity({
|
|
221
|
+
recipeIdentity,
|
|
222
|
+
bootstrapHash,
|
|
223
|
+
workDir,
|
|
224
|
+
}: {
|
|
225
|
+
readonly recipeIdentity?: string;
|
|
226
|
+
readonly bootstrapHash?: string;
|
|
227
|
+
readonly workDir?: string;
|
|
228
|
+
}): Promise<string> {
|
|
229
|
+
const encoder = new TextEncoder();
|
|
230
|
+
const chunks: Uint8Array[] = [];
|
|
231
|
+
const pushString = (value: string) => {
|
|
232
|
+
chunks.push(encoder.encode(value));
|
|
233
|
+
chunks.push(encoder.encode('\0'));
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
pushString(String(SANDBOX_BOOTSTRAP_IDENTITY_VERSION));
|
|
237
|
+
pushString(recipeIdentity ?? '');
|
|
238
|
+
pushString(bootstrapHash ?? '');
|
|
239
|
+
pushString(workDir ?? '');
|
|
240
|
+
|
|
241
|
+
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
242
|
+
const buffer = new Uint8Array(totalLength);
|
|
243
|
+
let offset = 0;
|
|
244
|
+
for (const chunk of chunks) {
|
|
245
|
+
buffer.set(chunk, offset);
|
|
246
|
+
offset += chunk.length;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
|
250
|
+
const bytes = new Uint8Array(digest);
|
|
251
|
+
let hex = '';
|
|
252
|
+
for (let i = 0; i < 8; i++) {
|
|
253
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
254
|
+
}
|
|
255
|
+
return hex;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function joinSandboxPath({
|
|
259
|
+
base,
|
|
260
|
+
path,
|
|
261
|
+
}: {
|
|
262
|
+
readonly base: string;
|
|
263
|
+
readonly path: string;
|
|
264
|
+
}): string {
|
|
265
|
+
return posix.join(base, path);
|
|
266
|
+
}
|
package/src/agent/prewarm.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { HarnessV1SandboxProvider } from '../v1';
|
|
2
2
|
import type { HarnessAgentAdapter } from './harness-agent-types';
|
|
3
|
+
import type { HarnessAgentSandboxConfig } from './harness-agent-settings';
|
|
4
|
+
import { applyBootstrapRecipe } from './internal/bootstrap-recipe';
|
|
3
5
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from './internal/bootstrap
|
|
6
|
+
createSandboxBootstrapPlan,
|
|
7
|
+
validateSandboxBootstrapSettings,
|
|
8
|
+
} from './internal/sandbox-bootstrap';
|
|
9
|
+
|
|
10
|
+
type SandboxBootstrapSettings = Omit<HarnessAgentSandboxConfig, 'onSession'>;
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Pre-build a harness's sandbox template without running an agent. Idempotent:
|
|
@@ -22,25 +26,39 @@ import {
|
|
|
22
26
|
export async function prewarmHarness(options: {
|
|
23
27
|
readonly harness: HarnessAgentAdapter;
|
|
24
28
|
readonly sandboxProvider: HarnessV1SandboxProvider;
|
|
29
|
+
readonly sandboxConfig?: SandboxBootstrapSettings;
|
|
25
30
|
readonly abortSignal?: AbortSignal;
|
|
26
31
|
}): Promise<void> {
|
|
32
|
+
const sandboxConfig = options.sandboxConfig ?? {};
|
|
33
|
+
validateSandboxBootstrapSettings(sandboxConfig);
|
|
27
34
|
const recipe = await options.harness.getBootstrap?.({
|
|
28
35
|
abortSignal: options.abortSignal,
|
|
29
36
|
});
|
|
30
|
-
|
|
37
|
+
const bootstrapPlan = await createSandboxBootstrapPlan({
|
|
38
|
+
recipe,
|
|
39
|
+
settings: sandboxConfig,
|
|
40
|
+
});
|
|
41
|
+
if (bootstrapPlan.identity == null || bootstrapPlan.onFirstCreate == null) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
31
44
|
|
|
32
|
-
const identity = await hashBootstrap(recipe);
|
|
33
45
|
const sandboxSession = await options.sandboxProvider.createSession({
|
|
34
46
|
abortSignal: options.abortSignal,
|
|
35
|
-
identity,
|
|
36
|
-
onFirstCreate:
|
|
37
|
-
applyBootstrapRecipe(session, recipe, identity, opts),
|
|
47
|
+
identity: bootstrapPlan.identity,
|
|
48
|
+
onFirstCreate: bootstrapPlan.onFirstCreate,
|
|
38
49
|
});
|
|
39
50
|
|
|
40
51
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
if (bootstrapPlan.recipe != null && bootstrapPlan.recipeIdentity != null) {
|
|
53
|
+
await applyBootstrapRecipe(
|
|
54
|
+
sandboxSession.restricted(),
|
|
55
|
+
bootstrapPlan.recipe,
|
|
56
|
+
bootstrapPlan.recipeIdentity,
|
|
57
|
+
{
|
|
58
|
+
abortSignal: options.abortSignal,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
}
|
|
44
62
|
} finally {
|
|
45
63
|
await Promise.resolve(sandboxSession.stop()).catch(() => {});
|
|
46
64
|
}
|