@tangle-network/agent-provider-tangle 0.14.0 → 0.14.2

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,19 +1,19 @@
1
1
  # @tangle-network/agent-provider-tangle
2
2
 
3
3
  Wraps `@tangle-network/sandbox` as an `AgentEnvironmentProvider`.
4
- The peer range is `>=0.33.1 <1.0.0`, and this package is developed and tested against 0.33.1.
5
- The floor is 0.33.1 because workspace branching uses the keyed snapshot, fork,
4
+ The peer range is `>=0.34.0 <1.0.0`, and this package is developed and tested against 0.34.3.
5
+ The floor is 0.34.0 because workspace branching uses the keyed snapshot, fork,
6
6
  lookup, and cleanup operations added to that SDK.
7
7
  The provider fails closed when the configured backend or its catalog entry cannot be read.
8
8
  Newer SDKs may also provide `getBackend()` as a lookup over the same catalog.
9
9
 
10
10
  ```ts
11
- import { Sandbox } from '@tangle-network/sandbox'
12
- import { createTangleProvider } from '@tangle-network/agent-provider-tangle'
11
+ import { Sandbox } from "@tangle-network/sandbox";
12
+ import { createTangleProvider } from "@tangle-network/agent-provider-tangle";
13
13
 
14
14
  const provider = createTangleProvider({
15
15
  client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }),
16
- })
16
+ });
17
17
  ```
18
18
 
19
19
  Detached dispatch returns the immutable Sandbox execution receipt in `controlRef`.
@@ -43,16 +43,16 @@ One provider reaches deployments of different ages, which is why the environment
43
43
 
44
44
  Each deployment flag this adapter reads gates the claims it backs, and no flag is read that gates nothing:
45
45
 
46
- | Deployment flag | Claims it gates |
47
- | --- | --- |
48
- | `dispatch.runControlRef` | `streaming.detach`, `streaming.turnIdempotency`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
46
+ | Deployment flag | Claims it gates |
47
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------ |
48
+ | `dispatch.runControlRef` | `streaming.detach`, `streaming.turnIdempotency`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
49
49
  | `dispatch.executionIdOnAdmission` | `streaming.detach`, `streaming.turnIdempotency`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
50
- | `cancel.canonicalRunCancellation` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
51
- | `cancel.digestBound` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
52
- | `cancel.idempotent` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
53
- | `runs.eventReplay` | `streaming.replay`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
54
- | `runs.executionScopedStatus` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
55
- | `interactions.responseDedupe` | `interactions`, `environment.respondToInteraction`, `session.respondToInteraction` |
50
+ | `cancel.canonicalRunCancellation` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
51
+ | `cancel.digestBound` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
52
+ | `cancel.idempotent` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
53
+ | `runs.eventReplay` | `streaming.replay`, `sessions.continue`, `retainedControl`, `session.cancelRun` |
54
+ | `runs.executionScopedStatus` | `sessions.continue`, `retainedControl`, `session.cancelRun` |
55
+ | `interactions.responseDedupe` | `interactions`, `environment.respondToInteraction`, `session.respondToInteraction` |
56
56
 
57
57
  Detached dispatch carries the caller's exact reference and refuses a receipt that does not name the execution back, so it needs both `dispatch` flags and a session handle to reach the run through.
58
58
  `sessions.continue`, `retainedControl`, and `session.cancelRun` need every flag in the table, because the capability schema refuses a partial retained-control block and each identity rests on its own flag.
@@ -70,19 +70,19 @@ The claim and the two methods stand or fall together.
70
70
  It is offered on the environment and on a session handle; the environment routes the command to the session its binding names.
71
71
  The command carries only the answer the caller supplied. No field is filled in on the caller's behalf, and an answer the outstanding ask's spec rejects is refused with the field named.
72
72
 
