@ai-sdk/harness 1.0.122 → 1.0.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.124",
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.114"
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.114",
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
  }
@@ -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: [],
@@ -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
@@ -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
@@ -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
  }