@ai-sdk/harness 1.0.123 → 1.0.126

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.123",
3
+ "version": "1.0.126",
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.47",
49
- "ai": "7.0.113"
48
+ "@ai-sdk/provider-utils": "5.0.49",
49
+ "ai": "7.0.116"
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.113",
61
+ "@ai-sdk/otel": "1.0.116",
62
62
  "@opentelemetry/sdk-trace-base": "2.7.1",
63
63
  "@types/node": "22.19.19",
64
64
  "@types/ws": "^8.5.13",
@@ -0,0 +1,80 @@
1
+ import type {
2
+ HarnessAgentAdapter,
3
+ HarnessSandboxTemplate,
4
+ } from './harness-agent-types';
5
+ import type { HarnessAgentSandboxConfig } from './harness-agent-settings';
6
+ import { hashHarnessBootstrap } from './internal/bootstrap-recipe';
7
+ import { resolvePreparedSandboxIdentity } from './internal/prepared-sandbox-identity';
8
+ import {
9
+ normalizeSandboxWorkDir,
10
+ runSandboxBootstrap,
11
+ validateSandboxBootstrapSettings,
12
+ } from './internal/sandbox-bootstrap';
13
+
14
+ export async function createHarnessSandboxTemplate(options: {
15
+ readonly harnesses: ReadonlyArray<HarnessAgentAdapter>;
16
+ readonly sandboxConfig?: Omit<HarnessAgentSandboxConfig, 'onSession'>;
17
+ }): Promise<HarnessSandboxTemplate | undefined> {
18
+ if (options.harnesses.length === 0) {
19
+ throw new Error(
20
+ 'createHarnessSandboxTemplate: at least one harness must be provided.',
21
+ );
22
+ }
23
+ const settings = options.sandboxConfig ?? {};
24
+ validateSandboxBootstrapSettings(settings);
25
+ const workDir =
26
+ settings.workDir == null
27
+ ? undefined
28
+ : normalizeSandboxWorkDir(settings.workDir);
29
+ const harnesses = [
30
+ ...new Map(
31
+ options.harnesses.map(harness => [harness.harnessId, harness]),
32
+ ).values(),
33
+ ].sort((a, b) => a.harnessId.localeCompare(b.harnessId));
34
+ const recipes = await Promise.all(
35
+ harnesses.map(async harness => {
36
+ const recipe = await harness.getBootstrap?.({});
37
+ return recipe == null
38
+ ? undefined
39
+ : {
40
+ recipe,
41
+ identity: await hashHarnessBootstrap(recipe),
42
+ };
43
+ }),
44
+ );
45
+ const recipeIdentities: Record<string, string> = {};
46
+ for (let index = 0; index < harnesses.length; index++) {
47
+ const entry = recipes[index];
48
+ if (entry != null)
49
+ recipeIdentities[harnesses[index].harnessId] = entry.identity;
50
+ }
51
+ const identity = await resolvePreparedSandboxIdentity({
52
+ recipeIdentities,
53
+ bootstrapHash: settings.bootstrapHash,
54
+ workDir,
55
+ });
56
+ if (identity == null) return undefined;
57
+ return {
58
+ identity,
59
+ prepare: async ({ session, abortSignal }) => {
60
+ for (const entry of recipes) {
61
+ if (entry != null) {
62
+ await runSandboxBootstrap({
63
+ session,
64
+ recipe: entry.recipe,
65
+ recipeIdentity: entry.identity,
66
+ abortSignal,
67
+ });
68
+ }
69
+ }
70
+ await runSandboxBootstrap({
71
+ session,
72
+ workDir,
73
+ onBootstrap: settings.onBootstrap,
74
+ bootstrapHash: settings.bootstrapHash,
75
+ skipOnBootstrapIfMarked: true,
76
+ abortSignal,
77
+ });
78
+ },
79
+ };
80
+ }
@@ -356,6 +356,7 @@ export type HarnessAgentSettings<
356
356
  * sessions. When omitted, every `createSession()` call must provide an
357
357
  * existing network sandbox session.
358
358
  */
359
+ /** @deprecated Supply `sandboxSession` to `HarnessAgent.createSession()` instead. */
359
360
  readonly sandbox?: HarnessV1SandboxProvider;
360
361
 