73
- | Result | Acknowledgement status |
74
- | --- | --- |
75
- | The deployment recorded and delivered the response | `accepted` |
76
- | The deployment already holds this exact response | `already_resolved_same` |
73
+ | Result | Acknowledgement status |
74
+ | ------------------------------------------------------------- | ---------------------------------------------------------------- |
75
+ | The deployment recorded and delivered the response | `accepted` |
76
+ | The deployment already holds this exact response | `already_resolved_same` |
77
77
  | The deployment already holds a different response for the ask | `already_resolved_different`, message naming the recorded digest |
78
- | The command names another provider, environment, or session | `binding_mismatch` |
79
- | The deployment refuses the binding | `binding_mismatch` |
80
- | The ask is unknown to the deployment | `unknown_interaction` |
81
- | The session is unknown to the deployment | `unknown_run` |
82
- | The ask left the outstanding set on a deadline | `expired` |
83
- | The ask's spec rejects the answer | `invalid_response`, message naming each field |
84
- | The response is recorded and delivery is unconfirmed | `transport_failure`, `retryable: true` |
85
- | The request did not reach the route | `transport_failure`, `retryable: true` |
78
+ | The command names another provider, environment, or session | `binding_mismatch` |
79
+ | The deployment refuses the binding | `binding_mismatch` |
80
+ | The ask is unknown to the deployment | `unknown_interaction` |
81
+ | The session is unknown to the deployment | `unknown_run` |
82
+ | The ask left the outstanding set on a deadline | `expired` |
83
+ | The ask's spec rejects the answer | `invalid_response`, message naming each field |
84
+ | The response is recorded and delivery is unconfirmed | `transport_failure`, `retryable: true` |
85
+ | The request did not reach the route | `transport_failure`, `retryable: true` |
86
86
 
87
87
  A refusal is never reported as a success, and a recorded response whose delivery the deployment does not confirm is never reported as `accepted`.
88
88
 
@@ -150,24 +150,24 @@ Clean branch resources before deleting the source or use a platform reaper.
150
150
  import {
151
151
  workspaceCheckpointRequestDigest,
152
152
  workspaceForkRequestDigest,
153
- } from '@tangle-network/agent-interface'
153
+ } from "@tangle-network/agent-interface";
154
154
 
155
155
  const checkpoint = await environment.workspaceBranching?.checkpoint({
156
156
  source: exactRun,
157
- idempotencyKey: 'checkpoint-before-analysis',
157
+ idempotencyKey: "checkpoint-before-analysis",
158
158
  requestDigest: workspaceCheckpointRequestDigest({ source: exactRun }),
159
- })
159
+ });
160
160
 
161
- if (checkpoint?.status === 'created' || checkpoint?.status === 'replayed') {
161
+ if (checkpoint?.status === "created" || checkpoint?.status === "replayed") {
162
162
  const fork = await environment.workspaceBranching?.fork({
163
163
  checkpoint: checkpoint.checkpoint,
164
- placement: { kind: 'sandbox', sandboxId: 'analysis-worker' },
165
- idempotencyKey: 'analysis-worker',
164
+ placement: { kind: "sandbox", sandboxId: "analysis-worker" },
165
+ idempotencyKey: "analysis-worker",
166
166
  requestDigest: workspaceForkRequestDigest({
167
167
  checkpoint: checkpoint.checkpoint,
168
- placement: { kind: 'sandbox', sandboxId: 'analysis-worker' },
168
+ placement: { kind: "sandbox", sandboxId: "analysis-worker" },
169
169
  }),
170
- })
170
+ });
171
171
  }
172
172
  ```
173
173
 
@@ -192,28 +192,35 @@ const provider = createTangleProvider({
192
192
  client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }),
193
193
  confidentialAttestationVerifier: async ({ report, attestation }) =>
194
194
  (await verifyTangleQuote({ report, attestation })) ?? null,
195
- })
195
+ });
196
196
  ```
197
197
 
