@tanstack/ai-sandbox 0.3.4 → 0.5.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 +26 -0
- package/dist/esm/approvals.js +5 -4
- package/dist/esm/approvals.js.map +1 -1
- package/dist/esm/bridge-events.js +7 -6
- package/dist/esm/bridge-events.js.map +1 -1
- package/dist/esm/checkpoint-store.d.ts +147 -0
- package/dist/esm/checkpoint-store.js +267 -0
- package/dist/esm/checkpoint-store.js.map +1 -0
- package/dist/esm/chunk-identity.d.ts +0 -17
- package/dist/esm/chunk-identity.js +28 -5
- package/dist/esm/chunk-identity.js.map +1 -1
- package/dist/esm/contracts.d.ts +19 -0
- package/dist/esm/index.d.ts +11 -1
- package/dist/esm/index.js +12 -7
- package/dist/esm/memory-snapshot-types.d.ts +129 -0
- package/dist/esm/memory-snapshots.d.ts +6 -0
- package/dist/esm/memory-snapshots.js +490 -0
- package/dist/esm/memory-snapshots.js.map +1 -0
- package/dist/esm/middleware.d.ts +33 -1
- package/dist/esm/middleware.js +339 -94
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/ngrok.d.ts +1 -1
- package/dist/esm/sandbox.d.ts +16 -0
- package/dist/esm/sandbox.js +62 -9
- package/dist/esm/sandbox.js.map +1 -1
- package/dist/esm/snapshot-operations.d.ts +65 -0
- package/dist/esm/snapshot-operations.js +317 -0
- package/dist/esm/snapshot-operations.js.map +1 -0
- package/dist/esm/snapshot-tools.d.ts +185 -0
- package/dist/esm/snapshot-tools.js +160 -0
- package/dist/esm/snapshot-tools.js.map +1 -0
- package/dist/esm/snapshots.d.ts +51 -0
- package/dist/esm/snapshots.js +350 -0
- package/dist/esm/snapshots.js.map +1 -0
- package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
- package/dist/esm/testkit/checkpoint-conformance.js +453 -0
- package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
- package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
- package/dist/esm/testkit/conformance.d.ts +4 -0
- package/dist/esm/testkit/conformance.js +3 -1
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/dist/esm/tool-history.js +4 -5
- package/dist/esm/tool-history.js.map +1 -1
- package/package.json +8 -3
- package/skills/ai-sandbox/SKILL.md +96 -8
- package/src/approvals.ts +13 -12
- package/src/bridge-events.ts +16 -10
- package/src/checkpoint-store.ts +652 -0
- package/src/chunk-identity.ts +36 -5
- package/src/contracts.ts +12 -0
- package/src/index.ts +56 -0
- package/src/memory-snapshot-types.ts +167 -0
- package/src/memory-snapshots.ts +936 -0
- package/src/middleware.ts +610 -160
- package/src/sandbox.ts +107 -6
- package/src/snapshot-operations.ts +540 -0
- package/src/snapshot-tools.ts +208 -0
- package/src/snapshots.ts +711 -0
- package/src/testkit/checkpoint-conformance.ts +472 -0
- package/src/testkit/checkpoint-fork-conformance.ts +299 -0
- package/src/testkit/conformance.ts +7 -0
- package/src/tool-history.ts +9 -13
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { SandboxSnapshotError } from "./snapshots.js";
|
|
2
|
+
import { toolDefinition } from "@tanstack/ai";
|
|
3
|
+
//#region src/snapshot-tools.ts
|
|
4
|
+
function field(value, key) {
|
|
5
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
6
|
+
return Reflect.get(value, key);
|
|
7
|
+
}
|
|
8
|
+
function requiredString(value, key) {
|
|
9
|
+
const candidate = field(value, key);
|
|
10
|
+
if (typeof candidate !== "string" || candidate.length === 0) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT", `Snapshot tool requires a non-empty ${key}`);
|
|
11
|
+
return candidate;
|
|
12
|
+
}
|
|
13
|
+
function optionalString(value, key) {
|
|
14
|
+
const candidate = field(value, key);
|
|
15
|
+
if (candidate === void 0) return void 0;
|
|
16
|
+
if (typeof candidate !== "string" || candidate.length === 0) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT", `Snapshot tool ${key} must be a non-empty string when provided`);
|
|
17
|
+
return candidate;
|
|
18
|
+
}
|
|
19
|
+
function requireIdentifier(value, label) {
|
|
20
|
+
if (value.length === 0) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT", `${label} must be a non-empty string`);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function createSnapshotTools(snapshots, options) {
|
|
24
|
+
const threadId = requireIdentifier(options.threadId, "threadId");
|
|
25
|
+
const runId = requireIdentifier(options.runId, "runId");
|
|
26
|
+
const createThreadId = options.createThreadId;
|
|
27
|
+
const tenant = options.tenant;
|
|
28
|
+
const onForked = options.onForked;
|
|
29
|
+
if (typeof createThreadId !== "function") throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT", "createSnapshotTools requires createThreadId");
|
|
30
|
+
return [
|
|
31
|
+
toolDefinition({
|
|
32
|
+
name: "save_sandbox_snapshot",
|
|
33
|
+
description: "Save a named checkpoint of the current live sandbox for this thread. Do not pass a thread id.",
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: { label: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description: "A short name for this version, such as release-1."
|
|
39
|
+
} },
|
|
40
|
+
required: ["label"],
|
|
41
|
+
additionalProperties: false
|
|
42
|
+
},
|
|
43
|
+
outputSchema: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {
|
|
46
|
+
checkpointId: { type: "string" },
|
|
47
|
+
label: { type: "string" },
|
|
48
|
+
threadId: { type: "string" }
|
|
49
|
+
},
|
|
50
|
+
required: [
|
|
51
|
+
"checkpointId",
|
|
52
|
+
"label",
|
|
53
|
+
"threadId"
|
|
54
|
+
],
|
|
55
|
+
additionalProperties: false
|
|
56
|
+
}
|
|
57
|
+
}).server(async (input) => {
|
|
58
|
+
const label = requiredString(input, "label");
|
|
59
|
+
const checkpoint = await snapshots.save({
|
|
60
|
+
threadId,
|
|
61
|
+
runId,
|
|
62
|
+
label,
|
|
63
|
+
...tenant === void 0 ? {} : { tenant }
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
checkpointId: checkpoint.id,
|
|
67
|
+
label: checkpoint.label ?? label,
|
|
68
|
+
threadId
|
|
69
|
+
};
|
|
70
|
+
}),
|
|
71
|
+
toolDefinition({
|
|
72
|
+
name: "fork_sandbox_snapshot",
|
|
73
|
+
description: "Copy one checkpoint from this thread into a new empty thread. Omit checkpointId to copy the latest checkpoint. Do not pass thread ids.",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: { checkpointId: {
|
|
77
|
+
type: "string",
|
|
78
|
+
description: "The checkpoint to copy. When omitted, the latest checkpoint is copied."
|
|
79
|
+
} },
|
|
80
|
+
additionalProperties: false
|
|
81
|
+
},
|
|
82
|
+
outputSchema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
checkpointId: { type: "string" },
|
|
86
|
+
destinationThreadId: { type: "string" }
|
|
87
|
+
},
|
|
88
|
+
required: ["checkpointId", "destinationThreadId"],
|
|
89
|
+
additionalProperties: false
|
|
90
|
+
}
|
|
91
|
+
}).server(async (input) => {
|
|
92
|
+
const checkpointId = optionalString(input, "checkpointId") ?? await snapshots.checkpoints.getHead(threadId);
|
|
93
|
+
if (checkpointId === null) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_MISSING_CHECKPOINT", "This thread has no checkpoint to fork");
|
|
94
|
+
const destinationThreadId = requireIdentifier(createThreadId(), "destinationThreadId");
|
|
95
|
+
const checkpoint = await snapshots.fork({
|
|
96
|
+
threadId,
|
|
97
|
+
checkpointId,
|
|
98
|
+
destinationThreadId
|
|
99
|
+
});
|
|
100
|
+
if (onForked !== void 0) await onForked({
|
|
101
|
+
destinationThreadId,
|
|
102
|
+
checkpointId: checkpoint.id
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
checkpointId: checkpoint.id,
|
|
106
|
+
destinationThreadId
|
|
107
|
+
};
|
|
108
|
+
}),
|
|
109
|
+
toolDefinition({
|
|
110
|
+
name: "read_sandbox_snapshot_artifact",
|
|
111
|
+
description: "Read metadata for one artifact on a checkpoint in this thread. Do not pass a thread id.",
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
checkpointId: { type: "string" },
|
|
116
|
+
artifactId: { type: "string" }
|
|
117
|
+
},
|
|
118
|
+
required: ["checkpointId", "artifactId"],
|
|
119
|
+
additionalProperties: false
|
|
120
|
+
},
|
|
121
|
+
outputSchema: {
|
|
122
|
+
type: "object",
|
|
123
|
+
properties: {
|
|
124
|
+
artifactId: { type: "string" },
|
|
125
|
+
name: { type: "string" },
|
|
126
|
+
mimeType: { type: "string" },
|
|
127
|
+
size: { type: "number" },
|
|
128
|
+
createdAt: { type: "number" }
|
|
129
|
+
},
|
|
130
|
+
required: [
|
|
131
|
+
"artifactId",
|
|
132
|
+
"name",
|
|
133
|
+
"mimeType",
|
|
134
|
+
"size",
|
|
135
|
+
"createdAt"
|
|
136
|
+
],
|
|
137
|
+
additionalProperties: false
|
|
138
|
+
}
|
|
139
|
+
}).server(async (input) => {
|
|
140
|
+
const checkpointId = requiredString(input, "checkpointId");
|
|
141
|
+
const artifactId = requiredString(input, "artifactId");
|
|
142
|
+
const resolved = await snapshots.readArtifact({
|
|
143
|
+
threadId,
|
|
144
|
+
checkpointId,
|
|
145
|
+
artifactId
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
artifactId: resolved.artifact.artifactId,
|
|
149
|
+
name: resolved.artifact.name,
|
|
150
|
+
mimeType: resolved.artifact.mimeType,
|
|
151
|
+
size: resolved.artifact.size,
|
|
152
|
+
createdAt: resolved.artifact.createdAt
|
|
153
|
+
};
|
|
154
|
+
})
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
export { createSnapshotTools };
|
|
159
|
+
|
|
160
|
+
//# sourceMappingURL=snapshot-tools.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"snapshot-tools.js","names":[],"sources":["../../src/snapshot-tools.ts"],"sourcesContent":["import { toolDefinition } from '@tanstack/ai'\nimport { SandboxSnapshotError } from './snapshots'\nimport type { SandboxSnapshots } from './snapshot-operations'\n\nexport interface CreateSnapshotToolsOptions {\n threadId: string\n runId: string\n createThreadId: () => string\n tenant?: { userId?: string; orgId?: string }\n onForked?: (input: {\n destinationThreadId: string\n checkpointId: string\n }) => void | Promise<void>\n}\n\nfunction field(value: unknown, key: string): unknown {\n if (value === null || typeof value !== 'object') return undefined\n return Reflect.get(value, key)\n}\n\nfunction requiredString(value: unknown, key: string): string {\n const candidate = field(value, key)\n if (typeof candidate !== 'string' || candidate.length === 0) {\n throw new SandboxSnapshotError(\n 'SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT',\n `Snapshot tool requires a non-empty ${key}`,\n )\n }\n return candidate\n}\n\nfunction optionalString(value: unknown, key: string): string | undefined {\n const candidate = field(value, key)\n if (candidate === undefined) return undefined\n if (typeof candidate !== 'string' || candidate.length === 0) {\n throw new SandboxSnapshotError(\n 'SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT',\n `Snapshot tool ${key} must be a non-empty string when provided`,\n )\n }\n return candidate\n}\n\nfunction requireIdentifier(value: string, label: string): string {\n if (value.length === 0) {\n throw new SandboxSnapshotError(\n 'SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT',\n `${label} must be a non-empty string`,\n )\n }\n return value\n}\n\nexport function createSnapshotTools(\n snapshots: SandboxSnapshots,\n options: CreateSnapshotToolsOptions,\n) {\n const threadId = requireIdentifier(options.threadId, 'threadId')\n const runId = requireIdentifier(options.runId, 'runId')\n const createThreadId = options.createThreadId\n const tenant = options.tenant\n const onForked = options.onForked\n if (typeof createThreadId !== 'function') {\n throw new SandboxSnapshotError(\n 'SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT',\n 'createSnapshotTools requires createThreadId',\n )\n }\n\n const save = toolDefinition({\n name: 'save_sandbox_snapshot',\n description:\n 'Save a named checkpoint of the current live sandbox for this thread. Do not pass a thread id.',\n inputSchema: {\n type: 'object',\n properties: {\n label: {\n type: 'string',\n description: 'A short name for this version, such as release-1.',\n },\n },\n required: ['label'],\n additionalProperties: false,\n },\n outputSchema: {\n type: 'object',\n properties: {\n checkpointId: { type: 'string' },\n label: { type: 'string' },\n threadId: { type: 'string' },\n },\n required: ['checkpointId', 'label', 'threadId'],\n additionalProperties: false,\n },\n }).server(async (input) => {\n const label = requiredString(input, 'label')\n const checkpoint = await snapshots.save({\n threadId,\n runId,\n label,\n ...(tenant === undefined ? {} : { tenant }),\n })\n return {\n checkpointId: checkpoint.id,\n label: checkpoint.label ?? label,\n threadId,\n }\n })\n\n const fork = toolDefinition({\n name: 'fork_sandbox_snapshot',\n description:\n 'Copy one checkpoint from this thread into a new empty thread. Omit checkpointId to copy the latest checkpoint. Do not pass thread ids.',\n inputSchema: {\n type: 'object',\n properties: {\n checkpointId: {\n type: 'string',\n description:\n 'The checkpoint to copy. When omitted, the latest checkpoint is copied.',\n },\n },\n additionalProperties: false,\n },\n outputSchema: {\n type: 'object',\n properties: {\n checkpointId: { type: 'string' },\n destinationThreadId: { type: 'string' },\n },\n required: ['checkpointId', 'destinationThreadId'],\n additionalProperties: false,\n },\n }).server(async (input) => {\n const suppliedCheckpointId = optionalString(input, 'checkpointId')\n const checkpointId =\n suppliedCheckpointId ?? (await snapshots.checkpoints.getHead(threadId))\n if (checkpointId === null) {\n throw new SandboxSnapshotError(\n 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT',\n 'This thread has no checkpoint to fork',\n )\n }\n const destinationThreadId = requireIdentifier(\n createThreadId(),\n 'destinationThreadId',\n )\n const checkpoint = await snapshots.fork({\n threadId,\n checkpointId,\n destinationThreadId,\n })\n if (onForked !== undefined) {\n await onForked({\n destinationThreadId,\n checkpointId: checkpoint.id,\n })\n }\n return {\n checkpointId: checkpoint.id,\n destinationThreadId,\n }\n })\n\n const readArtifact = toolDefinition({\n name: 'read_sandbox_snapshot_artifact',\n description:\n 'Read metadata for one artifact on a checkpoint in this thread. Do not pass a thread id.',\n inputSchema: {\n type: 'object',\n properties: {\n checkpointId: { type: 'string' },\n artifactId: { type: 'string' },\n },\n required: ['checkpointId', 'artifactId'],\n additionalProperties: false,\n },\n outputSchema: {\n type: 'object',\n properties: {\n artifactId: { type: 'string' },\n name: { type: 'string' },\n mimeType: { type: 'string' },\n size: { type: 'number' },\n createdAt: { type: 'number' },\n },\n required: ['artifactId', 'name', 'mimeType', 'size', 'createdAt'],\n additionalProperties: false,\n },\n }).server(async (input) => {\n const checkpointId = requiredString(input, 'checkpointId')\n const artifactId = requiredString(input, 'artifactId')\n const resolved = await snapshots.readArtifact({\n threadId,\n checkpointId,\n artifactId,\n })\n return {\n artifactId: resolved.artifact.artifactId,\n name: resolved.artifact.name,\n mimeType: resolved.artifact.mimeType,\n size: resolved.artifact.size,\n createdAt: resolved.artifact.createdAt,\n }\n })\n\n return [save, fork, readArtifact] as const\n}\n"],"mappings":";;;AAeA,SAAS,MAAM,OAAgB,KAAsB;CACnD,IAAI,UAAU,QAAQ,OAAO,UAAU,UAAU,OAAO,KAAA;CACxD,OAAO,QAAQ,IAAI,OAAO,GAAG;AAC/B;AAEA,SAAS,eAAe,OAAgB,KAAqB;CAC3D,MAAM,YAAY,MAAM,OAAO,GAAG;CAClC,IAAI,OAAO,cAAc,YAAY,UAAU,WAAW,GACxD,MAAM,IAAI,qBACR,uCACA,sCAAsC,KACxC;CAEF,OAAO;AACT;AAEA,SAAS,eAAe,OAAgB,KAAiC;CACvE,MAAM,YAAY,MAAM,OAAO,GAAG;CAClC,IAAI,cAAc,KAAA,GAAW,OAAO,KAAA;CACpC,IAAI,OAAO,cAAc,YAAY,UAAU,WAAW,GACxD,MAAM,IAAI,qBACR,uCACA,iBAAiB,IAAI,0CACvB;CAEF,OAAO;AACT;AAEA,SAAS,kBAAkB,OAAe,OAAuB;CAC/D,IAAI,MAAM,WAAW,GACnB,MAAM,IAAI,qBACR,uCACA,GAAG,MAAM,4BACX;CAEF,OAAO;AACT;AAEA,SAAgB,oBACd,WACA,SACA;CACA,MAAM,WAAW,kBAAkB,QAAQ,UAAU,UAAU;CAC/D,MAAM,QAAQ,kBAAkB,QAAQ,OAAO,OAAO;CACtD,MAAM,iBAAiB,QAAQ;CAC/B,MAAM,SAAS,QAAQ;CACvB,MAAM,WAAW,QAAQ;CACzB,IAAI,OAAO,mBAAmB,YAC5B,MAAM,IAAI,qBACR,uCACA,6CACF;CA4IF,OAAO;EAzIM,eAAe;GAC1B,MAAM;GACN,aACE;GACF,aAAa;IACX,MAAM;IACN,YAAY,EACV,OAAO;KACL,MAAM;KACN,aAAa;IACf,EACF;IACA,UAAU,CAAC,OAAO;IAClB,sBAAsB;GACxB;GACA,cAAc;IACZ,MAAM;IACN,YAAY;KACV,cAAc,EAAE,MAAM,SAAS;KAC/B,OAAO,EAAE,MAAM,SAAS;KACxB,UAAU,EAAE,MAAM,SAAS;IAC7B;IACA,UAAU;KAAC;KAAgB;KAAS;IAAU;IAC9C,sBAAsB;GACxB;EACF,CAAC,CAAC,CAAC,OAAO,OAAO,UAAU;GACzB,MAAM,QAAQ,eAAe,OAAO,OAAO;GAC3C,MAAM,aAAa,MAAM,UAAU,KAAK;IACtC;IACA;IACA;IACA,GAAI,WAAW,KAAA,IAAY,CAAC,IAAI,EAAE,OAAO;GAC3C,CAAC;GACD,OAAO;IACL,cAAc,WAAW;IACzB,OAAO,WAAW,SAAS;IAC3B;GACF;EACF,CAmGQ;EAjGK,eAAe;GAC1B,MAAM;GACN,aACE;GACF,aAAa;IACX,MAAM;IACN,YAAY,EACV,cAAc;KACZ,MAAM;KACN,aACE;IACJ,EACF;IACA,sBAAsB;GACxB;GACA,cAAc;IACZ,MAAM;IACN,YAAY;KACV,cAAc,EAAE,MAAM,SAAS;KAC/B,qBAAqB,EAAE,MAAM,SAAS;IACxC;IACA,UAAU,CAAC,gBAAgB,qBAAqB;IAChD,sBAAsB;GACxB;EACF,CAAC,CAAC,CAAC,OAAO,OAAO,UAAU;GAEzB,MAAM,eADuB,eAAe,OAAO,cAEjD,KAAyB,MAAM,UAAU,YAAY,QAAQ,QAAQ;GACvE,IAAI,iBAAiB,MACnB,MAAM,IAAI,qBACR,uCACA,uCACF;GAEF,MAAM,sBAAsB,kBAC1B,eAAe,GACf,qBACF;GACA,MAAM,aAAa,MAAM,UAAU,KAAK;IACtC;IACA;IACA;GACF,CAAC;GACD,IAAI,aAAa,KAAA,GACf,MAAM,SAAS;IACb;IACA,cAAc,WAAW;GAC3B,CAAC;GAEH,OAAO;IACL,cAAc,WAAW;IACzB;GACF;EACF,CA4Cc;EA1CO,eAAe;GAClC,MAAM;GACN,aACE;GACF,aAAa;IACX,MAAM;IACN,YAAY;KACV,cAAc,EAAE,MAAM,SAAS;KAC/B,YAAY,EAAE,MAAM,SAAS;IAC/B;IACA,UAAU,CAAC,gBAAgB,YAAY;IACvC,sBAAsB;GACxB;GACA,cAAc;IACZ,MAAM;IACN,YAAY;KACV,YAAY,EAAE,MAAM,SAAS;KAC7B,MAAM,EAAE,MAAM,SAAS;KACvB,UAAU,EAAE,MAAM,SAAS;KAC3B,MAAM,EAAE,MAAM,SAAS;KACvB,WAAW,EAAE,MAAM,SAAS;IAC9B;IACA,UAAU;KAAC;KAAc;KAAQ;KAAY;KAAQ;IAAW;IAChE,sBAAsB;GACxB;EACF,CAAC,CAAC,CAAC,OAAO,OAAO,UAAU;GACzB,MAAM,eAAe,eAAe,OAAO,cAAc;GACzD,MAAM,aAAa,eAAe,OAAO,YAAY;GACrD,MAAM,WAAW,MAAM,UAAU,aAAa;IAC5C;IACA;IACA;GACF,CAAC;GACD,OAAO;IACL,YAAY,SAAS,SAAS;IAC9B,MAAM,SAAS,SAAS;IACxB,UAAU,SAAS,SAAS;IAC5B,MAAM,SAAS,SAAS;IACxB,WAAW,SAAS,SAAS;GAC/B;EACF,CAEoB;CAAY;AAClC"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { SandboxHandle } from './contracts.js';
|
|
2
|
+
import { SandboxSnapshotArtifact, SandboxSnapshotEntry } from './checkpoint-store.js';
|
|
3
|
+
import { MemoryArtifactRecord as ArtifactRecord } from './memory-snapshot-types.js';
|
|
4
|
+
type SnapshotBlobStore = {
|
|
5
|
+
get: (key: string) => Promise<{
|
|
6
|
+
arrayBuffer: () => Promise<ArrayBuffer>;
|
|
7
|
+
} | null>;
|
|
8
|
+
head: (key: string) => Promise<unknown>;
|
|
9
|
+
put: (key: string, body: Uint8Array) => Promise<unknown>;
|
|
10
|
+
};
|
|
11
|
+
export interface SandboxSnapshotPolicy {
|
|
12
|
+
/** Exact workspace projection hash, when known. */
|
|
13
|
+
workspaceHash?: string;
|
|
14
|
+
include?: (path: string, kind: 'file' | 'dir') => boolean;
|
|
15
|
+
exclude?: (path: string, kind: 'file' | 'dir') => boolean;
|
|
16
|
+
redact?: (input: {
|
|
17
|
+
path: string;
|
|
18
|
+
bytes: Uint8Array;
|
|
19
|
+
resolvedSecrets: Readonly<Record<string, string>>;
|
|
20
|
+
}) => Uint8Array;
|
|
21
|
+
}
|
|
22
|
+
export interface SandboxSnapshotBundle {
|
|
23
|
+
blobs: SnapshotBlobStore;
|
|
24
|
+
/** Internal resolved workspace root. */
|
|
25
|
+
workspaceRoot?: string;
|
|
26
|
+
/** Internal persistence stores used to capture immutable artifact bytes. */
|
|
27
|
+
artifacts?: {
|
|
28
|
+
listForThread: (threadId: string) => Promise<ReadonlyArray<ArtifactRecord>>;
|
|
29
|
+
};
|
|
30
|
+
resolveArtifactBlobKey?: (record: ArtifactRecord) => string;
|
|
31
|
+
}
|
|
32
|
+
export type SandboxSnapshotErrorCode = 'SANDBOX_SNAPSHOT_INVALID_TOOL_INPUT' | 'SANDBOX_SNAPSHOT_MISSING_SANDBOX' | 'SANDBOX_SNAPSHOT_MISSING_INSTANCES' | 'SANDBOX_SNAPSHOT_MISSING_PERSISTENCE_STORES' | 'SANDBOX_SNAPSHOT_MISSING_REUSABLE_SANDBOX' | 'SANDBOX_SNAPSHOT_REUSE_NONE' | 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT' | 'SANDBOX_SNAPSHOT_MISSING_CHECKPOINT_ARTIFACT' | 'SANDBOX_SNAPSHOT_FOREIGN_CHECKPOINT_ARTIFACT' | 'SANDBOX_SNAPSHOT_INVALID_ARTIFACT_BYTES' | 'SANDBOX_SNAPSHOT_FORK_UNAVAILABLE' | 'SANDBOX_SNAPSHOT_INVALID_PATH' | 'SANDBOX_SNAPSHOT_INVALID_WORKSPACE' | 'SANDBOX_SNAPSHOT_LSTAT_REQUIRED' | 'SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY' | 'SANDBOX_SNAPSHOT_MISSING_BLOB' | 'SANDBOX_SNAPSHOT_INVALID_BLOB' | 'SANDBOX_SNAPSHOT_ARTIFACT_SUPPORT_REQUIRED' | 'SANDBOX_SNAPSHOT_MISSING_ARTIFACT_BLOB';
|
|
33
|
+
export declare class SandboxSnapshotError extends Error {
|
|
34
|
+
readonly code: SandboxSnapshotErrorCode;
|
|
35
|
+
constructor(code: SandboxSnapshotErrorCode, message: string);
|
|
36
|
+
}
|
|
37
|
+
export declare function defaultSandboxSnapshotPolicy(workspaceHash?: string): SandboxSnapshotPolicy;
|
|
38
|
+
/**
|
|
39
|
+
* Keep default exclusions unless the caller passed `exclude`.
|
|
40
|
+
* `include` or `redact` alone must not capture `.env`, `.git`, or
|
|
41
|
+
* `node_modules`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveSandboxSnapshotPolicy(supplied: SandboxSnapshotPolicy | undefined, workspaceHash?: string): SandboxSnapshotPolicy;
|
|
44
|
+
export declare function captureSandboxFiles(handle: SandboxHandle, bundle: SandboxSnapshotBundle, suppliedPolicy?: SandboxSnapshotPolicy, resolvedSecrets?: Readonly<Record<string, string>>): Promise<{
|
|
45
|
+
files: Array<SandboxSnapshotEntry>;
|
|
46
|
+
}>;
|
|
47
|
+
export declare function restoreSandboxFiles(handle: SandboxHandle, bundle: SandboxSnapshotBundle, snapshot: {
|
|
48
|
+
files: ReadonlyArray<SandboxSnapshotEntry>;
|
|
49
|
+
}, suppliedPolicy?: SandboxSnapshotPolicy): Promise<void>;
|
|
50
|
+
export declare function captureSandboxArtifacts(bundle: SandboxSnapshotBundle, threadId: string, resolvedSecrets?: Readonly<Record<string, string>>): Promise<ReadonlyArray<SandboxSnapshotArtifact>>;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
//#region src/snapshots.ts
|
|
2
|
+
var SandboxSnapshotError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "SandboxSnapshotError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var DEFAULT_ROOT = "/workspace";
|
|
11
|
+
var PROJECTED_SKILL_ROOTS = /* @__PURE__ */ new Set([
|
|
12
|
+
".claude",
|
|
13
|
+
".codex",
|
|
14
|
+
".grok"
|
|
15
|
+
]);
|
|
16
|
+
function isFrameworkGeneratedSymlinkPath(path) {
|
|
17
|
+
if (path === "CLAUDE.md" || path === "GEMINI.md") return true;
|
|
18
|
+
const segments = path.split("/");
|
|
19
|
+
return segments.length === 3 && PROJECTED_SKILL_ROOTS.has(segments[0] ?? "") && segments[1] === "skills";
|
|
20
|
+
}
|
|
21
|
+
function defaultExcluded(path, workspaceHash) {
|
|
22
|
+
const segments = path.split("/");
|
|
23
|
+
return isFrameworkGeneratedSymlinkPath(path) || segments.some((segment) => segment === ".git" || segment === "node_modules" || segment.startsWith(".env")) || workspaceHash !== void 0 && segments[0] === `.tanstack-projected-${workspaceHash}`;
|
|
24
|
+
}
|
|
25
|
+
function isProtectedPath(path, workspaceHash) {
|
|
26
|
+
return workspaceHash !== void 0 && path.split("/")[0] === `.tanstack-projected-${workspaceHash}`;
|
|
27
|
+
}
|
|
28
|
+
var FILE_BLOB_KEY = /^sandbox-files\/sha256\/[0-9a-f]{64}$/;
|
|
29
|
+
function defaultSandboxSnapshotPolicy(workspaceHash) {
|
|
30
|
+
return {
|
|
31
|
+
workspaceHash,
|
|
32
|
+
exclude: (path) => defaultExcluded(path, workspaceHash)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Keep default exclusions unless the caller passed `exclude`.
|
|
37
|
+
* `include` or `redact` alone must not capture `.env`, `.git`, or
|
|
38
|
+
* `node_modules`.
|
|
39
|
+
*/
|
|
40
|
+
function resolveSandboxSnapshotPolicy(supplied, workspaceHash) {
|
|
41
|
+
const defaults = defaultSandboxSnapshotPolicy(workspaceHash ?? supplied?.workspaceHash);
|
|
42
|
+
if (supplied === void 0) return defaults;
|
|
43
|
+
const include = supplied.include;
|
|
44
|
+
const exclude = supplied.exclude;
|
|
45
|
+
const redact = supplied.redact;
|
|
46
|
+
const suppliedWorkspaceHash = supplied.workspaceHash;
|
|
47
|
+
return {
|
|
48
|
+
...suppliedWorkspaceHash === void 0 ? {} : { workspaceHash: suppliedWorkspaceHash },
|
|
49
|
+
...workspaceHash === void 0 ? {} : { workspaceHash },
|
|
50
|
+
...include === void 0 ? {} : { include },
|
|
51
|
+
exclude: exclude ?? defaults.exclude,
|
|
52
|
+
...redact === void 0 ? {} : { redact }
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function normalize(path) {
|
|
56
|
+
if (path.includes("\\")) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_PATH", `Unsafe snapshot path '${path}'`);
|
|
57
|
+
const value = path;
|
|
58
|
+
if (!value || value.includes("\0") || value.startsWith("/") || /^[A-Za-z]:/.test(value) || value.endsWith("/")) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_PATH", `Unsafe snapshot path '${path}'`);
|
|
59
|
+
const parts = value.split("/");
|
|
60
|
+
if (parts.some((part) => !part || part === "." || part === "..") || parts.join("/") !== value) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_PATH", `Unsafe snapshot path '${path}'`);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
function childPath(parent, child) {
|
|
64
|
+
if (!child.name || child.name.includes("/") || child.name.includes("\\") || child.name.includes("\0") || child.name === "." || child.name === "..") throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_WORKSPACE", `Invalid workspace entry '${child.name}'`);
|
|
65
|
+
const absolute = `${parent}/${child.name}`;
|
|
66
|
+
if (child.path !== absolute) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_WORKSPACE", `Invalid workspace entry path '${child.path}'`);
|
|
67
|
+
return {
|
|
68
|
+
absolute,
|
|
69
|
+
relative: child.name
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function lstat(handle, path) {
|
|
73
|
+
if (!handle.fs.lstat) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_LSTAT_REQUIRED", "Snapshot operations require fs.lstat");
|
|
74
|
+
return handle.fs.lstat(path);
|
|
75
|
+
}
|
|
76
|
+
function assertSupported(stat, path) {
|
|
77
|
+
if (stat.type === "symlink" || stat.type === "other" || stat.type === "file" && (stat.mode & 73) !== 0) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY", `Unsupported entry '${path}'`);
|
|
78
|
+
}
|
|
79
|
+
function included(path, kind, policy) {
|
|
80
|
+
if (policy.exclude?.(path, kind)) return false;
|
|
81
|
+
return kind === "dir" ? true : policy.include?.(path, kind) ?? true;
|
|
82
|
+
}
|
|
83
|
+
async function hash(bytes) {
|
|
84
|
+
const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes));
|
|
85
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
86
|
+
}
|
|
87
|
+
async function putIfAbsent(blobs, bytes, keys) {
|
|
88
|
+
const key = `sandbox-files/sha256/${await hash(bytes)}`;
|
|
89
|
+
if (!keys.has(key)) {
|
|
90
|
+
if (!await blobs.head(key)) await blobs.put(key, bytes);
|
|
91
|
+
keys.set(key, key);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
key,
|
|
95
|
+
size: bytes.byteLength
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function redactBytes(bytes, resolvedSecrets) {
|
|
99
|
+
const output = bytes.slice();
|
|
100
|
+
const redacted = new Uint8Array(bytes.length);
|
|
101
|
+
const secrets = Object.values(resolvedSecrets).filter(Boolean).map((secret) => new TextEncoder().encode(secret)).sort((a, b) => b.length - a.length || compareBytes(a, b));
|
|
102
|
+
for (const needle of secrets) {
|
|
103
|
+
if (!needle.length || needle.length > bytes.length) continue;
|
|
104
|
+
for (let start = 0; start <= bytes.length - needle.length; start++) {
|
|
105
|
+
let match = true;
|
|
106
|
+
for (let index = 0; index < needle.length; index++) if (bytes[start + index] !== needle[index]) {
|
|
107
|
+
match = false;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
if (!match) continue;
|
|
111
|
+
if (match) redacted.fill(1, start, start + needle.length);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (let index = 0; index < output.length; index++) if (redacted[index]) output[index] = 0;
|
|
115
|
+
return output;
|
|
116
|
+
}
|
|
117
|
+
async function captureSandboxFiles(handle, bundle, suppliedPolicy = defaultSandboxSnapshotPolicy(), resolvedSecrets = {}) {
|
|
118
|
+
const policy = resolveSandboxSnapshotPolicy(suppliedPolicy, suppliedPolicy.workspaceHash);
|
|
119
|
+
if (!handle.fs.lstat) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_LSTAT_REQUIRED", "Snapshot capture requires fs.lstat");
|
|
120
|
+
const rootPath = bundle.workspaceRoot ?? DEFAULT_ROOT;
|
|
121
|
+
const root = await lstat(handle, rootPath);
|
|
122
|
+
if (!root || root.type !== "dir") throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_WORKSPACE", "Snapshot workspace is missing or is not a directory");
|
|
123
|
+
assertSupported(root, rootPath);
|
|
124
|
+
const files = [];
|
|
125
|
+
const destinationKeys = /* @__PURE__ */ new Map();
|
|
126
|
+
const walk = async (absolute, relative) => {
|
|
127
|
+
const stat = await lstat(handle, absolute);
|
|
128
|
+
if (!stat) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_WORKSPACE", `Snapshot entry disappeared '${relative}'`);
|
|
129
|
+
assertSupported(stat, relative);
|
|
130
|
+
if (stat.type !== "file" && stat.type !== "dir") throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_UNSUPPORTED_ENTRY", `Unsupported entry '${relative}'`);
|
|
131
|
+
if (relative && (isProtectedPath(relative, policy.workspaceHash) || policy.exclude?.(relative, stat.type))) return false;
|
|
132
|
+
if (stat.type === "file") {
|
|
133
|
+
const path = normalize(relative);
|
|
134
|
+
if (policy.include && !policy.include(path, "file")) return false;
|
|
135
|
+
let bytes = await handle.fs.readBytes(absolute);
|
|
136
|
+
if (policy.redact) bytes = policy.redact({
|
|
137
|
+
path,
|
|
138
|
+
bytes,
|
|
139
|
+
resolvedSecrets
|
|
140
|
+
});
|
|
141
|
+
bytes = await redactBytes(bytes, resolvedSecrets);
|
|
142
|
+
const blob = await putIfAbsent(bundle.blobs, bytes, destinationKeys);
|
|
143
|
+
files.push({
|
|
144
|
+
path,
|
|
145
|
+
kind: "file",
|
|
146
|
+
blobKey: blob.key,
|
|
147
|
+
size: blob.size
|
|
148
|
+
});
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
const children = await handle.fs.list(absolute);
|
|
152
|
+
let hasCapturedChild = false;
|
|
153
|
+
for (const child of children) {
|
|
154
|
+
const childEntry = childPath(absolute, child);
|
|
155
|
+
const childRelative = relative ? `${relative}/${childEntry.relative}` : childEntry.relative;
|
|
156
|
+
if (isProtectedPath(childRelative, policy.workspaceHash) || policy.exclude?.(childRelative, child.type)) continue;
|
|
157
|
+
hasCapturedChild = await walk(childEntry.absolute, childRelative) || hasCapturedChild;
|
|
158
|
+
}
|
|
159
|
+
if (relative && !hasCapturedChild && (!policy.include || policy.include(relative, "dir"))) files.push({
|
|
160
|
+
path: normalize(relative),
|
|
161
|
+
kind: "dir"
|
|
162
|
+
});
|
|
163
|
+
return hasCapturedChild || relative !== "" && (!policy.include || policy.include(relative, "dir"));
|
|
164
|
+
};
|
|
165
|
+
await walk(rootPath, "");
|
|
166
|
+
files.sort((a, b) => comparePath(a.path, b.path));
|
|
167
|
+
return { files };
|
|
168
|
+
}
|
|
169
|
+
function validateManifest(snapshot, policy) {
|
|
170
|
+
const paths = /* @__PURE__ */ new Map();
|
|
171
|
+
for (const entry of snapshot.files) {
|
|
172
|
+
const path = normalize(entry.path);
|
|
173
|
+
for (const ancestor of parents(path)) if (isProtectedPath(ancestor, policy.workspaceHash) || policy.exclude?.(ancestor, "dir")) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_PATH", `Excluded snapshot ancestor '${ancestor}'`);
|
|
174
|
+
if (isProtectedPath(path, policy.workspaceHash)) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_PATH", `Protected snapshot path '${path}'`);
|
|
175
|
+
if (!included(path, entry.kind, policy) || entry.kind === "dir" && policy.include?.(path, "dir") === false) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_PATH", `Excluded snapshot path '${path}'`);
|
|
176
|
+
if (paths.has(path)) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_PATH", `Duplicate path '${path}'`);
|
|
177
|
+
if (entry.kind === "file") {
|
|
178
|
+
const { blobKey, size } = entry;
|
|
179
|
+
if (typeof blobKey !== "string" || !FILE_BLOB_KEY.test(blobKey) || typeof size !== "number" || !Number.isSafeInteger(size) || size < 0) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_PATH", `Invalid file entry '${path}'`);
|
|
180
|
+
paths.set(path, {
|
|
181
|
+
path,
|
|
182
|
+
kind: "file",
|
|
183
|
+
blobKey,
|
|
184
|
+
size
|
|
185
|
+
});
|
|
186
|
+
} else if (entry.kind === "dir") paths.set(path, {
|
|
187
|
+
path,
|
|
188
|
+
kind: "dir"
|
|
189
|
+
});
|
|
190
|
+
else throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_PATH", `Unknown snapshot entry '${path}'`);
|
|
191
|
+
}
|
|
192
|
+
for (const [path] of paths) for (let index = path.indexOf("/"); index !== -1; index = path.indexOf("/", index + 1)) {
|
|
193
|
+
const parent = paths.get(path.slice(0, index));
|
|
194
|
+
if (parent?.kind === "file") throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_PATH", `File ancestor '${parent.path}'`);
|
|
195
|
+
}
|
|
196
|
+
return [...paths.values()];
|
|
197
|
+
}
|
|
198
|
+
async function loadBlobs(entries, bundle) {
|
|
199
|
+
const blobs = /* @__PURE__ */ new Map();
|
|
200
|
+
for (const entry of entries) if (entry.kind === "file" && !blobs.has(entry.blobKey)) {
|
|
201
|
+
const object = await bundle.blobs.get(entry.blobKey);
|
|
202
|
+
if (!object) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_MISSING_BLOB", `Missing snapshot blob '${entry.blobKey}'`);
|
|
203
|
+
const bytes = new Uint8Array(await object.arrayBuffer());
|
|
204
|
+
const expectedKey = `sandbox-files/sha256/${await hash(bytes)}`;
|
|
205
|
+
if (entry.blobKey !== expectedKey) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_BLOB", `Invalid content for snapshot blob '${entry.blobKey}'`);
|
|
206
|
+
blobs.set(entry.blobKey, bytes);
|
|
207
|
+
}
|
|
208
|
+
for (const entry of entries) if (entry.kind === "file") {
|
|
209
|
+
const bytes = blobs.get(entry.blobKey);
|
|
210
|
+
if (!bytes || bytes.byteLength !== entry.size) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_BLOB", `Wrong size for snapshot blob '${entry.blobKey}'`);
|
|
211
|
+
}
|
|
212
|
+
return blobs;
|
|
213
|
+
}
|
|
214
|
+
async function scanCurrent(handle, absolute, relative, policy) {
|
|
215
|
+
const stat = await lstat(handle, absolute);
|
|
216
|
+
if (!stat) return [];
|
|
217
|
+
assertSupported(stat, relative);
|
|
218
|
+
if (stat.type === "file") return [{
|
|
219
|
+
path: relative,
|
|
220
|
+
kind: "file"
|
|
221
|
+
}];
|
|
222
|
+
const paths = [];
|
|
223
|
+
for (const child of await handle.fs.list(absolute)) {
|
|
224
|
+
const childEntry = childPath(absolute, child);
|
|
225
|
+
const childRelative = relative ? `${relative}/${childEntry.relative}` : childEntry.relative;
|
|
226
|
+
if (isProtectedPath(childRelative, policy.workspaceHash)) {
|
|
227
|
+
paths.push({
|
|
228
|
+
path: childRelative,
|
|
229
|
+
kind: "dir",
|
|
230
|
+
protected: true
|
|
231
|
+
});
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (!included(childRelative, child.type, policy)) {
|
|
235
|
+
paths.push({
|
|
236
|
+
path: childRelative,
|
|
237
|
+
kind: child.type,
|
|
238
|
+
protected: true
|
|
239
|
+
});
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
paths.push(...await scanCurrent(handle, childEntry.absolute, childRelative, policy));
|
|
243
|
+
}
|
|
244
|
+
return relative ? [{
|
|
245
|
+
path: relative,
|
|
246
|
+
kind: "dir"
|
|
247
|
+
}, ...paths] : paths;
|
|
248
|
+
}
|
|
249
|
+
async function scanDestination(handle, policy, rootPath) {
|
|
250
|
+
const root = await lstat(handle, rootPath);
|
|
251
|
+
if (!root || root.type !== "dir") throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_WORKSPACE", "Snapshot workspace is missing or is not a directory");
|
|
252
|
+
assertSupported(root, rootPath);
|
|
253
|
+
return scanCurrent(handle, rootPath, "", policy);
|
|
254
|
+
}
|
|
255
|
+
function parents(path) {
|
|
256
|
+
const values = [];
|
|
257
|
+
const parts = path.split("/");
|
|
258
|
+
for (let length = 1; length < parts.length; length++) values.push(parts.slice(0, length).join("/"));
|
|
259
|
+
return values;
|
|
260
|
+
}
|
|
261
|
+
function comparePath(a, b) {
|
|
262
|
+
return compareBytes(new TextEncoder().encode(a), new TextEncoder().encode(b));
|
|
263
|
+
}
|
|
264
|
+
function compareBytes(a, b) {
|
|
265
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
266
|
+
const left = a[i];
|
|
267
|
+
const right = b[i];
|
|
268
|
+
if (left !== right) return (left ?? 0) - (right ?? 0);
|
|
269
|
+
}
|
|
270
|
+
return a.length - b.length;
|
|
271
|
+
}
|
|
272
|
+
function getRequiredBlob(blobs, key) {
|
|
273
|
+
const bytes = blobs.get(key);
|
|
274
|
+
if (!bytes) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_MISSING_BLOB", `Missing snapshot blob '${key}'`);
|
|
275
|
+
return bytes;
|
|
276
|
+
}
|
|
277
|
+
function depth(path) {
|
|
278
|
+
return path.split("/").length;
|
|
279
|
+
}
|
|
280
|
+
function buildRestorePlan(entries, current) {
|
|
281
|
+
const desired = /* @__PURE__ */ new Map();
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
desired.set(entry.path, entry.kind);
|
|
284
|
+
for (const parent of parents(entry.path)) desired.set(parent, "dir");
|
|
285
|
+
}
|
|
286
|
+
const currentKinds = new Map(current.map((entry) => [entry.path, entry.kind]));
|
|
287
|
+
const candidates = current.filter((entry) => desired.get(entry.path) !== entry.kind).map((entry) => entry.path).sort((a, b) => depth(a) - depth(b) || comparePath(a, b));
|
|
288
|
+
const removes = [];
|
|
289
|
+
for (const path of candidates) {
|
|
290
|
+
if (current.some((entry) => entry.protected && (entry.path === path || entry.path.startsWith(`${path}/`)))) continue;
|
|
291
|
+
if (removes.some((ancestor) => path.startsWith(`${ancestor}/`))) continue;
|
|
292
|
+
removes.push(path);
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
removes,
|
|
296
|
+
mkdirs: [...desired].filter(([path, kind]) => kind === "dir" && currentKinds.get(path) !== "dir").map(([path]) => path).sort((a, b) => depth(a) - depth(b) || comparePath(a, b)),
|
|
297
|
+
writes: entries.filter((entry) => entry.kind === "file").sort((a, b) => comparePath(a.path, b.path))
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
async function restoreSandboxFiles(handle, bundle, snapshot, suppliedPolicy = defaultSandboxSnapshotPolicy()) {
|
|
301
|
+
const policy = resolveSandboxSnapshotPolicy(suppliedPolicy, suppliedPolicy.workspaceHash);
|
|
302
|
+
if (!handle.fs.lstat) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_LSTAT_REQUIRED", "Snapshot restore requires fs.lstat");
|
|
303
|
+
const entries = validateManifest(snapshot, policy);
|
|
304
|
+
const rootPath = bundle.workspaceRoot ?? DEFAULT_ROOT;
|
|
305
|
+
const current = await scanDestination(handle, policy, rootPath);
|
|
306
|
+
for (const entry of entries) if (entry.kind === "file" && current.some((currentEntry) => currentEntry.protected && currentEntry.path.startsWith(`${entry.path}/`))) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_INVALID_PATH", `Protected current descendant conflicts with '${entry.path}'`);
|
|
307
|
+
const blobs = await loadBlobs(entries, bundle);
|
|
308
|
+
const plan = buildRestorePlan(entries, current);
|
|
309
|
+
for (const path of plan.removes) await handle.fs.remove(`${rootPath}/${path}`);
|
|
310
|
+
for (const path of plan.mkdirs) await handle.fs.mkdir(`${rootPath}/${path}`);
|
|
311
|
+
for (const entry of plan.writes) await handle.fs.write(`${rootPath}/${entry.path}`, getRequiredBlob(blobs, entry.blobKey));
|
|
312
|
+
}
|
|
313
|
+
async function captureSandboxArtifacts(bundle, threadId, resolvedSecrets = {}) {
|
|
314
|
+
if (!bundle.artifacts) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_ARTIFACT_SUPPORT_REQUIRED", "Snapshot artifact capture requires an artifact store");
|
|
315
|
+
const records = await bundle.artifacts.listForThread(threadId);
|
|
316
|
+
const loaded = /* @__PURE__ */ new Map();
|
|
317
|
+
const destinationKeys = /* @__PURE__ */ new Map();
|
|
318
|
+
const resolveBlobKey = bundle.resolveArtifactBlobKey ?? ((record) => record.blobKey ?? `artifacts/${record.runId}/${record.artifactId}`);
|
|
319
|
+
for (const record of records) {
|
|
320
|
+
const sourceKey = resolveBlobKey(record);
|
|
321
|
+
if (loaded.has(sourceKey)) continue;
|
|
322
|
+
const source = await bundle.blobs.get(sourceKey);
|
|
323
|
+
if (!source) throw new SandboxSnapshotError("SANDBOX_SNAPSHOT_MISSING_ARTIFACT_BLOB", `Missing artifact source blob '${sourceKey}'`);
|
|
324
|
+
loaded.set(sourceKey, new Uint8Array(await source.arrayBuffer()));
|
|
325
|
+
}
|
|
326
|
+
const output = [];
|
|
327
|
+
for (const record of records) {
|
|
328
|
+
let bytes = getRequiredBlob(loaded, resolveBlobKey(record));
|
|
329
|
+
bytes = await redactBytes(bytes, resolvedSecrets);
|
|
330
|
+
const key = `sandbox-artifacts/sha256/${await hash(bytes)}`;
|
|
331
|
+
if (!destinationKeys.has(key)) {
|
|
332
|
+
if (!await bundle.blobs.head(key)) await bundle.blobs.put(key, bytes);
|
|
333
|
+
destinationKeys.set(key, key);
|
|
334
|
+
}
|
|
335
|
+
output.push({
|
|
336
|
+
artifactId: record.artifactId,
|
|
337
|
+
name: record.name,
|
|
338
|
+
mimeType: record.mimeType,
|
|
339
|
+
size: bytes.byteLength,
|
|
340
|
+
blobKey: key,
|
|
341
|
+
createdAt: record.createdAt
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
output.sort((a, b) => a.createdAt - b.createdAt || comparePath(a.artifactId, b.artifactId));
|
|
345
|
+
return Object.freeze(output.map((artifact) => Object.freeze(artifact)));
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
348
|
+
export { SandboxSnapshotError, captureSandboxArtifacts, captureSandboxFiles, defaultSandboxSnapshotPolicy, resolveSandboxSnapshotPolicy, restoreSandboxFiles };
|
|
349
|
+
|
|
350
|
+
//# sourceMappingURL=snapshots.js.map
|