@tangle-network/agent-provider-tangle 1.0.2 → 1.1.1

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/README.md CHANGED
@@ -15,6 +15,75 @@ const provider = createTangleProvider({
15
15
  });
16
16
  ```
17
17
 
18
+ ## `create()` returns a ready environment
19
+
20
+ `provider.create()` does not return until the sandbox reports `running`.
21
+ This is the contract `AgentEnvironmentProvider.create` states, and every runtime seam relies on it: a caller streams the first turn immediately after create, with nothing in between.
22
+ A sandbox that never reaches running fails the create call with the platform's reason, and the adapter deletes the sandbox it could not hand over.
23
+
24
+ The gap this closes is narrow and specific.
25
+ `client.create()` waits by itself only when the create response reports `pending` or `provisioning`.
26
+ A response that already reports `running` skips that wait, and `running` alone is not usable: the SDK's own `waitFor` treats the target as reached only when `filesystemIncarnationReadiness` is `ready`, because the sandbox filesystem is still being built until then.
27
+ So `create()` can return a sandbox that reports `running` while the platform still holds a lifecycle operation on it, and the first turn lands on that lock.
28
+
29
+ Composing an environment also reads the sandbox's deployment capability document once, and a sandbox that is not yet running cannot answer that read, so an environment composed during provisioning would claim nothing for the rest of its life.
30
+
31
+ `readyTimeoutMs` bounds the wait and defaults to `DEFAULT_TANGLE_READY_TIMEOUT_MS` (120 seconds, the Sandbox SDK's own default).
32
+
33
+ ```ts
34
+ const provider = createTangleProvider({
35
+ client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }),
36
+ readyTimeoutMs: 180_000,
37
+ });
38
+
39
+ const environment = await provider.create({ profile: { name: "worker" } });
40
+ for await (const event of environment.stream({ prompt: "run the task" })) {
41
+ // The sandbox is running. No caller-side wait, poll, or retry stands here.
42
+ }
43
+ ```
44
+
45
+ The platform owns the wait, and this adapter runs no status loop of its own.
46
+ It calls `waitFor("running")` on the created instance when the linked SDK offers it, because that refreshes the created instance in place and keeps its create receipt.
47
+ It falls back to `client.waitForRunning(id)`, then refreshes the created instance.
48
+ A client that offers neither cannot prove readiness, so the sandbox it returned has to report `running` by itself or the create call fails.
49
+
50
+ The adapter reads the platform's answer back rather than trusting the wait to have resolved for the right reason.
51
+ A create call returns only a sandbox that reports `running`: `failed`, `stopped`, and `expired` are refused with the status named, and so is a wait that resolves while the sandbox still reports `provisioning`.
52
+
53
+ ## Per-turn backend options
54
+
55
+ `AgentTurnInput.providerOptions.backend` reaches `PromptOptions.backend` on the Sandbox prompt call.
56
+ This is the exact block agent-runtime emits for a per-turn backend or model override, so a turn can select its model, its inline profile, or a session credential bundle without any change to the environment.
57
+
58
+ ```ts
59
+ await environment.stream({
60
+ prompt: "run the task",
61
+ providerOptions: {
62
+ backend: {
63
+ type: "opencode",
64
+ model: {
65
+ provider: "zai",
66
+ model: "glm-5.2",
67
+ authMode: "oauth",
68
+ authFiles: [{ path: ".config/opencode/auth.json", content: seatCredentials, mode: 0o600 }],
69
+ },
70
+ },
71
+ },
72
+ });
73
+ ```
74
+
75
+ A field the Sandbox prompt options do not declare is refused, in `providerOptions`, in `backend`, and in `backend.model`.
76
+ The SDK drops what it does not declare, so a forwarded unknown field would run the turn on different settings with no error anywhere.
77
+ The accepted field sets are pinned to the SDK's `BackendConfig` at compile time, at all three levels including `authFiles`, so a field the SDK adds or removes fails this package's build instead of reaching a caller as a wrong refusal.
78
+ Values are checked, not just field names: an inline `backend.profile` is read by `agentProfileSchema`, the package that owns profile rules, and `backend.metadata.traceAttributes` is held to the entry and length limits the Sandbox platform states for it.
79
+ `AgentTurnInput.interactions` and `backend.interactions` state the same posture, and a disagreement between them is refused rather than resolved by preference.
80
+ A turn `model` that disagrees with `backend.model.model` is refused for the same reason.
81
+ `AgentTurnInput.interactions` still maps to `backend.interactions` for a turn that carries no backend block at all.
82
+
83
+ Backend options are part of the retained request digest, so a retry under the same `turnId` with a changed model, profile, or seat conflicts instead of replaying work that ran on other settings.
84
+ Bearer material is excluded from that identity: `model.apiKey` is dropped and `authFiles` are reduced to the paths and modes they install.
85
+ A rotated seat token is the same seat running the same work, so an ordinary refresh continues its run rather than conflicting with it.
86
+
18
87
  Detached dispatch returns the immutable Sandbox execution receipt in `controlRef`.
19
88
  The adapter validates its complete capability document and omits optional environment methods whose capabilities are disabled.
20
89
  Created and reconstructed environments expose a recursively frozen Sandbox metadata snapshot for constant-time annotation checks.
@@ -22,8 +91,6 @@ Sandbox metadata can include caller-authored values and does not authenticate it
22
91
  Reconstruct an exact session with `environment.session(reference.id, { controlRef: reference.controlRef })`; replay cursors are exclusive at both the agent interface and Sandbox session stream.
23
92
  Result, replay, and cancel operations select that exact execution instead of whichever execution most recently changed the shared session.
24
93
  Session status with an exact control reference reports a state only when the payload names that execution; a payload bound to a different or unnamed execution reports `unknown`.
25
- `AgentTurnInput.interactions` maps unchanged to `PromptOptions.backend.interactions` for the selected Sandbox turn.
26
- The requested posture is part of the retained request digest, so a retry with changed interaction behavior conflicts instead of reusing prior work.
27
94
 
28
95
  ## Two capability documents
29
96
 
@@ -4,4 +4,6 @@ export declare function createTangleExactProcessProvider(input: {
4
4
  client: SandboxClientLike;
5
5
  options: TangleExactProcessOptions;
6
6
  providerName: string;
7
+ /** How long create() waits for the new sandbox to reach `running`. */
8
+ readyTimeoutMs: number;
7
9
  }): AgentExactProcessProvider;
@@ -1,9 +1,10 @@
1
1
  import { assertBoundedJson, attachCleanupHandle, awaitWithSignal, boundedIdentifier, boundedString, exactProcessRequestDigest, isBoundedJson, MAX_LIST_RESULTS, SANDBOX_LIST_PAGE_SIZE, } from "./tangle-contract-safety.js";
2
2
  import { sandboxInstanceAsExactProcessEnvironment } from "./tangle-exact-process-environment.js";
3
+ import { awaitSandboxRunning } from "./tangle-readiness.js";
3
4
  import { assertExactProcessSandbox, assertSupportedProviderOptions, assertUnreservedMetadata, EXACT_PROCESS_METADATA_KEY, isExactProcessRequestConflict, isExactProcessSandbox, metadataMatches, assertSignalOptions, } from "./tangle-exact-process-validation.js";
4
5
  const IMMUTABLE_TANGLE_IMAGE = /^(?:sha256:[a-f0-9]{64}|\S+@sha256:[a-f0-9]{64})$/i;
5
6
  export function createTangleExactProcessProvider(input) {
6
- const { client, options, providerName } = input;
7
+ const { client, options, providerName, readyTimeoutMs } = input;
7
8
  boundedIdentifier(providerName, "Tangle exact process provider");
8
9
  if (options.teamId !== undefined) {
9
10
  boundedIdentifier(options.teamId, "Tangle exact process team id");
@@ -51,7 +52,18 @@ export function createTangleExactProcessProvider(input) {
51
52
  }
52
53
  try {
53
54
  createInput.signal?.throwIfAborted();
55
+ // Ownership is decided from metadata this call already holds, so it is
56
+ // decided before the wait. A foreign sandbox returned under a reused
57
+ // idempotency key is a conflict now, not two minutes from now.
54
58
  assertExactProcessSandbox(box, providerName, options.teamId, identityDigest);
59
+ // A launch starts a process on this sandbox, so create() returns only
60
+ // after the sandbox can run one. The caller's provisioning budget owns
61
+ // the deadline when it named one.
62
+ await awaitSandboxRunning(box, client, {
63
+ timeoutMs: createInput.provisionTimeoutMs ?? readyTimeoutMs,
64
+ ...(createInput.signal ? { signal: createInput.signal } : {}),
65
+ });
66
+ createInput.signal?.throwIfAborted();
55
67
  return sandboxInstanceAsExactProcessEnvironment(box, providerName);
56
68
  }
57
69
  catch (error) {
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export type { TangleExactProcessOptions } from "./tangle-types.js";
2
2
  export * from "./tangle-types.js";
3
3
  export { createTangleProvider } from "./tangle-provider.js";
4
+ export { DEFAULT_TANGLE_READY_TIMEOUT_MS } from "./tangle-readiness.js";
4
5
  export { defaultTangleSandboxCapabilities } from "./tangle-capabilities.js";
5
6
  export { createTangleWorkspaceBranching, supportsWorkspaceBranching, } from "./tangle-workspace-branching.js";
6
7
  export type { TangleWorkspaceBranchingOptions } from "./tangle-workspace-branching.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from "./tangle-types.js";
2
2
  export { createTangleProvider } from "./tangle-provider.js";
3
+ export { DEFAULT_TANGLE_READY_TIMEOUT_MS } from "./tangle-readiness.js";
3
4
  export { defaultTangleSandboxCapabilities } from "./tangle-capabilities.js";
4
5
  export { createTangleWorkspaceBranching, supportsWorkspaceBranching, } from "./tangle-workspace-branching.js";
5
6
  export { decodeTangleConfidentialAttestationQuote, encodeTangleConfidentialAttestationQuote, MAX_TEE_EVIDENCE_BYTES, MAX_TEE_MEASUREMENT_BYTES, TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_KIND, TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_VERSION, } from "./tangle-confidential-attestation.js";
@@ -92,6 +92,13 @@ export function defaultTangleSandboxCapabilities(harness) {
92
92
  },
93
93
  placement: true,
94
94
  usage: false,
95
+ // Create carries both fields to the Sandbox API unchanged. The API still authorizes the
96
+ // caller for a delegated billing owner, and it rejects the create when it does not, so this
97
+ // states only that neither field is dropped on the way.
98
+ create: {
99
+ egress: ["open", "strict", "blocked"],
100
+ billingOwner: true,
101
+ },
95
102
  // This is intent only. Narrowing requires both raw TEE evidence and the
96
103
  // caller's external provider-key verifier before the flag survives.
97
104
  confidential: true,
@@ -1,4 +1,4 @@
1
- import { WorkspaceRequestSchema, workspaceCwdPathForBase, } from "@tangle-network/agent-interface/environment-provider";
1
+ import { AgentEnvironmentEgressPolicySchema, WorkspaceRequestSchema, workspaceCwdPathForBase, } from "@tangle-network/agent-interface/environment-provider";
2
2
  import { assertBoundedJson, boundedIdentifier, boundedString, MAX_ARRAY_LENGTH, MAX_MAP_ENTRIES, } from "./tangle-contract-safety.js";
3
3
  import { sandboxResourcesFromResourceRequest } from "./tangle-resources.js";
4
4
  export function sandboxOptionsFromCreateInput(input, defaultBackend, parsedWorkspace) {
@@ -32,6 +32,9 @@ export function sandboxOptionsFromCreateInput(input, defaultBackend, parsedWorks
32
32
  if (input.idempotencyKey !== undefined) {
33
33
  boundedIdentifier(input.idempotencyKey, "Tangle idempotency key");
34
34
  }
35
+ if (input.billingOwner !== undefined) {
36
+ boundedIdentifier(input.billingOwner, "Tangle billing owner");
37
+ }
35
38
  const resources = sandboxResourcesFromResourceRequest(input.resources);
36
39
  // Sandbox injects secrets by name from its own store. Accepting a name/value
37
40
  // record and dropping it would create an environment with no credentials and
@@ -53,6 +56,8 @@ export function sandboxOptionsFromCreateInput(input, defaultBackend, parsedWorks
53
56
  ...(resources ? { resources } : {}),
54
57
  ...(input.env ? { env: input.env } : {}),
55
58
  ...(Array.isArray(input.secrets) ? { secrets: input.secrets } : {}),
59
+ ...(input.egress === undefined ? {} : { egressPolicy: sandboxEgressPolicy(input.egress) }),
60
+ ...(input.billingOwner === undefined ? {} : { billingOwnerId: input.billingOwner }),
56
61
  ...(input.metadata ? { metadata: input.metadata } : {}),
57
62
  ...(input.name === undefined ? {} : { name: input.name }),
58
63
  ...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
@@ -64,6 +69,27 @@ export function sandboxOptionsFromCreateInput(input, defaultBackend, parsedWorks
64
69
  };
65
70
  return mapped;
66
71
  }
72
+ /**
73
+ * Project the portable egress policy onto the Sandbox policy.
74
+ *
75
+ * The schema is a strict discriminated union, so a domain list outside `strict` is refused rather
76
+ * than sent: Sandbox IGNORES `allowDomains` in `open` and `blocked` mode, and a silently ignored
77
+ * allowlist is a policy the caller believes is in force and is not.
78
+ *
79
+ * `includeImplicitDomains` stays unset, which is the Sandbox default of false. A strict policy
80
+ * therefore reaches the named domains plus the model endpoints the platform provisioned, matching
81
+ * what {@link AgentEnvironmentEgressPolicy} states; opting in would silently add ~40 hosts,
82
+ * including public source hosts.
83
+ */
84
+ function sandboxEgressPolicy(policy) {
85
+ const parsed = AgentEnvironmentEgressPolicySchema.parse(policy);
86
+ if (parsed.mode !== "strict")
87
+ return { mode: parsed.mode };
88
+ return {
89
+ mode: "strict",
90
+ ...(parsed.allowDomains === undefined ? {} : { allowDomains: [...parsed.allowDomains] }),
91
+ };
92
+ }
67
93
  /** Reject value-bearing secret maps before any custom mapper can drop them. */
68
94
  export function assertNoInlineSecretValues(input, parsedWorkspace) {
69
95
  if (input.providerOptions !== undefined) {
@@ -126,6 +152,8 @@ export function assertCreateInputShape(input, parsedWorkspace) {
126
152
  "resources",
127
153
  "env",
128
154
  "secrets",
155
+ "egress",
156
+ "billingOwner",
129
157
  "metadata",
130
158
  "name",
131
159
  "idempotencyKey",
@@ -175,6 +203,33 @@ export function assertMappedCreateOptions(options) {
175
203
  }
176
204
  if (options.idempotencyKey !== undefined)
177
205
  boundedIdentifier(options.idempotencyKey, "Tangle mapped idempotency key");
206
+ if (options.billingOwnerId !== undefined)
207
+ boundedIdentifier(options.billingOwnerId, "Tangle mapped billing owner");
208
+ if (options.egressPolicy !== undefined) {
209
+ if (!options.egressPolicy || typeof options.egressPolicy !== "object" || Array.isArray(options.egressPolicy)) {
210
+ throw new Error("Tangle mapped egress policy must be an object");
211
+ }
212
+ if (!["open", "strict", "blocked"].includes(options.egressPolicy.mode)) {
213
+ throw new Error("Tangle mapped egress policy mode is invalid");
214
+ }
215
+ if (options.egressPolicy.mode !== "strict" && options.egressPolicy.allowDomains !== undefined) {
216
+ throw new Error("Tangle mapped egress policy allows domains only in strict mode");
217
+ }
218
+ // The default path is schema-checked, so this gate holds a custom mapper to the same shape.
219
+ // A non-string or padded host reaches the platform, matches nothing, and leaves the caller
220
+ // believing an allowlist is in force that is not.
221
+ if (options.egressPolicy.allowDomains !== undefined) {
222
+ if (!Array.isArray(options.egressPolicy.allowDomains)) {
223
+ throw new Error("Tangle mapped egress allowed domains must be an array");
224
+ }
225
+ if (options.egressPolicy.allowDomains.length > MAX_ARRAY_LENGTH) {
226
+ throw new Error("Tangle mapped egress allowed domains exceed their bound");
227
+ }
228
+ for (const domain of options.egressPolicy.allowDomains) {
229
+ boundedIdentifier(domain, "Tangle mapped egress allowed domain");
230
+ }
231
+ }
232
+ }
178
233
  if (options.env !== undefined)
179
234
  assertStringRecord(options.env, "Tangle mapped");
180
235
  assertMappedSecretNames(options);
@@ -1,5 +1,7 @@
1
1
  import { canonicalCandidateDigest } from "@tangle-network/agent-interface";
2
+ import { backendFromTurnProviderOptions, backendRequestIdentity, } from "./tangle-prompt.js";
2
3
  export function sessionPromptRequestDigest(input, provider, environmentId, sessionId, options = {}) {
4
+ const backend = backendRequestIdentity(backendFromTurnProviderOptions(input.providerOptions));
3
5
  return canonicalCandidateDigest({
4
6
  provider,
5
7
  environmentId,
@@ -18,6 +20,12 @@ export function sessionPromptRequestDigest(input, provider, environmentId, sessi
18
20
  ...(input.interactions === undefined
19
21
  ? {}
20
22
  : { interactions: input.interactions }),
23
+ // Per-turn backend options select the model, the profile, and the seat the
24
+ // turn runs on. Leaving them out of the identity would let a retry under
25
+ // the same turn id reuse work that ran on other settings. The bearer
26
+ // material inside them is excluded, so a token refresh does not conflict
27
+ // with the run it continues.
28
+ ...(backend === undefined ? {} : { backend }),
21
29
  });
22
30
  }
23
31
  export function hasReplayPayload(input) {
@@ -15,8 +15,10 @@ import type { TangleConfidentialAttestationVerifier } from "./tangle-types.js";
15
15
  * The document is measured once, here. A sandbox that is not yet running
16
16
  * cannot answer, so an environment composed during provisioning claims
17
17
  * nothing and keeps claiming nothing: the exposed operations and the document
18
- * are composed together and a caller may already hold either one. Compose the
19
- * environment again through `provider.get(id)` once the sandbox is running.
18
+ * are composed together and a caller may already hold either one. This is why
19
+ * `provider.create()` holds until the sandbox is running before it composes
20
+ * (see `tangle-readiness.ts`). `provider.get(id)` composes whatever the
21
+ * sandbox is at that moment, so compose again once a starting sandbox runs.
20
22
  *
21
23
  * @param request What the create call asked for. An environment rebuilt by id
22
24
  * carries none of it, so its observation reports the requested compute shape
@@ -32,8 +32,10 @@ import { confidentialVerifierOption, createTangleWorkspaceBranching, } from "./t
32
32
  * The document is measured once, here. A sandbox that is not yet running
33
33
  * cannot answer, so an environment composed during provisioning claims
34
34
  * nothing and keeps claiming nothing: the exposed operations and the document
35
- * are composed together and a caller may already hold either one. Compose the
36
- * environment again through `provider.get(id)` once the sandbox is running.
35
+ * are composed together and a caller may already hold either one. This is why
36
+ * `provider.create()` holds until the sandbox is running before it composes
37
+ * (see `tangle-readiness.ts`). `provider.get(id)` composes whatever the
38
+ * sandbox is at that moment, so compose again once a starting sandbox runs.
37
39
  *
38
40
  * @param request What the create call asked for. An environment rebuilt by id
39
41
  * carries none of it, so its observation reports the requested compute shape
@@ -69,6 +71,8 @@ export async function sandboxInstanceAsEnvironment(box, providerName, client, de
69
71
  // Usage is measured per execution, so the log collects what runs through
70
72
  // this handle and the observation reports the newest record it holds.
71
73
  const usageLog = createExecutionUsageLog();
74
+ // The single destroy this handle performs, once it has been asked for.
75
+ let destruction;
72
76
  const terminals = capabilities.interactiveTerminal?.attach === true
73
77
  ? createTangleTerminalRegistry(box)
74
78
  : undefined;
@@ -308,7 +312,31 @@ export async function sandboxInstanceAsEnvironment(box, providerName, client, de
308
312
  throw new Error("Tangle sandbox client cannot delete this environment");
309
313
  assertOptionKeys(options, ["signal"], "Tangle destroy");
310
314
  options?.signal?.throwIfAborted();
311
- await awaitWithSignal(box.delete(options), options?.signal);
315
+ // One environment has more than one owner of its end. A runtime
316
+ // that destroys on settle AND tears the executor down calls this
317
+ // twice for the same box, and the platform refuses the second
318
+ // DELETE with "A sandbox lifecycle operation is already in
319
+ // progress" while the first one's cleanup still holds the
320
+ // sandbox's lifecycle lease. Measured 2026-09-01 on
321
+ // `sandbox-b525e04a551b`: of two concurrent deletes one answered
322
+ // 200 `destroyed` and the other 409, and a third delete 0.4 s
323
+ // after the 200 was still refused. That refusal cost two
324
+ // completed agent turns their result
325
+ // (tangle-network/agent-sdk#280), because the second destroy ran
326
+ // in a caller's `finally` and its throw replaced the run's own
327
+ // outcome.
328
+ //
329
+ // So destroy is answered once per environment and every later
330
+ // caller joins that answer. This is not a retry and hides no
331
+ // platform error: a delete that fails is not remembered, and the
332
+ // next caller issues a real one.
333
+ destruction ??= awaitWithSignal(box.delete(options), options?.signal)
334
+ .then(() => undefined)
335
+ .catch((error) => {
336
+ destruction = undefined;
337
+ throw error;
338
+ });
339
+ await awaitWithSignal(destruction, options?.signal);
312
340
  options?.signal?.throwIfAborted();
313
341
  },
314
342
  }
@@ -11,6 +11,9 @@ export function sandboxInstanceAsExactProcessEnvironment(box, providerName) {
11
11
  const process = box.process;
12
12
  const fs = box.fs;
13
13
  const destroy = box.delete.bind(box);
14
+ // The same single delete this environment performs, once it is asked for.
15
+ // See the destroy() below for why one is all the platform accepts.
16
+ let destruction;
14
17
  return {
15
18
  id: box.id,
16
19
  provider: providerName,
@@ -177,7 +180,22 @@ export function sandboxInstanceAsExactProcessEnvironment(box, providerName) {
177
180
  async destroy(options = {}) {
178
181
  assertSignalOptions(options, "Tangle exact process destroy");
179
182
  options.signal?.throwIfAborted();
180
- await awaitWithSignal(destroy(), options.signal);
183
+ // Deleting one sandbox twice is refused, not repeated: the platform
184
+ // holds a per-sandbox lifecycle lease past the first DELETE's own
185
+ // response while its deferred cleanup runs, so a second DELETE inside
186
+ // that window answers "A sandbox lifecycle operation is already in
187
+ // progress". Measured 2026-09-01 (issue #280) on the streaming
188
+ // environment, which has the same shape and the same platform route.
189
+ // This environment is destroyed once and later callers join that
190
+ // answer; a delete that fails is not remembered, so nothing hides a
191
+ // real error.
192
+ destruction ??= awaitWithSignal(destroy(), options.signal)
193
+ .then(() => undefined)
194
+ .catch((error) => {
195
+ destruction = undefined;
196
+ throw error;
197
+ });
198
+ await awaitWithSignal(destruction, options.signal);
181
199
  options.signal?.throwIfAborted();
182
200
  },
183
201
  };
@@ -1,4 +1,4 @@
1
- import type { PromptOptions, PromptResult } from "@tangle-network/sandbox";
1
+ import type { BackendConfig, PromptOptions, PromptResult } from "@tangle-network/sandbox";
2
2
  import type { AgentTurnInput, AgentTurnResult } from "@tangle-network/agent-interface/environment-provider";
3
3
  import type { AgentExactRunControlRef, InputPart } from "@tangle-network/agent-interface";
4
4
  export declare function promptFromTurnInput(input: AgentTurnInput): string | InputPart[];
@@ -8,6 +8,71 @@ export declare function promptOptionsFromTurnInput(input: AgentTurnInput, target
8
8
  environmentId: string;
9
9
  sessionId?: string;
10
10
  }): PromptOptions;
11
+ /**
12
+ * The `BackendConfig` fields the Sandbox prompt options declare.
13
+ *
14
+ * A turn may carry any of them and nothing else. An undeclared field is
15
+ * refused instead of forwarded, because the SDK drops what it does not
16
+ * declare: the turn would then run on different settings with no error
17
+ * anywhere, which is the silent substitution the SDK's own `model` field
18
+ * exists to prevent.
19
+ */
20
+ declare const SANDBOX_BACKEND_FIELD_LIST: readonly ["type", "profile", "model", "server", "interactions", "metadata"];
21
+ /**
22
+ * The `BackendConfig["model"]` fields the Sandbox prompt options declare.
23
+ *
24
+ * This is the block that carries per-turn credentials — `authMode: "oauth"`
25
+ * with `authFiles` for a subscription seat — so it is checked field by field
26
+ * rather than passed through as opaque JSON.
27
+ */
28
+ declare const SANDBOX_BACKEND_MODEL_FIELD_LIST: readonly ["provider", "model", "apiKey", "baseUrl", "maxThinkingTokens", "mode", "apiKeyEnv", "authMode", "authFiles"];
29
+ /**
30
+ * The two lists above are the SDK's field sets, restated as values because a
31
+ * TypeScript type cannot be read at run time. This pin keeps them exact in
32
+ * both directions: a field the SDK adds, renames, or removes fails the build
33
+ * here rather than reaching a caller as a wrong refusal or a silent drop.
34
+ */
35
+ type Exhaustive<T extends never> = T;
36
+ type SandboxBackendModel = NonNullable<BackendConfig["model"]>;
37
+ type UncoveredBackendField = Exhaustive<Exclude<keyof BackendConfig, (typeof SANDBOX_BACKEND_FIELD_LIST)[number]>>;
38
+ type StaleBackendField = Exhaustive<Exclude<(typeof SANDBOX_BACKEND_FIELD_LIST)[number], keyof BackendConfig>>;
39
+ type UncoveredBackendModelField = Exhaustive<Exclude<keyof SandboxBackendModel, (typeof SANDBOX_BACKEND_MODEL_FIELD_LIST)[number]>>;
40
+ type StaleBackendModelField = Exhaustive<Exclude<(typeof SANDBOX_BACKEND_MODEL_FIELD_LIST)[number], keyof SandboxBackendModel>>;
41
+ declare const SANDBOX_AUTH_FILE_FIELD_LIST: readonly ["path", "content", "mode"];
42
+ type SandboxAuthFile = NonNullable<SandboxBackendModel["authFiles"]>[number];
43
+ type UncoveredAuthFileField = Exhaustive<Exclude<keyof SandboxAuthFile, (typeof SANDBOX_AUTH_FILE_FIELD_LIST)[number]>>;
44
+ type StaleAuthFileField = Exhaustive<Exclude<(typeof SANDBOX_AUTH_FILE_FIELD_LIST)[number], keyof SandboxAuthFile>>;
45
+ type SandboxBackendFieldCoverage = [
46
+ UncoveredBackendField,
47
+ StaleBackendField,
48
+ UncoveredBackendModelField,
49
+ StaleBackendModelField,
50
+ UncoveredAuthFileField,
51
+ StaleAuthFileField
52
+ ];
53
+ export type { SandboxBackendFieldCoverage };
54
+ type SandboxPromptBackend = NonNullable<PromptOptions["backend"]>;
55
+ /**
56
+ * Read the per-turn backend options a caller sent through `providerOptions`.
57
+ *
58
+ * `AgentTurnInput.providerOptions.backend` is the exact shape agent-runtime
59
+ * emits for a per-turn backend or model override, so refusing it dropped the
60
+ * caller's model, profile, and session credential bundle at the adapter
61
+ * boundary. Every field is checked against what the Sandbox prompt options
62
+ * declare, and the result is bounded JSON the SDK reads once.
63
+ */
64
+ export declare function backendFromTurnProviderOptions(providerOptions: Record<string, unknown> | undefined): SandboxPromptBackend | undefined;
65
+ /**
66
+ * The part of the backend options that decides which work a turn is.
67
+ *
68
+ * Bearer material is deliberately absent. A rotated seat token is the same
69
+ * seat running the same work, so digesting the token bytes would make an
70
+ * ordinary refresh conflict with the run it is continuing. The auth files are
71
+ * reduced to the paths and modes they install, which states that the turn
72
+ * carries a credential bundle and which slots it fills, without binding the
73
+ * identity to the secret inside.
74
+ */
75
+ export declare function backendRequestIdentity(backend: SandboxPromptBackend | undefined): Record<string, unknown> | undefined;
11
76
  type SandboxRunStatus = "success" | "failed" | "blocked_on_approval" | "awaiting_question" | "awaiting_interaction" | "awaiting_plan_decision";
12
77
  type ValidatedSandboxPromptResult = Record<string, unknown> & {
13
78
  success: boolean;
@@ -22,4 +87,3 @@ export declare function agentTurnResultFromPromptRecord(record: ValidatedSandbox
22
87
  sessionId?: string;
23
88
  controlRef?: AgentExactRunControlRef;
24
89
  }): AgentTurnResult;
25
- export {};
@@ -1,6 +1,6 @@
1
- import { AgentExactRunControlRefSchema, AgentTurnInputSchema, ContextTransferReceiptSchema, contextTransferResultMatchesRequest, } from "@tangle-network/agent-interface";
1
+ import { agentProfileSchema, AgentExactRunControlRefSchema, AgentTurnInputSchema, ContextTransferReceiptSchema, contextTransferResultMatchesRequest, } from "@tangle-network/agent-interface";
2
2
  import { tokenUsageFromData } from "./tangle-result-values.js";
3
- import { assertBoundedJson } from "./tangle-contract-safety.js";
3
+ import { assertBoundedJson, boundedString } from "./tangle-contract-safety.js";
4
4
  export function promptFromTurnInput(input) {
5
5
  AgentTurnInputSchema.parse(input);
6
6
  if (input.parts)
@@ -40,17 +40,13 @@ export function promptOptionsFromTurnInput(input, target) {
40
40
  throw new Error("Tangle executionId conflicts with the control reference");
41
41
  }
42
42
  }
43
- if (input.providerOptions && Object.keys(input.providerOptions).length > 0) {
44
- throw new Error("Tangle prompt providerOptions are not supported");
45
- }
43
+ const backend = turnBackendOptions(input);
46
44
  const sessionId = input.sessionId ?? controlRef?.sessionId;
47
45
  const executionId = input.executionId ?? controlRef?.executionId;
48
46
  return {
49
47
  ...(sessionId ? { sessionId } : {}),
50
48
  ...(input.model ? { model: input.model } : {}),
51
- ...(input.interactions === undefined
52
- ? {}
53
- : { backend: { interactions: input.interactions } }),
49
+ ...(backend === undefined ? {} : { backend }),
54
50
  ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
55
51
  ...(input.context ? { context: input.context } : {}),
56
52
  ...(input.signal ? { signal: input.signal } : {}),
@@ -61,6 +57,294 @@ export function promptOptionsFromTurnInput(input, target) {
61
57
  ...(input.detach !== undefined ? { detach: input.detach } : {}),
62
58
  };
63
59
  }
60
+ /**
61
+ * The `BackendConfig` fields the Sandbox prompt options declare.
62
+ *
63
+ * A turn may carry any of them and nothing else. An undeclared field is
64
+ * refused instead of forwarded, because the SDK drops what it does not
65
+ * declare: the turn would then run on different settings with no error
66
+ * anywhere, which is the silent substitution the SDK's own `model` field
67
+ * exists to prevent.
68
+ */
69
+ const SANDBOX_BACKEND_FIELD_LIST = [
70
+ "type",
71
+ "profile",
72
+ "model",
73
+ "server",
74
+ "interactions",
75
+ "metadata",
76
+ ];
77
+ const SANDBOX_BACKEND_FIELDS = new Set(SANDBOX_BACKEND_FIELD_LIST);
78
+ /**
79
+ * The `BackendConfig["model"]` fields the Sandbox prompt options declare.
80
+ *
81
+ * This is the block that carries per-turn credentials — `authMode: "oauth"`
82
+ * with `authFiles` for a subscription seat — so it is checked field by field
83
+ * rather than passed through as opaque JSON.
84
+ */
85
+ const SANDBOX_BACKEND_MODEL_FIELD_LIST = [
86
+ "provider",
87
+ "model",
88
+ "apiKey",
89
+ "baseUrl",
90
+ "maxThinkingTokens",
91
+ "mode",
92
+ "apiKeyEnv",
93
+ "authMode",
94
+ "authFiles",
95
+ ];
96
+ const SANDBOX_BACKEND_MODEL_FIELDS = new Set(SANDBOX_BACKEND_MODEL_FIELD_LIST);
97
+ const SANDBOX_AUTH_FILE_FIELD_LIST = ["path", "content", "mode"];
98
+ /**
99
+ * Read the per-turn backend options a caller sent through `providerOptions`.
100
+ *
101
+ * `AgentTurnInput.providerOptions.backend` is the exact shape agent-runtime
102
+ * emits for a per-turn backend or model override, so refusing it dropped the
103
+ * caller's model, profile, and session credential bundle at the adapter
104
+ * boundary. Every field is checked against what the Sandbox prompt options
105
+ * declare, and the result is bounded JSON the SDK reads once.
106
+ */
107
+ export function backendFromTurnProviderOptions(providerOptions) {
108
+ if (providerOptions === undefined)
109
+ return undefined;
110
+ assertDeclaredFields(providerOptions, new Set(["backend"]), "Tangle prompt providerOptions");
111
+ if (!Object.hasOwn(providerOptions, "backend"))
112
+ return undefined;
113
+ return sandboxPromptBackend(providerOptions.backend);
114
+ }
115
+ function sandboxPromptBackend(value) {
116
+ const present = plainRecord(value, "Tangle prompt backend options");
117
+ assertDeclaredFields(present, SANDBOX_BACKEND_FIELDS, "Tangle prompt backend options");
118
+ const backend = {
119
+ ...(present.type === undefined
120
+ ? {}
121
+ : { type: boundedString(present.type, "Tangle prompt backend type") }),
122
+ // The profile decides tools, permissions, mounts, and the security policy
123
+ // of the turn, so it is read by the schema that owns those rules rather
124
+ // than passed through as bounded JSON.
125
+ ...(present.profile === undefined
126
+ ? {}
127
+ : { profile: agentProfileSchema.parse(present.profile) }),
128
+ ...(present.model === undefined
129
+ ? {}
130
+ : { model: sandboxPromptBackendModel(present.model) }),
131
+ ...(present.server === undefined
132
+ ? {}
133
+ : { server: sandboxPromptBackendServer(present.server) }),
134
+ ...(present.interactions === undefined
135
+ ? {}
136
+ : { interactions: sandboxPromptInteractions(present.interactions) }),
137
+ ...(present.metadata === undefined
138
+ ? {}
139
+ : { metadata: sandboxPromptBackendMetadata(present.metadata) }),
140
+ };
141
+ assertBoundedJson(backend);
142
+ return backend;
143
+ }
144
+ function sandboxPromptBackendModel(value) {
145
+ const present = plainRecord(value, "Tangle prompt backend model options");
146
+ assertDeclaredFields(present, SANDBOX_BACKEND_MODEL_FIELDS, "Tangle prompt backend model options");
147
+ const model = {};
148
+ for (const field of ["provider", "model", "apiKey", "baseUrl", "apiKeyEnv"]) {
149
+ if (present[field] === undefined)
150
+ continue;
151
+ if (typeof present[field] !== "string" || present[field] === "") {
152
+ throw new Error(`Tangle prompt backend model ${field} must be a non-empty string`);
153
+ }
154
+ model[field] = boundedString(present[field], `Tangle prompt backend model ${field}`);
155
+ }
156
+ if (present.maxThinkingTokens !== undefined) {
157
+ if (!Number.isSafeInteger(present.maxThinkingTokens) ||
158
+ present.maxThinkingTokens < 0) {
159
+ throw new Error("Tangle prompt backend model maxThinkingTokens must be a non-negative integer");
160
+ }
161
+ model.maxThinkingTokens = present.maxThinkingTokens;
162
+ }
163
+ if (present.mode !== undefined) {
164
+ if (present.mode !== "api" && present.mode !== "cli") {
165
+ throw new Error('Tangle prompt backend model mode must be "api" or "cli"');
166
+ }
167
+ model.mode = present.mode;
168
+ }
169
+ if (present.authMode !== undefined) {
170
+ if (present.authMode !== "api-key" && present.authMode !== "oauth") {
171
+ throw new Error('Tangle prompt backend model authMode must be "api-key" or "oauth"');
172
+ }
173
+ model.authMode = present.authMode;
174
+ }
175
+ if (present.authFiles !== undefined) {
176
+ model.authFiles = sandboxPromptAuthFiles(present.authFiles);
177
+ }
178
+ return model;
179
+ }
180
+ function sandboxPromptBackendServer(value) {
181
+ const present = plainRecord(value, "Tangle prompt backend server options");
182
+ assertDeclaredFields(present, new Set(["port", "hostname"]), "Tangle prompt backend server options");
183
+ const server = {};
184
+ if (present.port !== undefined) {
185
+ if (!Number.isSafeInteger(present.port) ||
186
+ present.port < 1 ||
187
+ present.port > 65_535) {
188
+ throw new Error("Tangle prompt backend server port must be a TCP port number");
189
+ }
190
+ server.port = present.port;
191
+ }
192
+ if (present.hostname !== undefined) {
193
+ if (typeof present.hostname !== "string" || present.hostname === "") {
194
+ throw new Error("Tangle prompt backend server hostname must be a non-empty string");
195
+ }
196
+ server.hostname = boundedString(present.hostname, "Tangle prompt backend server hostname");
197
+ }
198
+ return server;
199
+ }
200
+ function sandboxPromptInteractions(value) {
201
+ const present = plainRecord(value, "Tangle prompt backend interactions");
202
+ assertDeclaredFields(present, new Set(["permission", "question", "plan"]), "Tangle prompt backend interactions");
203
+ const interactions = {};
204
+ for (const kind of ["permission", "question", "plan"]) {
205
+ if (present[kind] === undefined)
206
+ continue;
207
+ if (typeof present[kind] !== "boolean") {
208
+ throw new Error(`Tangle prompt backend interaction ${kind} must be a boolean`);
209
+ }
210
+ interactions[kind] = present[kind];
211
+ }
212
+ return interactions;
213
+ }
214
+ /** Sandbox caps trace attributes, and its own documentation forbids secrets there. */
215
+ const MAX_TRACE_ATTRIBUTES = 32;
216
+ const MAX_TRACE_ATTRIBUTE_KEY_LENGTH = 128;
217
+ const MAX_TRACE_ATTRIBUTE_VALUE_LENGTH = 1_024;
218
+ function sandboxPromptBackendMetadata(value) {
219
+ const present = plainRecord(value, "Tangle prompt backend metadata");
220
+ assertDeclaredFields(present, new Set(["containerType", "traceAttributes"]), "Tangle prompt backend metadata");
221
+ const metadata = {};
222
+ if (present.containerType !== undefined) {
223
+ if (typeof present.containerType !== "string" || present.containerType === "") {
224
+ throw new Error("Tangle prompt backend metadata containerType must be a non-empty string");
225
+ }
226
+ metadata.containerType = boundedString(present.containerType, "Tangle prompt backend metadata containerType");
227
+ }
228
+ if (present.traceAttributes !== undefined) {
229
+ const attributes = plainRecord(present.traceAttributes, "Tangle prompt backend metadata traceAttributes");
230
+ const entries = Object.entries(attributes);
231
+ if (entries.length > MAX_TRACE_ATTRIBUTES) {
232
+ throw new Error(`Tangle prompt backend metadata traceAttributes exceeds ${MAX_TRACE_ATTRIBUTES} entries`);
233
+ }
234
+ for (const [key, attribute] of entries) {
235
+ if (key.length > MAX_TRACE_ATTRIBUTE_KEY_LENGTH) {
236
+ throw new Error("Tangle prompt backend metadata traceAttributes key exceeds its bound");
237
+ }
238
+ if (typeof attribute !== "string" ||
239
+ attribute.length > MAX_TRACE_ATTRIBUTE_VALUE_LENGTH) {
240
+ throw new Error("Tangle prompt backend metadata traceAttributes value must be a bounded string");
241
+ }
242
+ }
243
+ metadata.traceAttributes = { ...attributes };
244
+ }
245
+ return metadata;
246
+ }
247
+ function sandboxPromptAuthFiles(value) {
248
+ if (!Array.isArray(value)) {
249
+ throw new Error("Tangle prompt backend authFiles must be an array");
250
+ }
251
+ return value.map((entry) => {
252
+ const present = plainRecord(entry, "Tangle prompt backend auth file");
253
+ assertDeclaredFields(present, new Set(SANDBOX_AUTH_FILE_FIELD_LIST), "Tangle prompt backend auth file fields");
254
+ if (typeof present.path !== "string" || present.path.length === 0) {
255
+ throw new Error("Tangle prompt backend auth file requires a path");
256
+ }
257
+ if (typeof present.content !== "string") {
258
+ throw new Error("Tangle prompt backend auth file requires string content");
259
+ }
260
+ if (present.mode !== undefined && !Number.isSafeInteger(present.mode)) {
261
+ throw new Error("Tangle prompt backend auth file mode must be an integer");
262
+ }
263
+ return {
264
+ path: boundedString(present.path, "Tangle prompt backend auth file path"),
265
+ content: present.content,
266
+ ...(present.mode === undefined ? {} : { mode: present.mode }),
267
+ };
268
+ });
269
+ }
270
+ function assertDeclaredFields(record, declared, label) {
271
+ const unsupported = Object.keys(record).filter((key) => !declared.has(key));
272
+ if (unsupported.length > 0) {
273
+ throw new Error(`${label} are not supported: ${unsupported.sort().join(", ")}`);
274
+ }
275
+ }
276
+ function plainRecord(value, label) {
277
+ if (!value ||
278
+ typeof value !== "object" ||
279
+ Array.isArray(value) ||
280
+ (Object.getPrototypeOf(value) !== Object.prototype &&
281
+ Object.getPrototypeOf(value) !== null)) {
282
+ throw new Error(`${label} must be a JSON object`);
283
+ }
284
+ return value;
285
+ }
286
+ /**
287
+ * The part of the backend options that decides which work a turn is.
288
+ *
289
+ * Bearer material is deliberately absent. A rotated seat token is the same
290
+ * seat running the same work, so digesting the token bytes would make an
291
+ * ordinary refresh conflict with the run it is continuing. The auth files are
292
+ * reduced to the paths and modes they install, which states that the turn
293
+ * carries a credential bundle and which slots it fills, without binding the
294
+ * identity to the secret inside.
295
+ */
296
+ export function backendRequestIdentity(backend) {
297
+ if (backend === undefined)
298
+ return undefined;
299
+ const model = backend.model;
300
+ if (model === undefined)
301
+ return { ...backend };
302
+ const { apiKey: _apiKey, authFiles, ...rest } = model;
303
+ return {
304
+ ...backend,
305
+ model: {
306
+ ...rest,
307
+ ...(authFiles === undefined
308
+ ? {}
309
+ : {
310
+ authFiles: authFiles.map((file) => ({
311
+ path: file.path,
312
+ ...(file.mode === undefined ? {} : { mode: file.mode }),
313
+ })),
314
+ }),
315
+ },
316
+ };
317
+ }
318
+ /**
319
+ * Combine the turn's requested interaction posture with its backend options.
320
+ *
321
+ * agent-runtime emits both: it maps `backend.interactions` onto the canonical
322
+ * `AgentTurnInput.interactions` and carries the whole backend block through
323
+ * `providerOptions`. The two must agree, and a disagreement is refused rather
324
+ * than resolved by preference, because either answer would run the turn on a
325
+ * posture the caller did not ask for.
326
+ */
327
+ function turnBackendOptions(input) {
328
+ const backend = backendFromTurnProviderOptions(input.providerOptions);
329
+ const backendModel = backend?.model?.model;
330
+ if (input.model !== undefined &&
331
+ backendModel !== undefined &&
332
+ backendModel !== input.model) {
333
+ throw new Error("Tangle turn model conflicts with its backend model");
334
+ }
335
+ if (input.interactions === undefined)
336
+ return backend;
337
+ if (backend === undefined)
338
+ return { interactions: input.interactions };
339
+ if (backend.interactions !== undefined &&
340
+ !sameRequestedInteractions(backend.interactions, input.interactions)) {
341
+ throw new Error("Tangle turn interactions conflict with its backend interactions");
342
+ }
343
+ return { ...backend, interactions: input.interactions };
344
+ }
345
+ function sameRequestedInteractions(left, right) {
346
+ return ["permission", "question", "plan"].every((kind) => (left[kind] ?? false) === (right[kind] ?? false));
347
+ }
64
348
  const SANDBOX_OPTIONAL_RESULT_FIELDS = new Set([
65
349
  "executionId",
66
350
  "response",
@@ -5,16 +5,22 @@ import { sandboxInstanceAsEnvironment } from "./tangle-environment.js";
5
5
  import { confidentialVerifierOption, createTangleWorkspaceBranching, } from "./tangle-workspace-branching.js";
6
6
  import { assertCreateInputShape, assertMappedCreateOptions, assertMappedSecretNames, assertNoInlineSecretValues, sandboxOptionsFromCreateInput } from "./tangle-create-options.js";
7
7
  import { statusFromUnknown } from "./tangle-environment-values.js";
8
+ import { awaitSandboxRunning, DEFAULT_TANGLE_READY_TIMEOUT_MS, } from "./tangle-readiness.js";
8
9
  import { requestedResourceProfile } from "./tangle-resources.js";
9
10
  import { assertBoundedJson, attachCleanupHandle, awaitWithSignal, boundedIdentifier, boundedString, MAX_LIST_RESULTS, SANDBOX_LIST_PAGE_SIZE, } from "./tangle-contract-safety.js";
10
11
  export function createTangleProvider(options) {
11
12
  const providerName = options.name ?? "tangle-sandbox";
12
13
  boundedIdentifier(providerName, "Tangle provider name");
14
+ const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_TANGLE_READY_TIMEOUT_MS;
15
+ if (!Number.isFinite(readyTimeoutMs) || readyTimeoutMs <= 0) {
16
+ throw new Error("Tangle readyTimeoutMs must be a positive number of milliseconds");
17
+ }
13
18
  const exactProcess = options.exactProcess
14
19
  ? createTangleExactProcessProvider({
15
20
  client: options.client,
16
21
  options: options.exactProcess,
17
22
  providerName,
23
+ readyTimeoutMs,
18
24
  })
19
25
  : undefined;
20
26
  const resolveDeclaredCapabilities = async () => {
@@ -88,6 +94,14 @@ export function createTangleProvider(options) {
88
94
  throw error;
89
95
  }
90
96
  try {
97
+ input.signal?.throwIfAborted();
98
+ // The environment this call returns must accept a turn, and composing it
99
+ // reads a deployment document that a starting sandbox cannot answer.
100
+ // Both facts wait here, once, so no caller repeats the wait.
101
+ await awaitSandboxRunning(box, options.client, {
102
+ timeoutMs: readyTimeoutMs,
103
+ ...(input.signal ? { signal: input.signal } : {}),
104
+ });
91
105
  input.signal?.throwIfAborted();
92
106
  const requestedResources = requestedResourceProfile(input.resources);
93
107
  const environment = await sandboxInstanceAsEnvironment(box, providerName, options.client, declaredCapabilities, input.signal ? { signal: input.signal } : undefined, {
@@ -0,0 +1,43 @@
1
+ import type { SandboxClientLike, SandboxInstanceLike } from "./tangle-types.js";
2
+ /**
3
+ * How long `create()` waits for a new sandbox to reach `running`.
4
+ *
5
+ * The value is the Sandbox SDK's own wait default, so the adapter states no
6
+ * second deadline that disagrees with the platform's.
7
+ */
8
+ export declare const DEFAULT_TANGLE_READY_TIMEOUT_MS = 120000;
9
+ /**
10
+ * Hold `create()` until the sandbox can accept a turn.
11
+ *
12
+ * The gap this closes is narrow and specific. `client.create()` waits by
13
+ * itself only when the create response reports `pending` or `provisioning`.
14
+ * A response that already reports `running` skips that wait, and `running`
15
+ * alone is not usable: the SDK's own `waitFor` treats the target as reached
16
+ * only when `filesystemIncarnationReadiness` is `ready`, because the box's
17
+ * filesystem is still being built until then. An environment composed before
18
+ * that is an environment that cannot accept a turn.
19
+ *
20
+ * WHAT THIS WAIT IS NOT FOR. An earlier version of this comment claimed the
21
+ * wait also closed the platform's "A sandbox lifecycle operation is already in
22
+ * progress", and that claim is withdrawn: measured 2026-09-01 (issue #280),
23
+ * that refusal comes from `DELETE /v1/sandboxes/:id`, not from a turn. The
24
+ * platform guards exactly three routes with the per-sandbox lifecycle lease —
25
+ * resume, stop and delete — and the runtime path a prompt takes is not one of
26
+ * them, so no readiness wait here can prevent it and none is trying to. The
27
+ * collision is answered where it happens, in `destroy()`
28
+ * (`tangle-environment.ts`).
29
+ *
30
+ * Readiness also decides what the environment can claim. Composing an
31
+ * environment reads the sandbox's deployment capability document once, and a
32
+ * sandbox that is not yet running cannot answer that read, so an environment
33
+ * composed during provisioning claims nothing for the rest of its life.
34
+ *
35
+ * The platform owns the wait, and this adapter runs no status loop of its own.
36
+ * `waitFor` on the created instance is preferred because it refreshes that
37
+ * instance in place, which keeps the create receipt and gains the runtime
38
+ * connection the environment reads.
39
+ */
40
+ export declare function awaitSandboxRunning(box: SandboxInstanceLike, client: SandboxClientLike, options: {
41
+ timeoutMs: number;
42
+ signal?: AbortSignal;
43
+ }): Promise<void>;
@@ -0,0 +1,100 @@
1
+ import { awaitWithSignal } from "./tangle-contract-safety.js";
2
+ import { statusFromUnknown } from "./tangle-environment-values.js";
3
+ /**
4
+ * How long `create()` waits for a new sandbox to reach `running`.
5
+ *
6
+ * The value is the Sandbox SDK's own wait default, so the adapter states no
7
+ * second deadline that disagrees with the platform's.
8
+ */
9
+ export const DEFAULT_TANGLE_READY_TIMEOUT_MS = 120_000;
10
+ /**
11
+ * Hold `create()` until the sandbox can accept a turn.
12
+ *
13
+ * The gap this closes is narrow and specific. `client.create()` waits by
14
+ * itself only when the create response reports `pending` or `provisioning`.
15
+ * A response that already reports `running` skips that wait, and `running`
16
+ * alone is not usable: the SDK's own `waitFor` treats the target as reached
17
+ * only when `filesystemIncarnationReadiness` is `ready`, because the box's
18
+ * filesystem is still being built until then. An environment composed before
19
+ * that is an environment that cannot accept a turn.
20
+ *
21
+ * WHAT THIS WAIT IS NOT FOR. An earlier version of this comment claimed the
22
+ * wait also closed the platform's "A sandbox lifecycle operation is already in
23
+ * progress", and that claim is withdrawn: measured 2026-09-01 (issue #280),
24
+ * that refusal comes from `DELETE /v1/sandboxes/:id`, not from a turn. The
25
+ * platform guards exactly three routes with the per-sandbox lifecycle lease —
26
+ * resume, stop and delete — and the runtime path a prompt takes is not one of
27
+ * them, so no readiness wait here can prevent it and none is trying to. The
28
+ * collision is answered where it happens, in `destroy()`
29
+ * (`tangle-environment.ts`).
30
+ *
31
+ * Readiness also decides what the environment can claim. Composing an
32
+ * environment reads the sandbox's deployment capability document once, and a
33
+ * sandbox that is not yet running cannot answer that read, so an environment
34
+ * composed during provisioning claims nothing for the rest of its life.
35
+ *
36
+ * The platform owns the wait, and this adapter runs no status loop of its own.
37
+ * `waitFor` on the created instance is preferred because it refreshes that
38
+ * instance in place, which keeps the create receipt and gains the runtime
39
+ * connection the environment reads.
40
+ */
41
+ export async function awaitSandboxRunning(box, client, options) {
42
+ const { timeoutMs, signal } = options;
43
+ signal?.throwIfAborted();
44
+ const waitOptions = {
45
+ timeoutMs,
46
+ ...(signal ? { signal } : {}),
47
+ };
48
+ const wait = waitForRunning(box, client, waitOptions);
49
+ if (wait === undefined) {
50
+ // A client that offers no wait cannot prove readiness, so the sandbox it
51
+ // returned has to prove it by itself or the create call fails.
52
+ assertRunning(box, box.status, "the linked client provides neither instance waitFor() nor client waitForRunning()");
53
+ return;
54
+ }
55
+ let observed;
56
+ try {
57
+ observed = await awaitWithSignal(wait(), signal);
58
+ }
59
+ catch (error) {
60
+ // An abort is the caller's own outcome and keeps its identity.
61
+ if (signal?.aborted)
62
+ throw error;
63
+ throw new Error(`Tangle sandbox ${box.id} did not reach running: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
64
+ }
65
+ // A wait that resolves is the platform's answer, and the status it leaves
66
+ // behind is that answer in a readable form. Reading it back costs nothing
67
+ // and refuses a client whose wait resolves over a sandbox that is not
68
+ // running, which is the one way a half-started environment could still
69
+ // reach the caller.
70
+ assertRunning(box, observed, "its wait resolved without reaching running");
71
+ }
72
+ function assertRunning(box, status, reason) {
73
+ const observed = statusFromUnknown(status);
74
+ if (observed === "running")
75
+ return;
76
+ throw new Error(`Tangle create cannot return sandbox ${box.id}: it reports ${observed} and ${reason}`);
77
+ }
78
+ function waitForRunning(box, client, options) {
79
+ const instanceWait = box.waitFor;
80
+ if (instanceWait) {
81
+ return async () => {
82
+ await instanceWait.call(box, "running", options);
83
+ // `waitFor` refreshes this instance in place, so this instance now holds
84
+ // the status the platform answered with.
85
+ return box.status;
86
+ };
87
+ }
88
+ const clientWait = client.waitForRunning;
89
+ if (clientWait) {
90
+ return async () => {
91
+ const ready = await clientWait.call(client, box.id, options);
92
+ // The client-side wait resolves a second instance for the same id. That
93
+ // instance carries the platform's answer; the created instance still
94
+ // holds what it was returned with until it refreshes.
95
+ await box.refresh?.(options.signal);
96
+ return ready?.id === box.id ? ready.status : undefined;
97
+ };
98
+ }
99
+ return undefined;
100
+ }
@@ -37,6 +37,15 @@ export interface SandboxClientLike {
37
37
  get?(id: string, requestOptions?: {
38
38
  signal?: AbortSignal;
39
39
  }): Promise<SandboxInstanceLike | null>;
40
+ /**
41
+ * Poll a sandbox to `running` by id, and report a provision failure with the
42
+ * platform's own reason. It resolves a second instance for the same id, so a
43
+ * caller that holds the created instance refreshes that instance afterwards.
44
+ */
45
+ waitForRunning?(id: string, options?: {
46
+ timeoutMs?: number;
47
+ signal?: AbortSignal;
48
+ }): Promise<SandboxInstanceLike>;
40
49
  list?(options?: {
41
50
  scope?: string;
42
51
  limit?: number;
@@ -455,6 +464,15 @@ export interface SandboxInstanceLike {
455
464
  * by id or when the platform reported no receipt.
456
465
  */
457
466
  createReceipt?(): SandboxCreateReceiptLike | null;
467
+ /**
468
+ * Hold until this sandbox reaches a lifecycle status, refreshing this
469
+ * instance in place. Preferred over the client-side wait because the created
470
+ * instance keeps its create receipt and gains its runtime connection.
471
+ */
472
+ waitFor?(status: "running", options?: {
473
+ timeoutMs?: number;
474
+ signal?: AbortSignal;
475
+ }): Promise<void>;
458
476
  refresh?(signal?: AbortSignal): Promise<void>;
459
477
  delete?(options?: {
460
478
  signal?: AbortSignal;
@@ -565,6 +583,12 @@ export interface TangleProviderOptions {
565
583
  validateProfile?: AgentEnvironmentProvider["validateProfile"];
566
584
  mapCreateInput?: (input: CreateAgentEnvironmentInput) => CreateSandboxOptions;
567
585
  exactProcess?: TangleExactProcessOptions;
586
+ /**
587
+ * How long `create()` waits for a new sandbox to reach `running` before it
588
+ * fails. Defaults to `DEFAULT_TANGLE_READY_TIMEOUT_MS`, exported from this
589
+ * package, which is the Sandbox SDK's own wait default of 120 seconds.
590
+ */
591
+ readyTimeoutMs?: number;
568
592
  /** External provider-key and measurement verifier for confidential forks. */
569
593
  confidentialAttestationVerifier?: TangleConfidentialAttestationVerifier;
570
594
  }
package/package.json CHANGED
@@ -1,16 +1,15 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-tangle",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "description": "AgentEnvironmentProvider adapter for Tangle sandboxes",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "main": "./dist/index.js",
8
- "types": "./dist/index.d.ts",
7
+ "main": "dist/index.js",
8
+ "types": "src/index.ts",
9
9
  "exports": {
10
10
  ".": {
11
11
  "import": "./dist/index.js",
12
- "types": "./dist/index.d.ts",
13
- "default": "./dist/index.js"
12
+ "types": "./src/index.ts"
14
13
  }
15
14
  },
16
15
  "repository": {
@@ -20,7 +19,16 @@
20
19
  },
21
20
  "publishConfig": {
22
21
  "access": "public",
23
- "registry": "https://registry.npmjs.org"
22
+ "registry": "https://registry.npmjs.org",
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "import": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ }
24
32
  },
25
33
  "files": [
26
34
  "dist/index.d.ts",
@@ -75,6 +83,8 @@
75
83
  "dist/tangle-prompt.js",
76
84
  "dist/tangle-provider.d.ts",
77
85
  "dist/tangle-provider.js",
86
+ "dist/tangle-readiness.d.ts",
87
+ "dist/tangle-readiness.js",
78
88
  "dist/tangle-result-values.d.ts",
79
89
  "dist/tangle-result-values.js",
80
90
  "dist/tangle-session-control.d.ts",
@@ -88,8 +98,15 @@
88
98
  "README.md",
89
99
  "LICENSE"
90
100
  ],
101
+ "scripts": {
102
+ "build": "tsc -p tsconfig.json",
103
+ "check-types": "tsc --noEmit",
104
+ "clean": "rm -rf dist",
105
+ "prepublishOnly": "pnpm run build",
106
+ "test": "vitest run src"
107
+ },
91
108
  "dependencies": {
92
- "@tangle-network/agent-interface": "^2.0.0"
109
+ "@tangle-network/agent-interface": "^2.2.0"
93
110
  },
94
111
  "peerDependencies": {
95
112
  "@tangle-network/sandbox": ">=0.34.6 <1.0.0"
@@ -101,17 +118,11 @@
101
118
  },
102
119
  "devDependencies": {
103
120
  "@tangle-network/agent-eval": "0.170.0",
121
+ "@tangle-network/agent-provider-testkit": "workspace:*",
104
122
  "@tangle-network/agent-runtime": "0.184.0",
105
123
  "@tangle-network/sandbox": "0.34.6",
106
- "@types/node": "26.4.0",
124
+ "@types/node": "catalog:",
107
125
  "typescript": "7.0.2",
108
- "vitest": "4.1.11",
109
- "@tangle-network/agent-provider-testkit": "0.8.6"
110
- },
111
- "scripts": {
112
- "build": "tsc -p tsconfig.json",
113
- "check-types": "tsc --noEmit",
114
- "clean": "rm -rf dist",
115
- "test": "vitest run src"
126
+ "vitest": "catalog:"
116
127
  }
117
- }
128
+ }