361
362
  /**
@@ -15,6 +15,7 @@ import type {
15
15
  HarnessV1PromptControl,
16
16
  HarnessV1PromptTurnOptions,
17
17
  HarnessV1ResumeSessionState,
18
+ HarnessV1SandboxTemplate,
18
19
  HarnessV1Session,
19
20
  HarnessV1Skill,
20
21
  HarnessV1StartOptions,
@@ -24,6 +25,7 @@ import type {
24
25
 
25
26
  export type HarnessAgentAdapter<TBuiltinTools extends ToolSet = ToolSet> =
26
27
  HarnessV1<TBuiltinTools>;
28
+ export type HarnessSandboxTemplate = HarnessV1SandboxTemplate;
27
29
 
28
30
  export type HarnessAgentBuiltinTool<
29
31
  INPUT = unknown,
@@ -48,6 +48,7 @@ import type {
48
48
  HarnessAgentResumeSessionState,
49
49
  HarnessAgentSkill,
50
50
  HarnessAgentToolSpec,
51
+ HarnessSandboxTemplate,
51
52
  } from './harness-agent-types';
52
53
  import { collectHarnessAgentToolApprovalContinuations } from './harness-agent-tool-approval-continuation';
53
54
  import { collectHarnessAgentToolResultContinuations } from './harness-agent-tool-result-continuation';
@@ -59,6 +60,7 @@ import {
59
60
  createSandboxBootstrapPlan,
60
61
  ensureSandboxDirectory,
61
62
  resolveSessionWorkDir,
63
+ runSandboxBootstrap,
62
64
  validateSandboxBootstrapSettings,
63
65
  } from './internal/sandbox-bootstrap';
64
66
  import { buildObservability } from './internal/resolve-observability';
@@ -72,6 +74,7 @@ import { resolveSandboxDefaultWorkingDirectory } from '../utils/resolve-sandbox-
72
74
  import { resolveSandboxHomeDir } from '../utils/sandbox-home-dir';
73
75
  import { getRestrictedSandboxSession } from '../utils/get-restricted-sandbox-session';
74
76
  import type { HarnessAgentLifecycleCallbacks } from './internal/turn-telemetry';
77
+ import { createHarnessSandboxTemplate } from './create-harness-sandbox-template';
75
78
 
76
79
  export type { HarnessAllTools } from './harness-agent-tool-types';
77
80
 
@@ -222,6 +225,11 @@ export class HarnessAgent<
222
225
  CALL_OPTIONS
223
226
  >,
224
227
  ) {
228
+ if (settings.sandbox != null) {
229
+ console.warn(
230
+ 'HarnessAgent: `sandbox` is deprecated. Supply `sandboxSession` to createSession() instead.',
231
+ );
232
+ }
225
233
  const sandboxConfig = resolveSandboxConfig(settings);
226
234
  validateSandboxBootstrapSettings(sandboxConfig);
227
235
  this.settings = settings;
@@ -290,6 +298,13 @@ export class HarnessAgent<
290
298
  return this.settings.output != null;
291
299
  }
292
300
 
301
+ getSandboxTemplate(): Promise<HarnessSandboxTemplate | undefined> {
302
+ return createHarnessSandboxTemplate({
303
+ harnesses: [this.settings.harness],
304
+ sandboxConfig: this.sandboxConfig,
305
+ });
306
+ }
307
+
293
308
  /**
294
309
  * Start a fresh session, or resume from state previously returned by
295
310
  * `session.detach()` or `session.stop()`. The returned
@@ -540,6 +555,14 @@ export class HarnessAgent<
540
555
  }
541
556
 
542
557
  try {
558
+ await runSandboxBootstrap({
559
+ session: getRestrictedSandboxSession(sandboxSession),
560
+ workDir: this.sandboxConfig.workDir,
561
+ onBootstrap: this.sandboxConfig.onBootstrap,
562
+ bootstrapHash: this.sandboxConfig.bootstrapHash,
563
+ skipOnBootstrapIfMarked: true,
564
+ abortSignal,
565
+ });
543
566
  await ensureSandboxDirectory({
544
567
  session: sandboxSession,
545
568
  workDir: sessionWorkDir,
@@ -0,0 +1,70 @@
1
+ import { posix } from 'node:path';
2
+ import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
3
+ import { harnessStateDirectoryPath } from '../../v1';
4
+ import { resolveSandboxHomeDir } from '../../utils/sandbox-home-dir';
5
+
6
+ export async function onBootstrapMarkerPath({
7
+ session,
8
+ bootstrapHash,
9
+ abortSignal,
10
+ }: {
11
+ session: SandboxSession;
12
+ bootstrapHash: string;
13
+ abortSignal?: AbortSignal;
14
+ }): Promise<string> {
15
+ const digest = new Uint8Array(
16
+ await crypto.subtle.digest(
17
+ 'SHA-256',
18
+ new TextEncoder().encode(bootstrapHash),
19
+ ),
20
+ );
21
+ const filename = Array.from(digest, byte =>
22
+ byte.toString(16).padStart(2, '0'),
23
+ ).join('');
24
+ return posix.join(
25
+ harnessStateDirectoryPath({
26
+ sandboxHomeDir: await resolveSandboxHomeDir({
27
+ sandbox: session,
28
+ abortSignal,
29
+ }),
30
+ }),
31
+ '.on-bootstrap',
32
+ `${filename}.ok`,
33
+ );
34
+ }
35
+
36
+ export async function hasOnBootstrapMarker(options: {
37
+ session: SandboxSession;
38
+ bootstrapHash: string;
39
+ abortSignal?: AbortSignal;
40
+ }): Promise<boolean> {
41
+ return (
42
+ (await options.session.readTextFile({
43
+ path: await onBootstrapMarkerPath(options),
44
+ abortSignal: options.abortSignal,
45
+ })) !== null
46
+ );
47
+ }
48
+
49
+ export async function writeOnBootstrapMarker(options: {
50
+ session: SandboxSession;
51
+ bootstrapHash: string;
52
+ abortSignal?: AbortSignal;
53
+ }): Promise<void> {
54
+ const path = await onBootstrapMarkerPath(options);
55
+ const result = await options.session.run({
56
+ command: 'mkdir -p "$MARKER_DIR"',
57
+ env: { MARKER_DIR: posix.dirname(path) },
58
+ abortSignal: options.abortSignal,
59
+ });
60
+ if (result.exitCode !== 0) {
61
+ throw new Error(
62
+ `Failed to create onBootstrap marker directory: ${result.stderr || result.stdout}`,
63
+ );
64
+ }
65
+ await options.session.writeTextFile({
66
+ path,
67
+ content: '',
68
+ abortSignal: options.abortSignal,
69
+ });
70
+ }
@@ -0,0 +1,42 @@
1
+ const PREPARED_SANDBOX_IDENTITY_VERSION = 1;
2
+
3
+ export async function resolvePreparedSandboxIdentity({
4
+ recipeIdentities,
5
+ bootstrapHash,
6
+ workDir,
7
+ }: {
8
+ readonly recipeIdentities: Record<string, string>;
9
+ readonly bootstrapHash?: string;
10
+ readonly workDir?: string;
11
+ }): Promise<string | undefined> {
12
+ const entries = Object.entries(recipeIdentities).sort(([a], [b]) =>
13
+ a.localeCompare(b),
14
+ );
15
+ if (entries.length === 0 && bootstrapHash == null) return undefined;
16
+
17
+ const encoder = new TextEncoder();
18
+ const chunks: Uint8Array[] = [];
19
+ const pushString = (value: string) => {
20
+ chunks.push(encoder.encode(value));
21
+ chunks.push(encoder.encode('\0'));
22
+ };
23
+ pushString(String(PREPARED_SANDBOX_IDENTITY_VERSION));
24
+ pushString(workDir ?? '');
25
+ pushString(bootstrapHash ?? '');
26
+ for (const [harnessId, identity] of entries) {
27
+ pushString(harnessId);
28
+ pushString(identity);
29
+ }
30
+ const buffer = new Uint8Array(
31
+ chunks.reduce((sum, chunk) => sum + chunk.length, 0),
32
+ );
33
+ let offset = 0;
34
+ for (const chunk of chunks) {
35
+ buffer.set(chunk, offset);
36
+ offset += chunk.length;
37
+ }
38
+ const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', buffer));
39
+ return Array.from(digest.slice(0, 8), byte =>
40
+ byte.toString(16).padStart(2, '0'),
41
+ ).join('');
42
+ }
@@ -53,7 +53,11 @@ import type { HarnessAgentToolApprovalConfiguration } from '../harness-agent-set
53
53
  import { HarnessStreamTextResult } from './harness-stream-text-result';
54
54
  import { getOwn } from './get-own';
55
55
  import { translateStreamPart } from './translate-stream-part';
56
- import { createToolInputWorkDirStripper, stripWorkDir } from './strip-work-dir';
56
+ import {
57
+ createToolInputWorkDirStripper,
58
+ stripParsedToolInputWorkDir,
59
+ stripWorkDir,
60
+ } from './strip-work-dir';
57
61
  import {
58
62
  createTurnLifecycle,
59
63
  type HarnessAgentLifecycleCallbacks,
@@ -63,6 +67,8 @@ import { resolveCustomToolApproval } from './permission-mode';
63
67
  import { logBridgeError } from '../../utils/bridge-diagnostics';
64
68
  import { pinSandboxChannelEventCheckpoint } from '../../utils/sandbox-channel';
65
69
 
70
+ const invalidToolInputMessage = 'Tool input validation failed.';
71
+
66
72
  function unwrapToolResultOutput(toolResult: ToolResultPart): {
67
73
  output: unknown;
68
74
  isError?: boolean;
@@ -647,7 +653,9 @@ export function runPrompt<
647
653
  continuation: ToolApprovalResponse,
648
654
  ): Promise<'continued' | 'awaiting-tool-result'> => {
649
655
  const rawToolCall =
650
- rawToolCallsByToolCallId.get(approval.toolCallId) ??
656
+ (approval.kind === 'builtin'
657
+ ? rawToolCallsByToolCallId.get(approval.toolCallId)
658
+ : undefined) ??
651
659
  ({
652
660
  type: 'tool-call',
653
661
  toolCallId: approval.toolCallId,
@@ -656,16 +664,31 @@ export function runPrompt<
656
664
  providerExecuted: approval.providerExecuted,
657
665
  nativeName: approval.nativeName,
658
666
  } satisfies Extract<HarnessV1StreamPart, { type: 'tool-call' }>);
659
- const parsedInput = await safeParseJSON({ text: rawToolCall.input });
660
- const toolCall: ToolCallTextStreamPart = {
661
- type: 'tool-call',
662
- toolCallId: rawToolCall.toolCallId,
663
- toolName: rawToolCall.toolName,
664
- input: parsedInput.success ? parsedInput.value : rawToolCall.input,
665
- ...(rawToolCall.providerExecuted !== undefined
666
- ? { providerExecuted: rawToolCall.providerExecuted }
667
- : {}),
668
- };
667
+ let validatedToolCall: ToolCallTextStreamPart | undefined;
668
+ let toolCall: ToolCallTextStreamPart;
669
+ if (approval.kind === 'custom' && continuation.approved) {
670
+ validatedToolCall = asToolCallTextStreamPart({
671
+ part: await validateToolCall({
672
+ event: rawToolCall,
673
+ tools: input.tools,
674
+ }),
675
+ });
676
+ toolCall = displayHostToolCall({
677
+ toolCall: validatedToolCall,
678
+ sessionWorkDir: input.sessionWorkDir,
679
+ });
680
+ } else {
681
+ const parsedInput = await safeParseJSON({ text: rawToolCall.input });
682
+ toolCall = {
683
+ type: 'tool-call',
684
+ toolCallId: rawToolCall.toolCallId,
685
+ toolName: rawToolCall.toolName,
686
+ input: parsedInput.success ? parsedInput.value : rawToolCall.input,
687
+ ...(rawToolCall.providerExecuted !== undefined
688
+ ? { providerExecuted: rawToolCall.providerExecuted }
689
+ : {}),
690
+ };
691
+ }
669
692
 
670
693
  enqueueApprovalResponse(approval, continuation, toolCall);
671
694
  onToolApprovalSettled(approval.approvalId);
@@ -699,6 +722,29 @@ export function runPrompt<
699
722
  return 'continued';
700
723
  }
701
724
 
725
+ if (validatedToolCall == null) {
726
+ throw new Error(
727
+ `Harness '${input.harness.harnessId}' could not validate approved host tool '${approval.toolName}'.`,
728
+ );
729
+ }
730
+
731
+ if (validatedToolCall.invalid) {
732
+ const error = new Error(invalidToolInputMessage);
733
+ await submitToolResult({
734
+ toolCallId: approval.toolCallId,
735
+ output: { error: invalidToolInputMessage },
736
+ isError: true,
737
+ });
738
+ bufferedToolOutcomes.push(() => {
739
+ enqueueHostToolOutcome({
740
+ toolCall,
741
+ outcome: { ok: false, error },
742
+ });
743
+ });
744
+ await publishToolExecutions();
745
+ return 'continued';
746
+ }
747
+
702
748
  await lifecycle.start(input.model);
703
749
  toolExecutions.set(rawToolCall.toolCallId, {
704
750
  toolCall: toolCall as TypedToolCall<TOOLS>,
@@ -706,6 +752,7 @@ export function runPrompt<
706
752
  const executionStartedAt = Date.now();
707
753
  const execution = await maybeExecuteHostTool({
708
754
  event: rawToolCall,
755
+ parsedToolCall: validatedToolCall,
709
756
  tools: activeTools,
710
757
  toolsContext,
711
758
  wrappedExecuteTool: lifecycle.executeTool,
@@ -886,7 +933,6 @@ export function runPrompt<
886
933
  if (settledHostInputReplay || settledBuiltinApprovalReplay) {
887
934
  continue;
888
935
  }
889
-
890
936
  if (displayValue.type === 'finish-step' && closingResumedStep) {
891
937
  closingResumedStep = false;
892
938
  await publishToolExecutions();
@@ -971,15 +1017,34 @@ export function runPrompt<
971
1017
 
972
1018
  // Tool-call validation lives here (not in translateStreamPart) because
973
1019
  // schema parsing is async and needs the merged tool set in scope.
1020
+ let validatedHostToolCall: ToolCallTextStreamPart | undefined;
974
1021
  if (displayValue.type === 'tool-call') {
1022
+ const isHostTool =
1023
+ value.type === 'tool-call' &&
1024
+ !value.providerExecuted &&
1025
+ hasTool({ tools: activeTools, toolName: value.toolName }) &&
1026
+ !(
1027
+ value.toolName === 'askUserQuestions' &&
1028
+ hasTool({
1029
+ tools: input.harness.builtinTools,
1030
+ toolName: value.toolName,
1031
+ })
1032
+ );
975
1033
  const parsed = await validateToolCall<TOOLS>({
976
- event: displayValue,
1034
+ event: isHostTool ? value : displayValue,
977
1035
  tools: input.tools,
978
1036
  });
979
1037
  const parsedToolCall = asToolCallTextStreamPart({ part: parsed });
1038
+ validatedHostToolCall = isHostTool ? parsedToolCall : undefined;
1039
+ const displayToolCall = isHostTool
1040
+ ? displayHostToolCall({
1041
+ toolCall: parsedToolCall,
1042
+ sessionWorkDir: input.sessionWorkDir,
1043
+ })
1044
+ : parsedToolCall;
980
1045
  rawToolCallsByToolCallId.set(displayValue.toolCallId, displayValue);
981
- toolCallsByToolCallId.set(displayValue.toolCallId, parsedToolCall);
982
- result.enqueue(parsed);
1046
+ toolCallsByToolCallId.set(displayValue.toolCallId, displayToolCall);
1047
+ result.enqueue(displayToolCall as TextStreamPart<TOOLS>);
983
1048
  }
984
1049
 
985
1050
  if (value.type === 'text-delta') {
@@ -1155,6 +1220,31 @@ export function runPrompt<
1155
1220
  await finishForHostInputPause({ completeCurrentStep: true });
1156
1221
  return;
1157
1222
  }
1223
+ if (validatedHostToolCall == null) {
1224
+ throw new Error(
1225
+ `Harness '${input.harness.harnessId}' could not validate host tool '${toolCall.toolName}'.`,
1226
+ );
1227
+ }
1228
+ if (validatedHostToolCall.invalid) {
1229
+ settledHostToolCallIds.add(toolCall.toolCallId);
1230
+ toolExecutions.delete(toolCall.toolCallId);
1231
+ bufferedToolOutcomes.push(() => {
1232
+ result.enqueue({
1233
+ type: 'tool-error',
1234
+ toolCallId: toolCall.toolCallId,
1235
+ toolName: toolCall.toolName,
1236
+ input: parsedToolCall.input,
1237
+ error: new Error(invalidToolInputMessage),
1238
+ dynamic: true,
1239
+ } as TextStreamPart<TOOLS>);
1240
+ });
1241
+ await submitToolResult({
1242
+ toolCallId: toolCall.toolCallId,
1243
+ output: { error: invalidToolInputMessage },
1244
+ isError: true,
1245
+ });
1246
+ continue;
1247
+ }
1158
1248
  const customToolApprovalDecision = resolveCustomToolApproval({
1159
1249
  toolName: toolCall.toolName,
1160
1250
  toolApproval: input.toolApproval,
@@ -1266,6 +1356,7 @@ export function runPrompt<
1266
1356
  const executionStartedAt = Date.now();
1267
1357
  const execution = await maybeExecuteHostTool({
1268
1358
  event: toolCall,
1359
+ parsedToolCall: validatedHostToolCall,
1269
1360
  tools: activeTools,
1270
1361
  toolsContext,
1271
1362
  wrappedExecuteTool: lifecycle.executeTool,
@@ -1414,6 +1505,22 @@ function asToolCallTextStreamPart<TOOLS extends ToolSet>(input: {
1414
1505
  return input.part as ToolCallTextStreamPart;
1415
1506
  }
1416
1507
 
1508
+ function displayHostToolCall(input: {
1509
+ toolCall: ToolCallTextStreamPart;
1510
+ sessionWorkDir: string;
1511
+ }): ToolCallTextStreamPart {
1512
+ return {
1513
+ ...input.toolCall,
1514
+ input: stripParsedToolInputWorkDir({
1515
+ value: input.toolCall.input,
1516
+ sessionWorkDir: input.sessionWorkDir,
1517
+ }),
1518
+ ...(input.toolCall.invalid
1519
+ ? { error: new Error(invalidToolInputMessage) }
1520
+ : {}),
1521
+ };
1522
+ }
1523
+
1417
1524
  type ToolCallTextStreamPart = {
1418
1525
  readonly type: 'tool-call';
1419
1526
  readonly toolCallId: string;
@@ -1433,6 +1540,7 @@ function hasTool(input: { tools: ToolSet; toolName: string }): boolean {
1433
1540
 
1434
1541
  async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
1435
1542
  event: { toolCallId: string; toolName: string; input: string };
1543
+ parsedToolCall: ToolCallTextStreamPart;
1436
1544
  tools: TOOLS;
1437
1545
  toolsContext: InferToolSetContext<TOOLS>;
1438
1546
  wrappedExecuteTool: TurnLifecycle<ToolSet, Context>['executeTool'];
@@ -1450,8 +1558,15 @@ async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
1450
1558
 
1451
1559
  if (!isExecutableTool(tool)) return { executed: false };
1452
1560
 
1453
- const parsed = await safeParseJSON({ text: input.event.input });
1454
- const args = parsed.success ? parsed.value : input.event.input;
1561
+ if (input.parsedToolCall.invalid) {
1562
+ const error = new Error(invalidToolInputMessage);
1563
+ await input.submitToolResult({
1564
+ toolCallId: input.event.toolCallId,
1565
+ output: { error: invalidToolInputMessage },
1566
+ isError: true,
1567
+ });
1568
+ return { executed: true, outcome: { ok: false, error } };
1569
+ }
1455
1570
 
1456
1571
  let context: unknown;
1457
1572
  try {
@@ -1489,7 +1604,7 @@ async function maybeExecuteHostTool<TOOLS extends ToolSet>(input: {
1489
1604
  let output: unknown;
1490
1605
  const stream = executeTool({
1491
1606
  tool,
1492
- input: args as never,
1607
+ input: input.parsedToolCall.input as never,
1493
1608
  options: {
1494
1609
  toolCallId: input.event.toolCallId,
1495
1610
  messages: [],
@@ -6,6 +6,10 @@ import { resolveSandboxDefaultWorkingDirectory } from '../../utils/resolve-sandb
6
6
  import { resolveSandboxHomeDir } from '../../utils/sandbox-home-dir';
7
7
  import type { HarnessAgentSandboxConfig } from '../harness-agent-settings';
8
8
  import { applyBootstrapRecipe, hashHarnessBootstrap } from './bootstrap-recipe';
9
+ import {
10
+ hasOnBootstrapMarker,
11
+ writeOnBootstrapMarker,
12
+ } from './on-bootstrap-marker';
9
13
 
10
14
  const SANDBOX_BOOTSTRAP_IDENTITY_VERSION = 1;
11
15
 
@@ -125,6 +129,7 @@ export async function createSandboxBootstrapPlan({
125
129
  recipeIdentity,
126
130
  workDir,
127
131
  onBootstrap: settings.onBootstrap,
132
+ bootstrapHash: settings.bootstrapHash,
128
133
  abortSignal: opts.abortSignal,
129
134
  }),
130
135
  }
@@ -138,6 +143,8 @@ export async function runSandboxBootstrap({
138
143
  recipeIdentity,
139
144
  workDir,
140
145
  onBootstrap,
146
+ bootstrapHash,
147
+ skipOnBootstrapIfMarked = false,
141
148
  defaultWorkingDirectory,
142
149
  abortSignal,
143
150
  }: {
@@ -146,6 +153,8 @@ export async function runSandboxBootstrap({
146
153
  readonly recipeIdentity?: string;
147
154
  readonly workDir?: string;
148
155
  readonly onBootstrap?: SandboxBootstrapSettings['onBootstrap'];
156
+ readonly bootstrapHash?: string;
157
+ readonly skipOnBootstrapIfMarked?: boolean;
149
158
  readonly defaultWorkingDirectory?: string;
150
159
  readonly abortSignal?: AbortSignal;
151
160
  }): Promise<void> {
@@ -173,6 +182,13 @@ export async function runSandboxBootstrap({
173
182
 
174
183
  if (onBootstrap == null) return;
175
184
 
185
+ if (
186
+ skipOnBootstrapIfMarked &&
187
+ bootstrapHash != null &&
188
+ (await hasOnBootstrapMarker({ session, bootstrapHash, abortSignal }))
189
+ )
190
+ return;
191
+
176
192
  const resolvedDefaultWorkingDirectory =
177
193
  defaultWorkingDirectory ??
178
194
  (await resolveSandboxDefaultWorkingDirectory({
@@ -194,6 +210,9 @@ export async function runSandboxBootstrap({
194
210
  abortSignal,
195
211
  });
196
212
  await onBootstrap({ session, workDir: bootstrapWorkDir, abortSignal });
213
+ if (bootstrapHash != null) {
214
+ await writeOnBootstrapMarker({ session, bootstrapHash, abortSignal });
215
+ }
197
216
  }
198
217
 
199
218
  export async function ensureSandboxDirectory({
@@ -120,6 +120,63 @@ export function stripWorkDir(
120
120
  }
121
121
  }
122
122
 
123
+ export function stripParsedToolInputWorkDir({
124
+ value,
125
+ sessionWorkDir,
126
+ }: {
127
+ value: unknown;
128
+ sessionWorkDir: string;
129
+ }): unknown {
130
+ if (sessionWorkDir.length === 0) return value;
131
+
132
+ const projected = new WeakMap<object, unknown>();
133
+ const pending: Array<{
134
+ source: object;
135
+ target: Record<string, unknown> | unknown[];
136
+ }> = [];
137
+
138
+ const project = (item: unknown): unknown => {
139
+ if (typeof item === 'string') return stripString(item, sessionWorkDir);
140
+ if (item === null || typeof item !== 'object') return item;
141
+ if (
142
+ !Array.isArray(item) &&
143
+ Object.getPrototypeOf(item) !== Object.prototype &&
144
+ Object.getPrototypeOf(item) !== null
145
+ ) {
146
+ return item;
147
+ }
148
+ if (projected.has(item)) return projected.get(item);
149
+
150
+ const target: Record<string, unknown> | unknown[] = Array.isArray(item)
151
+ ? new Array(item.length)
152
+ : {};
153
+ projected.set(item, target);
154
+ pending.push({ source: item, target });
155
+ return target;
156
+ };
157
+
158
+ const result = project(value);
159
+ while (pending.length > 0) {
160
+ const { source, target } = pending.pop()!;
161
+ if (Array.isArray(source) && Array.isArray(target)) {
162
+ for (let index = 0; index < source.length; index++) {
163
+ if (index in source) target[index] = project(source[index]);
164
+ }
165
+ } else {
166
+ for (const [key, item] of Object.entries(source)) {
167
+ Object.defineProperty(target, key, {
168
+ value: project(item),
169
+ enumerable: true,
170
+ writable: true,
171
+ configurable: true,
172
+ });
173
+ }
174
+ }
175
+ }
176
+
177
+ return result;
178
+ }
179
+
123
180
  /**
124
181
  * Replace occurrences of the working directory in a string. A reference to the
125
182
  * directory followed by a separator becomes workspace-relative