@tangle-network/agent-provider-tangle 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @tangle-network/agent-provider-tangle
2
2
 
3
3
  Wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`.
4
- The peer range is `>=0.34.4 <1.0.0`, and this package is developed and tested against 0.34.4.
5
- The floor is 0.34.4 because exact interactive attachment needs the host receiver, and workspace branching needs keyed snapshot, fork, lookup, and cleanup operations.
4
+ The peer range is `>=0.34.6 <1.0.0`, and this package is developed and tested against 0.34.6.
5
+ The floor is 0.34.6 because exact interactive attachment needs the host receiver, and workspace branching needs keyed snapshots, durable restores, inventory recovery, and cleanup.
6
6
  The provider fails closed when the configured backend or its catalog entry cannot be read.
7
7
  Newer SDKs may also provide `getBackend()` as a lookup over the same catalog.
8
8
 
@@ -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
 
@@ -112,7 +179,7 @@ and recovering a checkpoint, forking one managed child, and cleaning both
112
179
  resources in dependency order.
113
180
  The adapter advertises `branching.checkpoint`, `branching.fork`, `retrySafe`,
114
181
  `lookup`, and `cleanup` only when the linked SDK exposes the complete managed
115
- surface: keyed `snapshot` and `fork`, operation lookup, inventory recovery, and
182
+ surface: keyed `snapshot`, durable snapshot restore, inventory recovery, and
116
183
  explicit deletion outcomes.
117
184
  An incomplete SDK surface clears every branching flag and omits
118
185
  `environment.workspaceBranching`.
@@ -122,9 +189,10 @@ calling Sandbox.
122
189
  Retries with the same key replay the original resource, while changed material
123
190
  returns a conflict containing the original interface digest.
124
191
  The provider stores a bounded request marker in snapshot tags and child
125
- metadata so a fresh process can recover the exact interface digest, but the
126
- marker only names a candidate: the Sandbox operation ledger still has to report
127
- a settled success before the adapter returns the resource.
192
+ metadata so a fresh process can recover the exact interface digest.
193
+ Snapshot-created children are validated through the marker and complete
194
+ account inventory, while legacy fork markers also require the Sandbox fork
195
+ ledger to report a settled success.
128
196
  Checkpoint deletion reports `in_use` with every verified child that still
129
197
  references it; delete the child first, then retry checkpoint deletion.
130
198
  The adapter never treats an SDK response without an explicit idempotency or
@@ -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
@@ -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. So `create()` can return a box
18
+ * that reports `running` while the platform still holds a lifecycle operation
19
+ * on it, and the first turn lands on that lock.
20
+ *
21
+ * Measured against that mechanism, 2026-09-01, discovery-lab#467: one box
22
+ * (`sandbox-97943ce9526d`) came back from `create()`, and the first
23
+ * `environment.stream()` failed 78 seconds later with "A sandbox lifecycle
24
+ * operation is already in progress". That string is the platform's, not the
25
+ * SDK's, and n is 1, so the incarnation window is the mechanism this wait
26
+ * addresses rather than a proven cause. A lifecycle lock held by a genuinely
27
+ * concurrent operation is a different failure, and no client-side wait
28
+ * prevents it.
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. So `create()` can return a box
19
+ * that reports `running` while the platform still holds a lifecycle operation
20
+ * on it, and the first turn lands on that lock.
21
+ *
22
+ * Measured against that mechanism, 2026-09-01, discovery-lab#467: one box
23
+ * (`sandbox-97943ce9526d`) came back from `create()`, and the first
24
+ * `environment.stream()` failed 78 seconds later with "A sandbox lifecycle
25
+ * operation is already in progress". That string is the platform's, not the
26
+ * SDK's, and n is 1, so the incarnation window is the mechanism this wait
27
+ * addresses rather than a proven cause. A lifecycle lock held by a genuinely
28
+ * concurrent operation is a different failure, and no client-side wait
29
+ * prevents it.
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
  }
@@ -303,47 +303,64 @@ export function createTangleWorkspaceBranching(options) {
303
303
  : "Sandbox checkpoint inventory is unavailable", true);
304
304
  }
305
305
  const metadata = forkMarkerMetadata(request);
306
- let result;
306
+ let returnedChild;
307
+ let outcome;
307
308
  try {
308
- result = await awaitWithSignal(box.fork?.(1, {
309
- metadata,
309
+ // Sandbox fork copies live VM memory, not the durable workspace view.
310
+ // Restore the checkpoint so files written before the checkpoint survive.
311
+ returnedChild = await awaitWithSignal(client.create({
312
+ fromSnapshot: request.checkpoint.checkpointId,
313
+ fromSandboxId: box.id,
310
314
  idempotencyKey: request.idempotencyKey,
311
- }), operation?.signal);
315
+ metadata,
316
+ ...(request.name === undefined ? {} : { name: request.name }),
317
+ }, operation?.signal === undefined
318
+ ? undefined
319
+ : { signal: operation.signal }), operation?.signal);
320
+ const receipt = returnedChild.createReceipt?.();
321
+ if (!receipt ||
322
+ receipt.idempotencyKeyApplied !== true ||
323
+ (receipt.outcome !== "created" &&
324
+ receipt.outcome !== "idempotent_replay")) {
325
+ const compensation = await compensateCreatedForkChild(returnedChild, receipt?.outcome === "created" ? "created" : "replayed", operation?.signal);
326
+ const removed = compensation === "destroyed" || compensation === "already_absent";
327
+ return forkUnknown(request, removed
328
+ ? "Sandbox snapshot restore returned no complete create receipt; the newly created child was removed"
329
+ : "Sandbox snapshot restore returned no complete create receipt; child cleanup was not confirmed", !removed);
330
+ }
331
+ outcome =
332
+ receipt.outcome === "idempotent_replay" ? "replayed" : "created";
312
333
  }
313
334
  catch (error) {
314
335
  operation?.signal?.throwIfAborted();
315
336
  const conflict = await forkConflictFromRemote(client, box, provider, request, options.confidentialAttestationVerifier, operation?.signal);
316
337
  return (conflict ??
317
- forkUnknown(request, `Sandbox fork outcome is unresolved: ${safeError(error)}`, true));
318
- }
319
- if (!result ||
320
- !validForkResult(result) ||
321
- result.idempotency === undefined ||
322
- (result.idempotency.outcome !== "created" &&
323
- result.idempotency.outcome !== "replayed") ||
324
- safeString(result.idempotency.requestDigest) === undefined) {
325
- return forkUnknown(request, "Sandbox fork returned no complete idempotent acknowledgement", true);
326
- }
327
- if (result.children.length !== 1 || result.complete !== true) {
328
- return forkUnknown(request, "Sandbox fork did not materialize exactly one complete child", true);
338
+ forkUnknown(request, `Sandbox snapshot restore outcome is unresolved: ${safeError(error)}`, true));
339
+ }
340
+ if (!returnedChild ||
341
+ typeof returnedChild !== "object" ||
342
+ safeIdentifier(returnedChild.id) === undefined ||
343
+ returnedChild.id === box.id) {
344
+ const compensation = returnedChild
345
+ ? await compensateCreatedForkChild(returnedChild, outcome, operation?.signal)
346
+ : "not_attempted";
347
+ const removed = compensation === "destroyed" || compensation === "already_absent";
348
+ return forkUnknown(request, removed
349
+ ? "Sandbox snapshot restore returned an invalid child; the newly created child was removed"
350
+ : "Sandbox snapshot restore returned an invalid child; child cleanup was not confirmed", !removed);
329
351
  }
330
- const returnedChild = result.children[0];
331
352
  const child = await completeForkChild(client, returnedChild, operation?.signal);
332
353
  if (!child) {
333
- const compensation = forkMarkerFromMetadata(returnedChild.metadata) === undefined
334
- ? await compensateUnmarkedForkChild(result, returnedChild, operation?.signal)
335
- : "not_attempted";
354
+ const compensation = await compensateCreatedForkChild(returnedChild, outcome, operation?.signal);
336
355
  const removed = compensation === "destroyed" || compensation === "already_absent";
337
356
  return forkUnknown(request, removed
338
- ? "Sandbox fork returned a child without a complete identity; the newly created child was removed"
339
- : compensation === "unconfirmed"
340
- ? "Sandbox fork returned a child without a complete identity; child cleanup was not confirmed"
341
- : "Sandbox fork returned a child without a complete identity", !removed);
357
+ ? "Sandbox snapshot restore returned a child without a complete identity; the newly created child was removed"
358
+ : "Sandbox snapshot restore returned a child without a complete identity; child cleanup was not confirmed", !removed);
342
359
  }
343
360
  const childMarker = forkMarkerFromMetadata(child.metadata);
344
361
  if (childMarker) {
345
362
  if (!markerBelongsToSource(childMarker, provider, box.id)) {
346
- return forkUnknown(request, "Sandbox fork returned a child marked for another source", true);
363
+ return forkUnknown(request, "Sandbox snapshot restore returned a child marked for another source", true);
347
364
  }
348
365
  if (childMarker.idempotencyKey !== request.idempotencyKey ||
349
366
  childMarker.requestDigest !== request.requestDigest) {
@@ -351,21 +368,19 @@ export function createTangleWorkspaceBranching(options) {
351
368
  }
352
369
  }
353
370
  if (!childMarker) {
354
- const compensation = await compensateUnmarkedForkChild(result, child, operation?.signal);
371
+ const compensation = await compensateCreatedForkChild(returnedChild, outcome, operation?.signal);
355
372
  const removed = compensation === "destroyed" || compensation === "already_absent";
356
373
  return forkUnknown(request, removed
357
- ? "Sandbox fork acknowledgement omitted its provider recovery marker; the newly created child was removed"
358
- : compensation === "unconfirmed"
359
- ? "Sandbox fork acknowledgement omitted its provider recovery marker; child cleanup was not confirmed"
360
- : "Sandbox fork acknowledgement omitted its provider recovery marker", !removed);
374
+ ? "Sandbox snapshot restore acknowledgement omitted its provider recovery marker; the newly created child was removed"
375
+ : "Sandbox snapshot restore acknowledgement omitted its provider recovery marker; child cleanup was not confirmed", !removed);
361
376
  }
362
377
  const environment = await environmentFromChild(request, child, provider, child.createdAt, options.confidentialAttestationVerifier, operation?.signal);
363
378
  if (!environment) {
364
- return forkUnknown(request, "Sandbox fork returned a child without a valid identity", true);
379
+ return forkUnknown(request, "Sandbox snapshot restore returned a child without a valid identity", true);
365
380
  }
366
381
  const record = { request, environment, child };
367
382
  forks.set(request.idempotencyKey, record);
368
- return forkSuccess(request, environment, result.idempotency.outcome);
383
+ return forkSuccess(request, environment, outcome);
369
384
  };
370
385
  const lookupFork = async (input, operation) => {
371
386
  const request = WorkspaceOperationLookupRequestSchema.parse(input);
@@ -461,14 +476,13 @@ export function createTangleWorkspaceBranching(options) {
461
476
  }
462
477
  /** Capability support requires every operation used by recovery and cleanup. */
463
478
  export function supportsWorkspaceBranching(box, client) {
464
- return (typeof client.list === "function" &&
479
+ return (typeof client.create === "function" &&
480
+ typeof client.list === "function" &&
465
481
  typeof client.get === "function" &&
466
482
  typeof box.snapshot === "function" &&
467
483
  typeof box.listSnapshots === "function" &&
468
484
  typeof box.deleteSnapshot === "function" &&
469
- typeof box.getSnapshotOperation === "function" &&
470
- typeof box.fork === "function" &&
471
- typeof box.getForkOperation === "function");
485
+ typeof box.getSnapshotOperation === "function");
472
486
  }
473
487
  /** Drop every in-process record for a resource the platform no longer holds. */
474
488
  function forgetRecords(records, matches) {
@@ -689,16 +703,6 @@ function validSnapshotInfo(snapshot, sandboxId) {
689
703
  safeIdentifier(snapshot.sandboxId) !== undefined &&
690
704
  (sandboxId === undefined || snapshot.sandboxId === sandboxId));
691
705
  }
692
- function validForkResult(result) {
693
- return (!!result &&
694
- Array.isArray(result.children) &&
695
- result.children.every((child) => child !== null &&
696
- typeof child === "object" &&
697
- safeIdentifier(child.id) !== undefined) &&
698
- result.requestedCount === 1 &&
699
- result.materializedCount === result.children.length &&
700
- typeof result.complete === "boolean");
701
- }
702
706
  function validOperationRecord(value) {
703
707
  return value !== null && typeof value === "object" && !Array.isArray(value);
704
708
  }
@@ -750,7 +754,7 @@ function legacyCheckpointMarkerTags(request) {
750
754
  ...chunks.map((chunk, index) => `${base}:material:${index}:${chunks.length}:${chunk}`),
751
755
  ];
752
756
  }
753
- function forkMarkerMetadata(request) {
757
+ function forkMarkerMetadata(request, materialization = "snapshot") {
754
758
  if (request.metadata && Object.hasOwn(request.metadata, FORK_METADATA_KEY)) {
755
759
  throw new Error(`fork metadata reserves ${FORK_METADATA_KEY}`);
756
760
  }
@@ -760,6 +764,7 @@ function forkMarkerMetadata(request) {
760
764
  idempotencyKey: request.idempotencyKey,
761
765
  requestDigest: request.requestDigest,
762
766
  request,
767
+ ...(materialization === "snapshot" ? { materialization } : {}),
763
768
  };
764
769
  assertBoundedJson(marker);
765
770
  return {
@@ -787,11 +792,18 @@ async function checkpointOperationSucceeded(box, marker, signal) {
787
792
  lookup.kind === "checkpoint" &&
788
793
  lookup.state === "succeeded");
789
794
  }
790
- /** The fork equivalent of {@link checkpointOperationSucceeded}. */
795
+ /** Confirm a fork child through its marker or the legacy fork ledger. */
791
796
  async function forkOperationLookup(box, marker, signal) {
797
+ if (marker.materialization === "snapshot") {
798
+ return {
799
+ outcome: "found",
800
+ kind: "fork",
801
+ state: "succeeded",
802
+ };
803
+ }
792
804
  const lookup = await awaitWithSignal(box.getForkOperation?.(marker.idempotencyKey, {
793
805
  count: 1,
794
- metadata: forkMarkerMetadata(marker.request),
806
+ metadata: forkMarkerMetadata(marker.request, marker.materialization),
795
807
  }), signal);
796
808
  return lookup;
797
809
  }
@@ -1023,9 +1035,9 @@ async function completeForkChild(client, child, signal) {
1023
1035
  return undefined;
1024
1036
  }
1025
1037
  }
1026
- /** Remove only a child this exact call confirmed it created. */
1027
- async function compensateUnmarkedForkChild(result, child, signal) {
1028
- if (result.idempotency?.outcome !== "created")
1038
+ /** Remove only a child this exact create call confirmed it created. */
1039
+ async function compensateCreatedForkChild(child, outcome, signal) {
1040
+ if (outcome !== "created")
1029
1041
  return "not_attempted";
1030
1042
  if (typeof child.delete !== "function")
1031
1043
  return "unconfirmed";
@@ -1266,6 +1278,10 @@ function forkMarkerFromMetadata(metadata, key) {
1266
1278
  typeof parsed.idempotencyKey !== "string" ||
1267
1279
  typeof parsed.requestDigest !== "string")
1268
1280
  return undefined;
1281
+ if (parsed.materialization !== undefined &&
1282
+ parsed.materialization !== "snapshot") {
1283
+ return undefined;
1284
+ }
1269
1285
  if (key !== undefined && parsed.idempotencyKey !== key)
1270
1286
  return undefined;
1271
1287
  const request = WorkspaceForkRequestSchema.safeParse(parsed.request);
@@ -1279,6 +1295,9 @@ function forkMarkerFromMetadata(metadata, key) {
1279
1295
  idempotencyKey: parsed.idempotencyKey,
1280
1296
  requestDigest: parsed.requestDigest,
1281
1297
  request: request.data,
1298
+ ...(parsed.materialization === "snapshot"
1299
+ ? { materialization: "snapshot" }
1300
+ : {}),
1282
1301
  };
1283
1302
  }
1284
1303
  /**
package/package.json CHANGED
@@ -1,16 +1,15 @@
1
1
  {
2
2
  "name": "@tangle-network/agent-provider-tangle",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
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
+ }