198
+ The provider stores the raw report in `ConfidentialAttestation.quote` through
199
+ `encodeTangleConfidentialAttestationQuote`.
200
+ The quote is a versioned canonical JSON object with base64url byte fields.
201
+ Use `decodeTangleConfidentialAttestationQuote` to recover a report and reject
202
+ legacy numeric-array quotes, unknown fields, malformed bytes, and oversized
203
+ reports before verification.
204
+
198
205
  ## Environment observation
199
206
 
200
207
  `environment.observe()` returns the normalized `AgentEnvironmentObservation`.
201
208
  Every surface carries a freshness discriminator, so a value the Sandbox SDK does not report is visibly absent instead of arriving as a measured zero.
202
209
  The `observation` capability flag for a surface is true only when a source can put a value on it for that environment; the observation itself always carries the surface, with `unavailable` and its reason when there is nothing to report.
203
210
 
204
- | Surface | Sandbox source | State when the source is missing |
205
- | --- | --- | --- |
206
- | `identity` | provider name and `box.id` | always known |
207
- | `lifecycle.status` | `box.status` after a refresh | `stale` with the refresh failure |
208
- | `lifecycle.cleanup` | `box.expiresAt` as a scheduled retirement | omitted from the lifecycle value |
209
- | `endpoint` | scheme, host, and explicit port of `connection.runtimeUrl` | `unavailable` |
210
- | `placement.verified` | `client.describePlacement(box)` | `unavailable` |
211
- | `resources.requested` | the `create()` resource request | omitted on an environment rebuilt by id |
212
- | `resources.effective` | cgroup `memoryLimitMb` and the attached GPU lease | `unavailable` |
213
- | `resourceUse.current` / `peak` | cgroup `memoryCurrentMb` / `memoryPeakMb` | `unavailable` |
214
- | `modelUsage` | the newest execution this handle measured | `unavailable` |
215
- | `computeBilling` | the GPU lease's billed or estimated customer cost | `unavailable` |
216
- | `accountUsage` | `client.subscription()` and `client.usage()` | `unavailable` |
211
+ | Surface | Sandbox source | State when the source is missing |
212
+ | ------------------------------ | ---------------------------------------------------------- | --------------------------------------- |
213
+ | `identity` | provider name and `box.id` | always known |
214
+ | `lifecycle.status` | `box.status` after a refresh | `stale` with the refresh failure |
215
+ | `lifecycle.cleanup` | `box.expiresAt` as a scheduled retirement | omitted from the lifecycle value |
216
+ | `endpoint` | scheme, host, and explicit port of `connection.runtimeUrl` | `unavailable` |
217
+ | `placement.verified` | `client.describePlacement(box)` | `unavailable` |
218
+ | `resources.requested` | the `create()` resource request | omitted on an environment rebuilt by id |
219
+ | `resources.effective` | cgroup `memoryLimitMb` and the attached GPU lease | `unavailable` |
220
+ | `resourceUse.current` / `peak` | cgroup `memoryCurrentMb` / `memoryPeakMb` | `unavailable` |
221
+ | `modelUsage` | the newest execution this handle measured | `unavailable` |
222
+ | `computeBilling` | the GPU lease's billed or estimated customer cost | `unavailable` |
223
+ | `accountUsage` | `client.subscription()` and `client.usage()` | `unavailable` |
217
224
 
218
225
  Four quantities have no Sandbox source at all, and the adapter reports them as absent rather than deriving them.
219
226
  There is no effective CPU or disk figure anywhere in the SDK, so `resources.effective` carries memory and the accelerator only.
@@ -259,16 +266,16 @@ Set `teamId` inside `exactProcess` to scope create, lookup, and recovery to one
259
266
  const provider = createTangleProvider({
260
267
  client: new Sandbox({ apiKey: process.env.TANGLE_API_KEY }),
261
268
  exactProcess: {},
262
- })
269
+ });
263
270
 
