@ai-sdk/harness 1.0.70 → 1.0.72

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.
@@ -248,6 +248,25 @@ export function runPrompt<
248
248
  | Extract<HarnessV1StreamPart, { type: 'finish' }>
249
249
  | undefined;
250
250
  const completedSteps: Array<StepResult<TOOLS, RUNTIME_CONTEXT>> = [];
251
+ const outstandingHostToolExecutions: Promise<void>[] = [];
252
+ const startHostToolExecution = (execution: Promise<void>): void => {
253
+ outstandingHostToolExecutions.push(execution);
254
+ // The execution is joined at the next step boundary. Attach a rejection
255
+ // handler immediately so failures cannot become unhandled in the
256
+ // meantime; awaiting the original promise still propagates the failure.
257
+ void execution.catch(() => {});
258
+ };
259
+ const waitForOutstandingHostToolExecutions = async (): Promise<void> => {
260
+ if (outstandingHostToolExecutions.length === 0) return;
261
+ const executions = outstandingHostToolExecutions.splice(0);
262
+ const results = await Promise.allSettled(executions);
263
+ const failedExecution = results.find(
264
+ result => result.status === 'rejected',
265
+ );
266
+ if (failedExecution != null) {
267
+ throw failedExecution.reason;
268
+ }
269
+ };
251
270
  const releasePendingStopBoundary = (): void => {
252
271
  pendingStopBoundary?.releaseCheckpoint?.();
253
272
  pendingStopBoundary = undefined;
@@ -311,6 +330,7 @@ export function runPrompt<
311
330
  const finishForHostInputPause = async (options: {
312
331
  completeCurrentStep: boolean;
313
332
  }): Promise<void> => {
333
+ await waitForOutstandingHostToolExecutions();
314
334
  if (options.completeCurrentStep) {
315
335
  await completeStep({
316
336
  finishReason: toolCallsFinishReason,
@@ -665,6 +685,7 @@ export function runPrompt<
665
685
  * or a second `error` part from `fail`).
666
686
  */
667
687
  if (value.type === 'error' && displayValue.type === 'error') {
688
+ await waitForOutstandingHostToolExecutions();
668
689
  // Telemetry and stderr diagnostics keep the raw error (absolute
669
690
  // paths help debugging); the consumer-facing settle uses the
670
691
  // workDir-stripped one, like every other forwarded part.
@@ -784,6 +805,7 @@ export function runPrompt<
784
805
 
785
806
  // Drive step boundaries.
786
807
  if (value.type === 'finish-step') {
808
+ await waitForOutstandingHostToolExecutions();
787
809
  await completeStep({
788
810
  finishReason: value.finishReason,
789
811
  usage: value.usage,
@@ -799,6 +821,7 @@ export function runPrompt<
799
821
  }
800
822
 
801
823
  if (value.type === 'finish') {
824
+ await waitForOutstandingHostToolExecutions();
802
825
  finalFinish = value;
803
826
  await telemetry.end({
804
827
  finishReason: value.finishReason,
@@ -910,52 +933,62 @@ export function runPrompt<
910
933
  await finishForHostInputPause({ completeCurrentStep: true });
911
934
  return;
912
935
  }
913
- const execution = await maybeExecuteHostTool({
914
- event: toolCall,
915
- tools: activeTools,
916
- wrappedExecuteTool: telemetry.executeTool,
917
- sandboxSession: input.sandboxSession,
918
- abortSignal: input.abortSignal,
919
- control,
920
- onPreliminaryResult: preliminaryOutput => {
921
- /*
922
- * Project a `yield`ed value as a preliminary AI SDK
923
- * `tool-result` part. Unlike the final result — which is
924
- * submitted to the runtime, echoed back as a `tool-result`
925
- * event, and stripped on its way through the loop above —
926
- * preliminary values never reach the runtime, so strip the
927
- * working directory here to match the final result's projection.
928
- */
929
- const stripped = stripWorkDir(
930
- {
931
- type: 'tool-result',
932
- toolCallId: toolCall.toolCallId,
933
- toolName: toolCall.toolName,
934
- result: preliminaryOutput as Extract<
935
- HarnessV1StreamPart,
936
- { type: 'tool-result' }
937
- >['result'],
938
- },
939
- input.sessionWorkDir,
940
- ) as Extract<HarnessV1StreamPart, { type: 'tool-result' }>;
941
- result.enqueue({
942
- type: 'tool-result',
943
- toolCallId: toolCall.toolCallId,
944
- toolName: toolCall.toolName,
945
- input: undefined,
946
- output: stripped.result,
947
- preliminary: true,
948
- } as TextStreamPart<TOOLS>);
949
- },
950
- });
951
- if (!execution.executed) {
936
+ if (!isExecutableTool(activeTools[toolCall.toolName])) {
952
937
  recordPendingToolResult({ toolCall });
953
938
  await finishForHostInputPause({ completeCurrentStep: true });
954
939
  return;
955
940
  }
956
- await telemetry.toolEnd(toolCall.toolCallId, execution.outcome);
941
+ startHostToolExecution(
942
+ (async () => {
943
+ const execution = await maybeExecuteHostTool({
944
+ event: toolCall,
945
+ tools: activeTools,
946
+ wrappedExecuteTool: telemetry.executeTool,
947
+ sandboxSession: input.sandboxSession,
948
+ abortSignal: input.abortSignal,
949
+ control,
950
+ onPreliminaryResult: preliminaryOutput => {
951
+ /*
952
+ * Project a `yield`ed value as a preliminary AI SDK
953
+ * `tool-result` part. Unlike the final result — which is
954
+ * submitted to the runtime, echoed back as a `tool-result`
955
+ * event, and stripped on its way through the loop above —
956
+ * preliminary values never reach the runtime, so strip the
957
+ * working directory here to match the final result's projection.
958
+ */
959
+ const stripped = stripWorkDir(
960
+ {
961
+ type: 'tool-result',
962
+ toolCallId: toolCall.toolCallId,
963
+ toolName: toolCall.toolName,
964
+ result: preliminaryOutput as Extract<
965
+ HarnessV1StreamPart,
966
+ { type: 'tool-result' }
967
+ >['result'],
968
+ },
969
+ input.sessionWorkDir,
970
+ ) as Extract<HarnessV1StreamPart, { type: 'tool-result' }>;
971
+ result.enqueue({
972
+ type: 'tool-result',
973
+ toolCallId: toolCall.toolCallId,
974
+ toolName: toolCall.toolName,
975
+ input: undefined,
976
+ output: stripped.result,
977
+ preliminary: true,
978
+ } as TextStreamPart<TOOLS>);
979
+ },
980
+ });
981
+ if (!execution.executed) {
982
+ throw new Error(
983
+ `Harness '${input.harness.harnessId}' could not execute host tool '${toolCall.toolName}'.`,
984
+ );
985
+ }
986
+ await telemetry.toolEnd(toolCall.toolCallId, execution.outcome);
987
+ })(),
988
+ );
957
989
  }
958
990
  }
991
+ await waitForOutstandingHostToolExecutions();
959
992
  const isTurnSuspending = input.isTurnSuspending?.() === true;
960
993
  if (isTurnSuspending) {
961
994
  if (finalFinish == null) {
@@ -982,6 +1015,11 @@ export function runPrompt<
982
1015
  : undefined,
983
1016
  );
984
1017
  } catch (err) {
1018
+ try {
1019
+ await waitForOutstandingHostToolExecutions();
1020
+ } catch {
1021
+ // Preserve the error that stopped the reader loop.
1022
+ }
985
1023
  await telemetry.error(err);
986
1024
  logBridgeError({
987
1025
  harnessId: input.harness.harnessId,
@@ -36,6 +36,9 @@ export type PrepareSandboxForHarnessResult = {
36
36
  * When a later `HarnessAgent` session uses a sandbox created from the persisted
37
37
  * artifact, the adapter recomputes the same recipe identity and the existing
38
38
  * bootstrap marker makes the bootstrap logic a no-op.
39
+ *
40
+ * Repeated harness IDs are prepared once. When multiple adapters use the same
41
+ * ID, the last adapter in `harnesses` is used.
39
42
  */
40
43
  export async function prepareSandboxForHarness(options: {
41
44
  readonly session: SandboxSession;
@@ -52,10 +55,11 @@ export async function prepareSandboxForHarness(options: {
52
55
  );
53
56
  }
54
57
 
55
- const harnesses = [...options.harnesses].sort((a, b) =>
56
- a.harnessId.localeCompare(b.harnessId),
57
- );
58
- assertUniqueHarnessIds(harnesses);
58
+ const harnesses = [
59
+ ...new Map(
60
+ options.harnesses.map(harness => [harness.harnessId, harness]),
61
+ ).values(),
62
+ ].sort((a, b) => a.harnessId.localeCompare(b.harnessId));
59
63
 
60
64
  const workDir =
61
65
  sandboxConfig.workDir == null
@@ -112,20 +116,6 @@ export async function prepareSandboxForHarness(options: {
112
116
  };
113
117
  }
114
118
 
115
- function assertUniqueHarnessIds(
116
- harnesses: ReadonlyArray<HarnessAgentAdapter>,
117
- ): void {
118
- const seen = new Set<string>();
119
- for (const harness of harnesses) {
120
- if (seen.has(harness.harnessId)) {
121
- throw new Error(
122
- `prepareSandboxForHarness: duplicate harness id "${harness.harnessId}".`,
123
- );
124
- }
125
- seen.add(harness.harnessId);
126
- }
127
- }
128
-
129
119
  async function resolvePreparedSandboxIdentity({
130
120
  recipeIdentities,
131
121
  bootstrapHash,
@@ -8,8 +8,8 @@ const symbol = Symbol.for(marker);
8
8
  /**
9
9
  * Thrown when a caller asks the harness to do something the adapter (or the
10
10
  * supplied sandbox) does not support, e.g. requesting manual compaction from
11
- * an adapter that only auto-compacts, or invoking `getPortUrl` on a sandbox
12
- * that does not expose one.
11
+ * an adapter that only auto-compacts, or invoking `getPortEndpoint` on a
12
+ * sandbox that does not expose one.
13
13
  *
14
14
  * The caller supplies the full human-readable message. Optional `harnessId`
15
15
  * is recorded as structured context for tooling.
@@ -6,6 +6,11 @@ export {
6
6
  } from './sandbox-channel';
7
7
  export { classifyDiskLog, type DiskLogRecoveryMode } from './classify-disk-log';
8
8
  export { getAiGatewayAuthFromEnv } from './ai-gateway-auth';
9
+ export {
10
+ createCredentialRequestTransformation,
11
+ maskSandboxCredentials,
12
+ warnCredentialBrokeringUnavailable,
13
+ } from './sandbox-credential-brokering';
9
14
  export { resolveSandboxHomeDir } from './sandbox-home-dir';
10
15
  export { shellQuote } from './shell-quote';
11
16
  export {
@@ -0,0 +1,41 @@
1
+ import type { HarnessV1RequestTransformation } from '../v1';
2
+
3
+ export function warnCredentialBrokeringUnavailable(): void {
4
+ console.warn(
5
+ 'The sandbox implementation does not support configuring request transformations, so credential brokering does not work. Falling back to less secure credential forwarding.',
6
+ );
7
+ }
8
+
9
+ export function maskSandboxCredentials({
10
+ environment,
11
+ credentialEnvironmentVariables,
12
+ }: {
13
+ environment: Readonly<Record<string, string>>;
14
+ credentialEnvironmentVariables: ReadonlyArray<string>;
15
+ }): Record<string, string> {
16
+ const maskedEnvironment = { ...environment };
17
+ for (const name of credentialEnvironmentVariables) {
18
+ if (maskedEnvironment[name] != null) {
19
+ maskedEnvironment[name] = name;
20
+ }
21
+ }
22
+ return maskedEnvironment;
23
+ }
24
+
25
+ export function createCredentialRequestTransformation({
26
+ baseUrl,
27
+ headers,
28
+ }: {
29
+ baseUrl: string;
30
+ headers: Readonly<Record<string, string>>;
31
+ }): HarnessV1RequestTransformation {
32
+ const url = new URL(baseUrl);
33
+ const pathname = url.pathname.replace(/\/+$/, '');
34
+ return {
35
+ match: {
36
+ host: url.hostname,
37
+ ...(pathname.length === 0 ? {} : { path: { startsWith: pathname } }),
38
+ },
39
+ transform: { headers },
40
+ };
41
+ }
@@ -1,5 +1,14 @@
1
1
  import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/provider-utils';
2
2
 
3
+ /**
4
+ * Connection details for a sandbox-exposed port. Headers are scoped to the
5
+ * returned URL and must be included when opening the connection.
6
+ */
7
+ export type HarnessV1PortEndpoint = {
8
+ readonly url: string;
9
+ readonly headers?: Readonly<Record<string, string>>;
10
+ };
11
+
3
12
  /**
4
13
  * Network sandbox session returned by `HarnessV1SandboxProvider.createSession()`. The
5
14
  * harness keeps this for the lifetime of a session. It is itself a
@@ -8,8 +17,8 @@ import type { Experimental_SandboxSession as SandboxSession } from '@ai-sdk/prov
8
17
  *
9
18
  * Code that should only touch the filesystem and spawn processes receives the
10
19
  * reduced view from {@link HarnessV1NetworkSandboxSession.restricted}, never the
11
- * network sandbox session itself — so it cannot stop the sandbox or change its
12
- * network policy.
20
+ * network sandbox session itself — so it cannot stop the sandbox, change
21
+ * network access, or transform requests.
13
22
  */
14
23
  export interface HarnessV1NetworkSandboxSession extends SandboxSession {
15
24
  /**
@@ -36,13 +45,23 @@ export interface HarnessV1NetworkSandboxSession extends SandboxSession {
36
45
  */
37
46
  readonly defaultWorkingDirectory: string;
38
47
 
39
- /** Ports the sandbox exposes; resolvable to public URLs via `getPortUrl`. */
48
+ /** Ports the sandbox exposes; resolvable via `getPortEndpoint`. */
40
49
  readonly ports: ReadonlyArray<number>;
41
50
 
42
51
  /**
43
- * Resolve a publicly-reachable URL for a sandbox-exposed port. Bridge-backed
52
+ * Resolve the connection details for a sandbox-exposed port. Bridge-backed
44
53
  * adapters call this to open their WebSocket to the in-sandbox bridge.
45
54
  */
55
+ readonly getPortEndpoint: (options: {
56
+ port: number;
57
+ protocol?: 'http' | 'https' | 'ws';
58
+ }) => PromiseLike<HarnessV1PortEndpoint>;
59
+
60
+ /**
61
+ * Resolve a publicly-reachable URL for a sandbox-exposed port.
62
+ *
63
+ * @deprecated Use `getPortEndpoint` instead.
64
+ */
46
65
  readonly getPortUrl: (options: {
47
66
  port: number;
48
67
  protocol?: 'http' | 'https' | 'ws';
@@ -68,6 +87,29 @@ export interface HarnessV1NetworkSandboxSession extends SandboxSession {
68
87
  policy: HarnessV1NetworkPolicy,
69
88
  ) => PromiseLike<void>;
70
89
 
90
+ /**
91
+ * Replace the sandbox's outbound request-transformation rules. Optional —
92
+ * implementations expose this only when credentials can be injected outside
93
+ * the sandbox security boundary. Calling this method assumes authority over
94
+ * the complete transformation set; harness adapters should normally use
95
+ * `addRequestTransformations` instead. Adapters may preserve legacy
96
+ * credential-forwarding behavior when additive request transformations are
97
+ * unavailable.
98
+ */
99
+ readonly setRequestTransformations?: (
100
+ transformations: ReadonlyArray<HarnessV1RequestTransformation>,
101
+ ) => PromiseLike<void>;
102
+
103
+ /**
104
+ * Add outbound request-transformation rules without replacing rules already
105
+ * managed by the sandbox session. Optional for the same reason as
106
+ * `setRequestTransformations`. Harness adapters should use this additive
107
+ * capability unless they explicitly own the complete transformation set.
108
+ */
109
+ readonly addRequestTransformations?: (
110
+ transformations: ReadonlyArray<HarnessV1RequestTransformation>,
111
+ ) => PromiseLike<void>;
112
+
71
113
  /**
72
114
  * Replace the set of ports exposed by the sandbox. Full-replacement
73
115
  * semantics: ports omitted from the array are deregistered. Optional —
@@ -86,7 +128,8 @@ export interface HarnessV1NetworkSandboxSession extends SandboxSession {
86
128
  *
87
129
  * The returned object points at exactly the same underlying sandbox
88
130
  * resource as the network sandbox session it was produced from; it is only a
89
- * narrower surface over the same resource, not a separate sandbox.
131
+ * narrower surface over the same resource, not a separate sandbox. In
132
+ * particular, it cannot mutate network access or request transformations.
90
133
  */
91
134
  readonly restricted: () => SandboxSession;
92
135
  }
@@ -121,3 +164,42 @@ export type HarnessV1NetworkPolicy =
121
164
  allowedCIDRs: ReadonlyArray<string>;
122
165
  deniedCIDRs?: ReadonlyArray<string>;
123
166
  };
167
+
168
+ type HarnessV1RequestTransformationPathMatcher =
169
+ | { exact: string }
170
+ | { startsWith: string }
171
+ | { regex: string };
172
+
173
+ type HarnessV1RequestTransformationKeyValuePartMatcher =
174
+ | { exact: string }
175
+ | { startsWith: string }
176
+ | { regex: string };
177
+
178
+ type HarnessV1RequestTransformationKeyValueMatcher = {
179
+ readonly key?: HarnessV1RequestTransformationKeyValuePartMatcher;
180
+ readonly value?: HarnessV1RequestTransformationKeyValuePartMatcher;
181
+ };
182
+
183
+ /**
184
+ * Outbound HTTPS request transformation applied outside the sandbox security
185
+ * boundary. The host is part of the match so each rule is self-contained and
186
+ * several rules, including several for the same host, can be installed at
187
+ * once.
188
+ *
189
+ * Credential values belong in `transform.headers`, while the sandbox process
190
+ * receives only a non-secret placeholder. Implementations must overwrite
191
+ * matching request headers after the request leaves the sandbox rather than
192
+ * making transformed values available inside it.
193
+ */
194
+ export type HarnessV1RequestTransformation = {
195
+ readonly match: {
196
+ readonly host: string;
197
+ readonly path?: HarnessV1RequestTransformationPathMatcher;
198
+ readonly method?: ReadonlyArray<string>;
199
+ readonly queryString?: ReadonlyArray<HarnessV1RequestTransformationKeyValueMatcher>;
200
+ readonly headers?: ReadonlyArray<HarnessV1RequestTransformationKeyValueMatcher>;
201
+ };
202
+ readonly transform: {
203
+ readonly headers: Readonly<Record<string, string>>;
204
+ };
205
+ };
@@ -78,8 +78,10 @@ export type HarnessV1StartOptions = {
78
78
  * Network sandbox session the adapter operates against. It is owned and
79
79
  * lifecycled by `HarnessAgent`. Adapters call `restricted()` for the
80
80
  * tool-safe filesystem/exec/spawn surface, and use the infra methods
81
- * (`getPortUrl`, `ports`, `setNetworkPolicy`) for bridge wiring. Adapters
82
- * must not call `stop()` themselves; the agent does that during cleanup.
81
+ * (`getPortEndpoint`, `ports`, `setNetworkPolicy`,
82
+ * `setRequestTransformations`, `addRequestTransformations`) for bridge
83
+ * wiring. Adapters must not call `stop()` themselves; the agent does that
84
+ * during cleanup.
83
85
  */
84
86
  readonly sandboxSession: HarnessV1NetworkSandboxSession;
85
87
 
package/src/v1/index.ts CHANGED
@@ -36,6 +36,8 @@ export type {
36
36
  export type {
37
37
  HarnessV1NetworkPolicy,
38
38
  HarnessV1NetworkSandboxSession,
39
+ HarnessV1PortEndpoint,
40
+ HarnessV1RequestTransformation,
39
41
  } from './harness-v1-network-sandbox-session';
40
42
  export type { HarnessV1Skill } from './harness-v1-skill';
41
43
  export type { HarnessV1StreamPart } from './harness-v1-stream-part';