264
271
  const environment = await provider.exactProcess!.create({
265
- image: 'ghcr.io/acme/agent@sha256:<64-hex-manifest-digest>',
266
- egress: { mode: 'blocked' },
272
+ image: "ghcr.io/acme/agent@sha256:<64-hex-manifest-digest>",
273
+ egress: { mode: "blocked" },
267
274
  maxLifetimeMs: 120_000,
268
275
  resources: { cpu: 1, memoryMb: 1024, diskMb: 1024 },
269
- metadata: { executionId: 'run-1' },
270
- idempotencyKey: 'run-1',
271
- })
276
+ metadata: { executionId: "run-1" },
277
+ idempotencyKey: "run-1",
278
+ });
272
279
  ```
273
280
 
274
281
  The adapter rejects ordinary sandboxes during create, recovery, and list operations.
package/dist/index.d.ts CHANGED
@@ -4,4 +4,6 @@ export { createTangleProvider } from "./tangle-provider.js";
4
4
  export { defaultTangleSandboxCapabilities } from "./tangle-capabilities.js";
5
5
  export { createTangleWorkspaceBranching, supportsWorkspaceBranching, } from "./tangle-workspace-branching.js";
6
6
  export type { TangleWorkspaceBranchingOptions } from "./tangle-workspace-branching.js";
7
+ 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";
8
+ export type { TangleConfidentialAttestationReport } from "./tangle-confidential-attestation.js";
7
9
  export { safeEndpointFromConnection } from "./tangle-observation.js";
package/dist/index.js CHANGED
@@ -2,4 +2,5 @@ export * from "./tangle-types.js";
2
2
  export { createTangleProvider } from "./tangle-provider.js";
3
3
  export { defaultTangleSandboxCapabilities } from "./tangle-capabilities.js";
4
4
  export { createTangleWorkspaceBranching, supportsWorkspaceBranching, } from "./tangle-workspace-branching.js";
5
+ 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";
5
6
  export { safeEndpointFromConnection } from "./tangle-observation.js";
@@ -0,0 +1,31 @@
1
+ import type { SandboxTeeAttestationReportLike } from "./tangle-types.js";
2
+ /** Version of the canonical opaque quote carried by a confidential attestation. */
3
+ export declare const TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_VERSION: 1;
4
+ /** Kind discriminator prevents this codec from accepting another quote format. */
5
+ export declare const TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_KIND: "tangle-sandbox-tee";
6
+ /** Raw evidence bound before it can enter a portable attestation. */
7
+ export declare const MAX_TEE_EVIDENCE_BYTES = 16384;
8
+ /** Raw measurement bound before it can enter a portable attestation. */
9
+ export declare const MAX_TEE_MEASUREMENT_BYTES = 256;
10
+ /** Read-only report shape accepted by the quote encoder. */
11
+ export interface TangleConfidentialAttestationReport {
12
+ readonly tee_type: string;
13
+ readonly evidence: readonly number[];
14
+ readonly measurement: readonly number[];
15
+ readonly timestamp: number;
16
+ }
17
+ /**
18
+ * Encode a Sandbox TEE report without decimal JSON byte arrays.
19
+ *
20
+ * The returned JSON uses a fixed key order and unpadded base64url byte fields.
21
+ * `undefined` means the report is not a valid bounded report or the quote does
22
+ * not fit the shared confidential-attestation field.
23
+ */
24
+ export declare function encodeTangleConfidentialAttestationQuote(report: TangleConfidentialAttestationReport): string | undefined;
25
+ /**
26
+ * Decode and canonicalize a provider quote.
27
+ *
28
+ * The parser rejects old numeric-array quotes, unknown fields, non-canonical
29
+ * JSON, malformed base64url, and reports outside the provider bounds.
30
+ */
31
+ export declare function decodeTangleConfidentialAttestationQuote(quote: unknown): SandboxTeeAttestationReportLike | undefined;
@@ -0,0 +1,165 @@
1
+ import { CONTRACT_MAX_CONFIDENTIAL_ATTESTATION_QUOTE_LENGTH, } from "@tangle-network/agent-interface";
2
+ /** Version of the canonical opaque quote carried by a confidential attestation. */
3
+ export const TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_VERSION = 1;
4
+ /** Kind discriminator prevents this codec from accepting another quote format. */
5
+ export const TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_KIND = "tangle-sandbox-tee";
6
+ /** Raw evidence bound before it can enter a portable attestation. */
7
+ export const MAX_TEE_EVIDENCE_BYTES = 16_384;
8
+ /** Raw measurement bound before it can enter a portable attestation. */
9
+ export const MAX_TEE_MEASUREMENT_BYTES = 256;
10
+ const MAX_TEE_TYPE_LENGTH = 64;
11
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
12
+ const QUOTE_KEYS = [
13
+ "version",
14
+ "kind",
15
+ "tee_type",
16
+ "evidence",
17
+ "measurement",
18
+ "timestamp",
19
+ ];
20
+ /**
21
+ * Encode a Sandbox TEE report without decimal JSON byte arrays.
22
+ *
23
+ * The returned JSON uses a fixed key order and unpadded base64url byte fields.
24
+ * `undefined` means the report is not a valid bounded report or the quote does
25
+ * not fit the shared confidential-attestation field.
26
+ */
27
+ export function encodeTangleConfidentialAttestationQuote(report) {
28
+ if (!validReportShape(report))
29
+ return undefined;
30
+ const document = {
31
+ version: TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_VERSION,
32
+ kind: TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_KIND,
33
+ tee_type: report.tee_type,
34
+ evidence: bytesToBase64url(report.evidence),
35
+ measurement: bytesToBase64url(report.measurement),
36
+ timestamp: report.timestamp,
37
+ };
38
+ const quote = JSON.stringify(document);
39
+ return quote.length <= CONTRACT_MAX_CONFIDENTIAL_ATTESTATION_QUOTE_LENGTH
40
+ ? quote
41
+ : undefined;
42
+ }
43
+ /**
44
+ * Decode and canonicalize a provider quote.
45
+ *
46
+ * The parser rejects old numeric-array quotes, unknown fields, non-canonical
47
+ * JSON, malformed base64url, and reports outside the provider bounds.
48
+ */
49
+ export function decodeTangleConfidentialAttestationQuote(quote) {
50
+ if (typeof quote !== "string" ||
51
+ quote.length === 0 ||
52
+ quote.length > CONTRACT_MAX_CONFIDENTIAL_ATTESTATION_QUOTE_LENGTH) {
53
+ return undefined;
54
+ }
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(quote);
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ const document = quoteDocument(parsed);
63
+ if (document === undefined)
64
+ return undefined;
65
+ const report = reportFromDocument(document);
66
+ if (report === undefined)
67
+ return undefined;
68
+ return encodeTangleConfidentialAttestationQuote(report) === quote
69
+ ? report
70
+ : undefined;
71
+ }
72
+ function validReportShape(report) {
73
+ if (!isPlainObject(report))
74
+ return false;
75
+ if (!hasExactKeys(report, ["tee_type", "evidence", "measurement", "timestamp"])) {
76
+ return false;
77
+ }
78
+ return (validTeeType(report.tee_type) &&
79
+ validBytes(report.evidence, MAX_TEE_EVIDENCE_BYTES) &&
80
+ validBytes(report.measurement, MAX_TEE_MEASUREMENT_BYTES) &&
81
+ Number.isSafeInteger(report.timestamp) &&
82
+ report.timestamp > 0);
83
+ }
84
+ function quoteDocument(value) {
85
+ if (!isPlainObject(value) || !hasExactKeys(value, QUOTE_KEYS))
86
+ return undefined;
87
+ const candidate = value;
88
+ const teeType = candidate.tee_type;
89
+ const evidenceValue = candidate.evidence;
90
+ const measurementValue = candidate.measurement;
91
+ const timestamp = candidate.timestamp;
92
+ if (candidate.version !== TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_VERSION ||
93
+ candidate.kind !== TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_KIND ||
94
+ !validTeeType(teeType) ||
95
+ typeof evidenceValue !== "string" ||
96
+ typeof measurementValue !== "string" ||
97
+ typeof timestamp !== "number" ||
98
+ !Number.isSafeInteger(timestamp) ||
99
+ timestamp <= 0) {
100
+ return undefined;
101
+ }
102
+ const evidence = base64urlToBytes(evidenceValue, MAX_TEE_EVIDENCE_BYTES);
103
+ const measurement = base64urlToBytes(measurementValue, MAX_TEE_MEASUREMENT_BYTES);
104
+ if (evidence === undefined || measurement === undefined)
105
+ return undefined;
106
+ return {
107
+ version: TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_VERSION,
108
+ kind: TANGLE_CONFIDENTIAL_ATTESTATION_QUOTE_KIND,
109
+ tee_type: teeType,
110
+ evidence: evidenceValue,
111
+ measurement: measurementValue,
112
+ timestamp,
113
+ };
114
+ }
115
+ function reportFromDocument(document) {
116
+ const evidence = base64urlToBytes(document.evidence, MAX_TEE_EVIDENCE_BYTES);
117
+ const measurement = base64urlToBytes(document.measurement, MAX_TEE_MEASUREMENT_BYTES);
118
+ if (evidence === undefined || measurement === undefined)
119
+ return undefined;
120
+ const report = {
121
+ tee_type: document.tee_type,
122
+ evidence: Array.from(evidence),
123
+ measurement: Array.from(measurement),
124
+ timestamp: document.timestamp,
125
+ };
126
+ return validReportShape(report) ? report : undefined;
127
+ }
128
+ function validTeeType(value) {
129
+ return (typeof value === "string" &&
130
+ value.length > 0 &&
131
+ value.length <= MAX_TEE_TYPE_LENGTH &&
132
+ value.trim() === value &&
133
+ !/[\u0000-\u001f\u007f]/u.test(value));
134
+ }
135
+ function validBytes(value, maxLength) {
136
+ return (Array.isArray(value) &&
137
+ value.length > 0 &&
138
+ value.length <= maxLength &&
139
+ value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255));
140
+ }
141
+ function bytesToBase64url(bytes) {
142
+ return Buffer.from(Uint8Array.from(bytes)).toString("base64url");
143
+ }
144
+ function base64urlToBytes(value, maxLength) {
145
+ if (value.length === 0 || !BASE64URL_PATTERN.test(value))
146
+ return undefined;
147
+ const bytes = Buffer.from(value, "base64url");
148
+ if (bytes.length === 0 ||
149
+ bytes.length > maxLength ||
150
+ bytes.toString("base64url") !== value) {
151
+ return undefined;
152
+ }
153
+ return bytes;
154
+ }
155
+ function isPlainObject(value) {
156
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
157
+ return false;
158
+ }
159
+ const prototype = Object.getPrototypeOf(value);
160
+ return prototype === Object.prototype || prototype === null;
161
+ }
162
+ function hasExactKeys(value, expected) {
163
+ const keys = Object.keys(value);
164
+ return keys.length === expected.length && keys.every((key) => expected.includes(key));
165
+ }
@@ -10,8 +10,8 @@ export interface TangleInteractiveAgentRegistry {
10
10
  /**
11
11
  * Test the linked SDK surface without accepting any older interactive shape.
12
12
  *
13
- * The current public Sandbox release fails this test. That is intentional:
14
- * it has no receipt, control claim, or mutation acknowledgement protocol.
13
+ * Sandbox owns the exact reference, control claim, terminal binding, and
14
+ * mutation acknowledgements. An older handle shape fails this test closed.
15
15
  */
16
16
  export declare function sandboxBacksInteractiveAgent(box: SandboxInstanceLike): boolean;
17
17
  /**