@tanstack/ai-sandbox 0.3.4 → 0.4.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.
Files changed (51) hide show
  1. package/README.md +26 -0
  2. package/dist/esm/checkpoint-store.d.ts +147 -0
  3. package/dist/esm/checkpoint-store.js +267 -0
  4. package/dist/esm/checkpoint-store.js.map +1 -0
  5. package/dist/esm/contracts.d.ts +19 -0
  6. package/dist/esm/index.d.ts +11 -1
  7. package/dist/esm/index.js +12 -7
  8. package/dist/esm/memory-snapshot-types.d.ts +129 -0
  9. package/dist/esm/memory-snapshots.d.ts +6 -0
  10. package/dist/esm/memory-snapshots.js +490 -0
  11. package/dist/esm/memory-snapshots.js.map +1 -0
  12. package/dist/esm/middleware.d.ts +33 -1
  13. package/dist/esm/middleware.js +339 -94
  14. package/dist/esm/middleware.js.map +1 -1
  15. package/dist/esm/ngrok.d.ts +1 -1
  16. package/dist/esm/sandbox.d.ts +16 -0
  17. package/dist/esm/sandbox.js +62 -9
  18. package/dist/esm/sandbox.js.map +1 -1
  19. package/dist/esm/snapshot-operations.d.ts +65 -0
  20. package/dist/esm/snapshot-operations.js +317 -0
  21. package/dist/esm/snapshot-operations.js.map +1 -0
  22. package/dist/esm/snapshot-tools.d.ts +185 -0
  23. package/dist/esm/snapshot-tools.js +160 -0
  24. package/dist/esm/snapshot-tools.js.map +1 -0
  25. package/dist/esm/snapshots.d.ts +51 -0
  26. package/dist/esm/snapshots.js +350 -0
  27. package/dist/esm/snapshots.js.map +1 -0
  28. package/dist/esm/testkit/checkpoint-conformance.d.ts +2 -0
  29. package/dist/esm/testkit/checkpoint-conformance.js +453 -0
  30. package/dist/esm/testkit/checkpoint-conformance.js.map +1 -0
  31. package/dist/esm/testkit/checkpoint-fork-conformance.d.ts +18 -0
  32. package/dist/esm/testkit/checkpoint-fork-conformance.js +191 -0
  33. package/dist/esm/testkit/checkpoint-fork-conformance.js.map +1 -0
  34. package/dist/esm/testkit/conformance.d.ts +4 -0
  35. package/dist/esm/testkit/conformance.js +3 -1
  36. package/dist/esm/testkit/conformance.js.map +1 -1
  37. package/package.json +8 -3
  38. package/skills/ai-sandbox/SKILL.md +96 -8
  39. package/src/checkpoint-store.ts +652 -0
  40. package/src/contracts.ts +12 -0
  41. package/src/index.ts +56 -0
  42. package/src/memory-snapshot-types.ts +167 -0
  43. package/src/memory-snapshots.ts +936 -0
  44. package/src/middleware.ts +610 -160
  45. package/src/sandbox.ts +107 -6
  46. package/src/snapshot-operations.ts +540 -0
  47. package/src/snapshot-tools.ts +208 -0
  48. package/src/snapshots.ts +711 -0
  49. package/src/testkit/checkpoint-conformance.ts +472 -0
  50. package/src/testkit/checkpoint-fork-conformance.ts +299 -0
  51. package/src/testkit/conformance.ts +7 -0
@@ -0,0 +1,191 @@
1
+ import { InMemorySandboxCheckpointStore } from "../checkpoint-store.js";
2
+ import { describe, expect, it } from "vitest";
3
+ //#region src/testkit/checkpoint-fork-conformance.ts
4
+ function sourceCheckpoint() {
5
+ return {
6
+ id: "source-root",
7
+ threadId: "source",
8
+ parentCheckpointId: null,
9
+ createdAt: 10,
10
+ reason: "named",
11
+ label: "source label",
12
+ sourceRunId: "run-1",
13
+ files: [{
14
+ path: "a.txt",
15
+ kind: "file",
16
+ blobKey: `sandbox-files/sha256/${"a".repeat(64)}`,
17
+ size: 1
18
+ }],
19
+ conversation: [{
20
+ role: "user",
21
+ content: "checkpoint conversation"
22
+ }],
23
+ artifacts: []
24
+ };
25
+ }
26
+ async function appendSource(checkpoints) {
27
+ const sourceWriter = await checkpoints.acquireWriter("source");
28
+ const source = sourceCheckpoint();
29
+ await checkpoints.append({
30
+ checkpoint: source,
31
+ expectedHeadId: null,
32
+ writer: sourceWriter
33
+ });
34
+ return {
35
+ source,
36
+ sourceWriter
37
+ };
38
+ }
39
+ function forkInput(writer, overrides = {}) {
40
+ return {
41
+ sourceThreadId: "source",
42
+ sourceCheckpointId: "source-root",
43
+ destinationThreadId: "destination",
44
+ destinationCheckpointId: "fork-root",
45
+ createdAt: 20,
46
+ writer,
47
+ ...overrides
48
+ };
49
+ }
50
+ async function destinationState(persistence, checkpoints) {
51
+ return {
52
+ transcript: await persistence.stores.messages.loadThread("destination"),
53
+ head: await checkpoints.getHead("destination"),
54
+ checkpoints: await checkpoints.list("destination"),
55
+ references: await checkpoints.listBlobReferences()
56
+ };
57
+ }
58
+ async function expectRejectedWithoutDestinationChanges(persistence, checkpoints, operation, code) {
59
+ const before = await destinationState(persistence, checkpoints);
60
+ await expect(operation).rejects.toMatchObject({ code });
61
+ expect(await destinationState(persistence, checkpoints)).toEqual(before);
62
+ }
63
+ function runSandboxCheckpointForkConformance(name, makeSnapshots) {
64
+ describe(`Sandbox checkpoint fork conformance: ${name}`, () => {
65
+ it("copies a selected historical checkpoint and creates an exact fork root", async () => {
66
+ const { persistence, checkpoints } = await makeSnapshots();
67
+ const { source, sourceWriter } = await appendSource(checkpoints);
68
+ await checkpoints.append({
69
+ checkpoint: {
70
+ id: "source-head",
71
+ threadId: "source",
72
+ parentCheckpointId: source.id,
73
+ createdAt: 11,
74
+ reason: "automatic",
75
+ files: [],
76
+ conversation: [{
77
+ role: "user",
78
+ content: "newer conversation"
79
+ }],
80
+ artifacts: []
81
+ },
82
+ expectedHeadId: source.id,
83
+ writer: sourceWriter
84
+ });
85
+ await persistence.stores.messages.saveThread("source", [{
86
+ role: "user",
87
+ content: "current source message"
88
+ }]);
89
+ const destinationWriter = await checkpoints.acquireWriter("destination");
90
+ const result = await checkpoints.forkFromCheckpoint(forkInput(destinationWriter));
91
+ expect(result.checkpoint).toEqual({
92
+ id: "fork-root",
93
+ threadId: "destination",
94
+ parentCheckpointId: null,
95
+ createdAt: 20,
96
+ reason: "fork-root",
97
+ files: source.files,
98
+ conversation: source.conversation,
99
+ artifacts: []
100
+ });
101
+ expect(await persistence.stores.messages.loadThread("destination")).toEqual(source.conversation);
102
+ expect(await checkpoints.getHead("source")).toBe("source-head");
103
+ });
104
+ it("rejects a plain checkpoint store because it has no fork capability", () => {
105
+ const store = new InMemorySandboxCheckpointStore();
106
+ expect("forkFromCheckpoint" in store).toBe(false);
107
+ });
108
+ it("rejects a missing source without changing the destination", async () => {
109
+ const { persistence, checkpoints } = await makeSnapshots();
110
+ const writer = await checkpoints.acquireWriter("destination");
111
+ await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(writer, { sourceCheckpointId: "missing" })), "SANDBOX_SNAPSHOT_FORK_SOURCE_NOT_FOUND");
112
+ });
113
+ it("rejects a source thread mismatch without changing the destination", async () => {
114
+ const { persistence, checkpoints } = await makeSnapshots();
115
+ await appendSource(checkpoints);
116
+ const writer = await checkpoints.acquireWriter("destination");
117
+ await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(writer, { sourceThreadId: "another-source" })), "SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH");
118
+ });
119
+ it("rejects equal source and destination threads without changing the source", async () => {
120
+ const { persistence, checkpoints } = await makeSnapshots();
121
+ const { source, sourceWriter } = await appendSource(checkpoints);
122
+ const before = {
123
+ transcript: await persistence.stores.messages.loadThread("source"),
124
+ head: await checkpoints.getHead("source"),
125
+ checkpoints: await checkpoints.list("source"),
126
+ references: await checkpoints.listBlobReferences()
127
+ };
128
+ await expect(checkpoints.forkFromCheckpoint(forkInput(sourceWriter, { destinationThreadId: source.threadId }))).rejects.toMatchObject({ code: "SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH" });
129
+ expect({
130
+ transcript: await persistence.stores.messages.loadThread("source"),
131
+ head: await checkpoints.getHead("source"),
132
+ checkpoints: await checkpoints.list("source"),
133
+ references: await checkpoints.listBlobReferences()
134
+ }).toEqual(before);
135
+ });
136
+ it("rejects a missing destination writer without changing the destination", async () => {
137
+ const { persistence, checkpoints } = await makeSnapshots();
138
+ await appendSource(checkpoints);
139
+ await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput({
140
+ threadId: "destination",
141
+ ownerToken: "missing-owner",
142
+ fence: 1
143
+ })), "SANDBOX_SNAPSHOT_WRITER_LOST");
144
+ });
145
+ it("rejects a wrong destination writer without changing the destination", async () => {
146
+ const { persistence, checkpoints } = await makeSnapshots();
147
+ await appendSource(checkpoints);
148
+ const wrongWriter = await checkpoints.acquireWriter("another-thread");
149
+ await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(wrongWriter)), "SANDBOX_SNAPSHOT_WRITER_LOST");
150
+ });
151
+ it("rejects a stale destination writer without changing the destination", async () => {
152
+ const { persistence, checkpoints } = await makeSnapshots();
153
+ await appendSource(checkpoints);
154
+ const staleWriter = await checkpoints.acquireWriter("destination");
155
+ await staleWriter.release();
156
+ await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(staleWriter)), "SANDBOX_SNAPSHOT_WRITER_LOST");
157
+ });
158
+ it("rejects a destination transcript without changing its checkpoint state", async () => {
159
+ const { persistence, checkpoints } = await makeSnapshots();
160
+ await appendSource(checkpoints);
161
+ await persistence.stores.messages.saveThread("destination", [{
162
+ role: "user",
163
+ content: "already here"
164
+ }]);
165
+ const writer = await checkpoints.acquireWriter("destination");
166
+ await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(writer)), "SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY");
167
+ });
168
+ it("rejects an orphaned checkpoint id without changing the destination", async () => {
169
+ const { persistence, checkpoints } = await makeSnapshots();
170
+ const { source } = await appendSource(checkpoints);
171
+ const orphanWriter = await checkpoints.acquireWriter("orphan-thread");
172
+ await checkpoints.append({
173
+ checkpoint: {
174
+ ...source,
175
+ id: "orphaned-id",
176
+ threadId: "orphan-thread",
177
+ parentCheckpointId: null,
178
+ createdAt: 11
179
+ },
180
+ expectedHeadId: null,
181
+ writer: orphanWriter
182
+ });
183
+ const destinationWriter = await checkpoints.acquireWriter("destination");
184
+ await expectRejectedWithoutDestinationChanges(persistence, checkpoints, checkpoints.forkFromCheckpoint(forkInput(destinationWriter, { destinationCheckpointId: "orphaned-id" })), "SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY");
185
+ });
186
+ });
187
+ }
188
+ //#endregion
189
+ export { runSandboxCheckpointForkConformance };
190
+
191
+ //# sourceMappingURL=checkpoint-fork-conformance.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkpoint-fork-conformance.js","names":[],"sources":["../../../src/testkit/checkpoint-fork-conformance.ts"],"sourcesContent":["import { describe, expect, it } from 'vitest'\nimport type { ModelMessage } from '@tanstack/ai'\nimport type {\n ForkCapableSandboxCheckpointStore,\n SandboxCheckpoint,\n SandboxCheckpointForkInput,\n SandboxCheckpointWriter,\n SandboxCheckpointWriterLease,\n} from '../checkpoint-store'\nimport { InMemorySandboxCheckpointStore } from '../checkpoint-store'\n\n/** Combined stores required to exercise an atomic checkpoint fork. */\nexport interface SandboxCheckpointForkConformanceInput {\n persistence: {\n stores: {\n messages: {\n loadThread: (threadId: string) => Promise<Array<ModelMessage>>\n saveThread: (\n threadId: string,\n messages: Array<ModelMessage>,\n ) => Promise<void>\n }\n }\n }\n checkpoints: ForkCapableSandboxCheckpointStore\n}\n\nexport interface SandboxCheckpointForkConformanceFactory {\n ():\n | SandboxCheckpointForkConformanceInput\n | Promise<SandboxCheckpointForkConformanceInput>\n}\n\ntype ForkInputWithoutWriter = Omit<SandboxCheckpointForkInput, 'writer'>\n\nfunction sourceCheckpoint(): SandboxCheckpoint {\n return {\n id: 'source-root',\n threadId: 'source',\n parentCheckpointId: null,\n createdAt: 10,\n reason: 'named',\n label: 'source label',\n sourceRunId: 'run-1',\n files: [\n {\n path: 'a.txt',\n kind: 'file',\n blobKey: `sandbox-files/sha256/${'a'.repeat(64)}`,\n size: 1,\n },\n ],\n conversation: [{ role: 'user', content: 'checkpoint conversation' }],\n artifacts: [],\n }\n}\n\nasync function appendSource(\n checkpoints: ForkCapableSandboxCheckpointStore,\n): Promise<{\n source: SandboxCheckpoint\n sourceWriter: SandboxCheckpointWriterLease\n}> {\n const sourceWriter = await checkpoints.acquireWriter('source')\n const source = sourceCheckpoint()\n await checkpoints.append({\n checkpoint: source,\n expectedHeadId: null,\n writer: sourceWriter,\n })\n return { source, sourceWriter }\n}\n\nfunction forkInput(\n writer: SandboxCheckpointWriter,\n overrides: Partial<ForkInputWithoutWriter> = {},\n): SandboxCheckpointForkInput {\n return {\n sourceThreadId: 'source',\n sourceCheckpointId: 'source-root',\n destinationThreadId: 'destination',\n destinationCheckpointId: 'fork-root',\n createdAt: 20,\n writer,\n ...overrides,\n }\n}\n\nasync function destinationState(\n persistence: SandboxCheckpointForkConformanceInput['persistence'],\n checkpoints: ForkCapableSandboxCheckpointStore,\n) {\n return {\n transcript: await persistence.stores.messages.loadThread('destination'),\n head: await checkpoints.getHead('destination'),\n checkpoints: await checkpoints.list('destination'),\n references: await checkpoints.listBlobReferences(),\n }\n}\n\nasync function expectRejectedWithoutDestinationChanges(\n persistence: SandboxCheckpointForkConformanceInput['persistence'],\n checkpoints: ForkCapableSandboxCheckpointStore,\n operation: Promise<unknown>,\n code: string,\n): Promise<void> {\n const before = await destinationState(persistence, checkpoints)\n await expect(operation).rejects.toMatchObject({ code })\n expect(await destinationState(persistence, checkpoints)).toEqual(before)\n}\n\nexport function runSandboxCheckpointForkConformance(\n name: string,\n makeSnapshots: SandboxCheckpointForkConformanceFactory,\n): void {\n describe(`Sandbox checkpoint fork conformance: ${name}`, () => {\n it('copies a selected historical checkpoint and creates an exact fork root', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n const { source, sourceWriter } = await appendSource(checkpoints)\n await checkpoints.append({\n checkpoint: {\n id: 'source-head',\n threadId: 'source',\n parentCheckpointId: source.id,\n createdAt: 11,\n reason: 'automatic',\n files: [],\n conversation: [{ role: 'user', content: 'newer conversation' }],\n artifacts: [],\n },\n expectedHeadId: source.id,\n writer: sourceWriter,\n })\n await persistence.stores.messages.saveThread('source', [\n { role: 'user', content: 'current source message' },\n ])\n const destinationWriter = await checkpoints.acquireWriter('destination')\n const result = await checkpoints.forkFromCheckpoint(\n forkInput(destinationWriter),\n )\n expect(result.checkpoint).toEqual({\n id: 'fork-root',\n threadId: 'destination',\n parentCheckpointId: null,\n createdAt: 20,\n reason: 'fork-root',\n files: source.files,\n conversation: source.conversation,\n artifacts: [],\n })\n expect(\n await persistence.stores.messages.loadThread('destination'),\n ).toEqual(source.conversation)\n expect(await checkpoints.getHead('source')).toBe('source-head')\n })\n\n it('rejects a plain checkpoint store because it has no fork capability', () => {\n const store = new InMemorySandboxCheckpointStore()\n expect('forkFromCheckpoint' in store).toBe(false)\n })\n\n it('rejects a missing source without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n const writer = await checkpoints.acquireWriter('destination')\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(\n forkInput(writer, { sourceCheckpointId: 'missing' }),\n ),\n 'SANDBOX_SNAPSHOT_FORK_SOURCE_NOT_FOUND',\n )\n })\n\n it('rejects a source thread mismatch without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n await appendSource(checkpoints)\n const writer = await checkpoints.acquireWriter('destination')\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(\n forkInput(writer, { sourceThreadId: 'another-source' }),\n ),\n 'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH',\n )\n })\n\n it('rejects equal source and destination threads without changing the source', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n const { source, sourceWriter } = await appendSource(checkpoints)\n const before = {\n transcript: await persistence.stores.messages.loadThread('source'),\n head: await checkpoints.getHead('source'),\n checkpoints: await checkpoints.list('source'),\n references: await checkpoints.listBlobReferences(),\n }\n await expect(\n checkpoints.forkFromCheckpoint(\n forkInput(sourceWriter, {\n destinationThreadId: source.threadId,\n }),\n ),\n ).rejects.toMatchObject({\n code: 'SANDBOX_SNAPSHOT_FORK_SOURCE_THREAD_MISMATCH',\n })\n expect({\n transcript: await persistence.stores.messages.loadThread('source'),\n head: await checkpoints.getHead('source'),\n checkpoints: await checkpoints.list('source'),\n references: await checkpoints.listBlobReferences(),\n }).toEqual(before)\n })\n\n it('rejects a missing destination writer without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n await appendSource(checkpoints)\n const missingWriter: SandboxCheckpointWriter = {\n threadId: 'destination',\n ownerToken: 'missing-owner',\n fence: 1,\n }\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(forkInput(missingWriter)),\n 'SANDBOX_SNAPSHOT_WRITER_LOST',\n )\n })\n\n it('rejects a wrong destination writer without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n await appendSource(checkpoints)\n const wrongWriter = await checkpoints.acquireWriter('another-thread')\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(forkInput(wrongWriter)),\n 'SANDBOX_SNAPSHOT_WRITER_LOST',\n )\n })\n\n it('rejects a stale destination writer without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n await appendSource(checkpoints)\n const staleWriter = await checkpoints.acquireWriter('destination')\n await staleWriter.release()\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(forkInput(staleWriter)),\n 'SANDBOX_SNAPSHOT_WRITER_LOST',\n )\n })\n\n it('rejects a destination transcript without changing its checkpoint state', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n await appendSource(checkpoints)\n await persistence.stores.messages.saveThread('destination', [\n { role: 'user', content: 'already here' },\n ])\n const writer = await checkpoints.acquireWriter('destination')\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(forkInput(writer)),\n 'SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY',\n )\n })\n\n it('rejects an orphaned checkpoint id without changing the destination', async () => {\n const { persistence, checkpoints } = await makeSnapshots()\n const { source } = await appendSource(checkpoints)\n const orphanWriter = await checkpoints.acquireWriter('orphan-thread')\n await checkpoints.append({\n checkpoint: {\n ...source,\n id: 'orphaned-id',\n threadId: 'orphan-thread',\n parentCheckpointId: null,\n createdAt: 11,\n },\n expectedHeadId: null,\n writer: orphanWriter,\n })\n const destinationWriter = await checkpoints.acquireWriter('destination')\n await expectRejectedWithoutDestinationChanges(\n persistence,\n checkpoints,\n checkpoints.forkFromCheckpoint(\n forkInput(destinationWriter, {\n destinationCheckpointId: 'orphaned-id',\n }),\n ),\n 'SANDBOX_SNAPSHOT_FORK_DESTINATION_NOT_EMPTY',\n )\n })\n })\n}\n"],"mappings":"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"}
@@ -8,6 +8,10 @@ export type { ReaperConformanceConfig } from './reaper-conformance.js';
8
8
  export { runDurableRunFieldsConformance } from './durable-run-fields-conformance.js';
9
9
  export type { MakeRunStore } from './durable-run-fields-conformance.js';
10
10
  export { makeFakeShellSpawn } from './shell-spawn.js';
11
+ export { runSandboxCheckpointStoreConformance } from './checkpoint-conformance.js';
12
+ export type { SandboxCheckpointStoreOptions } from '../checkpoint-store.js';
13
+ export { runSandboxCheckpointForkConformance } from './checkpoint-fork-conformance.js';
14
+ export type { SandboxCheckpointForkConformanceInput, SandboxCheckpointForkConformanceFactory, } from './checkpoint-fork-conformance.js';
11
15
  /**
12
16
  * Assert `makeStore()` produces a spec-compliant {@link SandboxInstanceStore}. Each
13
17
  * `it` gets a fresh store, so implementations may share process state across
@@ -3,6 +3,8 @@ import { runTakeoverConformance } from "./takeover-conformance.js";
3
3
  import { runReaperConformance } from "./reaper-conformance.js";
4
4
  import { runDurableRunFieldsConformance } from "./durable-run-fields-conformance.js";
5
5
  import { makeFakeShellSpawn } from "./shell-spawn.js";
6
+ import { runSandboxCheckpointStoreConformance } from "./checkpoint-conformance.js";
7
+ import { runSandboxCheckpointForkConformance } from "./checkpoint-fork-conformance.js";
6
8
  import { describe, expect, it } from "vitest";
7
9
  //#region src/testkit/conformance.ts
8
10
  /**
@@ -94,6 +96,6 @@ function runSandboxInstanceStoreConformance(name, makeStore) {
94
96
  });
95
97
  }
96
98
  //#endregion
97
- export { makeFakeShellSpawn, runDurableRunFieldsConformance, runJournalConformance, runReaperConformance, runSandboxInstanceStoreConformance, runTakeoverConformance };
99
+ export { makeFakeShellSpawn, runDurableRunFieldsConformance, runJournalConformance, runReaperConformance, runSandboxCheckpointForkConformance, runSandboxCheckpointStoreConformance, runSandboxInstanceStoreConformance, runTakeoverConformance };
98
100
 
99
101
  //# sourceMappingURL=conformance.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"conformance.js","names":[],"sources":["../../../src/testkit/conformance.ts"],"sourcesContent":["/**\n * Conformance suite for a {@link SandboxInstanceStore} implementation.\n *\n * Run this against a fresh BYO store (or the in-memory reference) to prove it\n * satisfies the get / upsert / delete contract `@tanstack/ai-sandbox`'s ensure\n * algorithm relies on — including insert-vs-overwrite and optional-field\n * handling (full replace must clear omitted optionals).\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { describe, expect, it } from 'vitest'\nimport type {\n SandboxInstanceRecord,\n SandboxInstanceStore,\n} from '../instance-store'\n\n// The `./testkit` package.json subpath resolves to THIS module's build output\n// only (`dist/esm/testkit/conformance.js`), not a barrel over `src/testkit/`.\n// Re-export the journal, takeover, and reaper conformance suites here so they\n// are reachable from `@tanstack/ai-sandbox/testkit` without changing that\n// mapping.\nexport { runJournalConformance } from './journal-conformance'\nexport type { JournalConformanceConfig } from './journal-conformance'\nexport { runTakeoverConformance } from './takeover-conformance'\nexport type { TakeoverConformanceConfig } from './takeover-conformance'\nexport { runReaperConformance } from './reaper-conformance'\nexport type { ReaperConformanceConfig } from './reaper-conformance'\nexport { runDurableRunFieldsConformance } from './durable-run-fields-conformance'\nexport type { MakeRunStore } from './durable-run-fields-conformance'\nexport { makeFakeShellSpawn } from './shell-spawn'\n\nfunction makeRecord(\n overrides?: Partial<SandboxInstanceRecord>,\n): SandboxInstanceRecord {\n return {\n key: 'thread-1',\n provider: 'fake',\n providerSandboxId: 'sb-1',\n threadId: 'thread-1',\n updatedAt: 1,\n ...overrides,\n }\n}\n\n/**\n * Assert `makeStore()` produces a spec-compliant {@link SandboxInstanceStore}. Each\n * `it` gets a fresh store, so implementations may share process state across\n * calls without cross-test bleed only if `makeStore` returns an isolated store.\n */\nexport function runSandboxInstanceStoreConformance(\n name: string,\n makeStore: () => SandboxInstanceStore | Promise<SandboxInstanceStore>,\n): void {\n describe(`SandboxInstanceStore conformance: ${name}`, () => {\n it('returns null for a missing key', async () => {\n const store = await makeStore()\n expect(await store.get('absent')).toBeNull()\n })\n\n it('round-trips an upserted record with all fields', async () => {\n const store = await makeStore()\n const record = makeRecord({\n latestSnapshotId: 'snap-1',\n latestRunId: 'run-1',\n })\n await store.upsert(record)\n expect(await store.get(record.key)).toEqual(record)\n })\n\n it('omits absent optional fields on read', async () => {\n const store = await makeStore()\n const record = makeRecord()\n await store.upsert(record)\n const loaded = await store.get(record.key)\n expect(loaded).toEqual(record)\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n expect(loaded && 'latestRunId' in loaded).toBe(false)\n })\n\n it('overwrites an existing record on re-upsert', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ latestSnapshotId: 'snap-1' }))\n await store.upsert(\n makeRecord({ providerSandboxId: 'sb-2', updatedAt: 2 }),\n )\n const loaded = await store.get('thread-1')\n expect(loaded?.providerSandboxId).toBe('sb-2')\n expect(loaded?.updatedAt).toBe(2)\n // The overwrite dropped latestSnapshotId — a durable store must clear it,\n // not retain the prior value.\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n })\n\n it('isolates records by key', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ key: 'a', threadId: 'a' }))\n await store.upsert(\n makeRecord({ key: 'b', threadId: 'b', providerSandboxId: 'sb-b' }),\n )\n expect((await store.get('a'))?.providerSandboxId).toBe('sb-1')\n expect((await store.get('b'))?.providerSandboxId).toBe('sb-b')\n })\n\n it('deletes a record', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord())\n await store.delete('thread-1')\n expect(await store.get('thread-1')).toBeNull()\n })\n\n it('delete of a missing key is a no-op', async () => {\n const store = await makeStore()\n await expect(store.delete('absent')).resolves.toBeUndefined()\n })\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAgCA,SAAS,WACP,WACuB;CACvB,OAAO;EACL,KAAK;EACL,UAAU;EACV,mBAAmB;EACnB,UAAU;EACV,WAAW;EACX,GAAG;CACL;AACF;;;;;;AAOA,SAAgB,mCACd,MACA,WACM;CACN,SAAS,qCAAqC,cAAc;EAC1D,GAAG,kCAAkC,YAAY;GAC/C,MAAM,QAAQ,MAAM,UAAU;GAC9B,OAAO,MAAM,MAAM,IAAI,QAAQ,CAAC,CAAC,CAAC,SAAS;EAC7C,CAAC;EAED,GAAG,kDAAkD,YAAY;GAC/D,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,SAAS,WAAW;IACxB,kBAAkB;IAClB,aAAa;GACf,CAAC;GACD,MAAM,MAAM,OAAO,MAAM;GACzB,OAAO,MAAM,MAAM,IAAI,OAAO,GAAG,CAAC,CAAC,CAAC,QAAQ,MAAM;EACpD,CAAC;EAED,GAAG,wCAAwC,YAAY;GACrD,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,SAAS,WAAW;GAC1B,MAAM,MAAM,OAAO,MAAM;GACzB,MAAM,SAAS,MAAM,MAAM,IAAI,OAAO,GAAG;GACzC,OAAO,MAAM,CAAC,CAAC,QAAQ,MAAM;GAC7B,OAAO,UAAU,sBAAsB,MAAM,CAAC,CAAC,KAAK,KAAK;GACzD,OAAO,UAAU,iBAAiB,MAAM,CAAC,CAAC,KAAK,KAAK;EACtD,CAAC;EAED,GAAG,8CAA8C,YAAY;GAC3D,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW,EAAE,kBAAkB,SAAS,CAAC,CAAC;GAC7D,MAAM,MAAM,OACV,WAAW;IAAE,mBAAmB;IAAQ,WAAW;GAAE,CAAC,CACxD;GACA,MAAM,SAAS,MAAM,MAAM,IAAI,UAAU;GACzC,OAAO,QAAQ,iBAAiB,CAAC,CAAC,KAAK,MAAM;GAC7C,OAAO,QAAQ,SAAS,CAAC,CAAC,KAAK,CAAC;GAGhC,OAAO,UAAU,sBAAsB,MAAM,CAAC,CAAC,KAAK,KAAK;EAC3D,CAAC;EAED,GAAG,2BAA2B,YAAY;GACxC,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW;IAAE,KAAK;IAAK,UAAU;GAAI,CAAC,CAAC;GAC1D,MAAM,MAAM,OACV,WAAW;IAAE,KAAK;IAAK,UAAU;IAAK,mBAAmB;GAAO,CAAC,CACnE;GACA,QAAQ,MAAM,MAAM,IAAI,GAAG,EAAA,EAAI,iBAAiB,CAAC,CAAC,KAAK,MAAM;GAC7D,QAAQ,MAAM,MAAM,IAAI,GAAG,EAAA,EAAI,iBAAiB,CAAC,CAAC,KAAK,MAAM;EAC/D,CAAC;EAED,GAAG,oBAAoB,YAAY;GACjC,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW,CAAC;GAC/B,MAAM,MAAM,OAAO,UAAU;GAC7B,OAAO,MAAM,MAAM,IAAI,UAAU,CAAC,CAAC,CAAC,SAAS;EAC/C,CAAC;EAED,GAAG,sCAAsC,YAAY;GACnD,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,OAAO,MAAM,OAAO,QAAQ,CAAC,CAAC,CAAC,SAAS,cAAc;EAC9D,CAAC;CACH,CAAC;AACH"}
1
+ {"version":3,"file":"conformance.js","names":[],"sources":["../../../src/testkit/conformance.ts"],"sourcesContent":["/**\n * Conformance suite for a {@link SandboxInstanceStore} implementation.\n *\n * Run this against a fresh BYO store (or the in-memory reference) to prove it\n * satisfies the get / upsert / delete contract `@tanstack/ai-sandbox`'s ensure\n * algorithm relies on — including insert-vs-overwrite and optional-field\n * handling (full replace must clear omitted optionals).\n *\n * Vitest is an OPTIONAL peer dependency: this module is imported only from test\n * files, which already run under Vitest.\n */\nimport { describe, expect, it } from 'vitest'\nimport type {\n SandboxInstanceRecord,\n SandboxInstanceStore,\n} from '../instance-store'\n\n// The `./testkit` package.json subpath resolves to THIS module's build output\n// only (`dist/esm/testkit/conformance.js`), not a barrel over `src/testkit/`.\n// Re-export the journal, takeover, and reaper conformance suites here so they\n// are reachable from `@tanstack/ai-sandbox/testkit` without changing that\n// mapping.\nexport { runJournalConformance } from './journal-conformance'\nexport type { JournalConformanceConfig } from './journal-conformance'\nexport { runTakeoverConformance } from './takeover-conformance'\nexport type { TakeoverConformanceConfig } from './takeover-conformance'\nexport { runReaperConformance } from './reaper-conformance'\nexport type { ReaperConformanceConfig } from './reaper-conformance'\nexport { runDurableRunFieldsConformance } from './durable-run-fields-conformance'\nexport type { MakeRunStore } from './durable-run-fields-conformance'\nexport { makeFakeShellSpawn } from './shell-spawn'\nexport { runSandboxCheckpointStoreConformance } from './checkpoint-conformance'\nexport type { SandboxCheckpointStoreOptions } from '../checkpoint-store'\nexport { runSandboxCheckpointForkConformance } from './checkpoint-fork-conformance'\nexport type {\n SandboxCheckpointForkConformanceInput,\n SandboxCheckpointForkConformanceFactory,\n} from './checkpoint-fork-conformance'\n\nfunction makeRecord(\n overrides?: Partial<SandboxInstanceRecord>,\n): SandboxInstanceRecord {\n return {\n key: 'thread-1',\n provider: 'fake',\n providerSandboxId: 'sb-1',\n threadId: 'thread-1',\n updatedAt: 1,\n ...overrides,\n }\n}\n\n/**\n * Assert `makeStore()` produces a spec-compliant {@link SandboxInstanceStore}. Each\n * `it` gets a fresh store, so implementations may share process state across\n * calls without cross-test bleed only if `makeStore` returns an isolated store.\n */\nexport function runSandboxInstanceStoreConformance(\n name: string,\n makeStore: () => SandboxInstanceStore | Promise<SandboxInstanceStore>,\n): void {\n describe(`SandboxInstanceStore conformance: ${name}`, () => {\n it('returns null for a missing key', async () => {\n const store = await makeStore()\n expect(await store.get('absent')).toBeNull()\n })\n\n it('round-trips an upserted record with all fields', async () => {\n const store = await makeStore()\n const record = makeRecord({\n latestSnapshotId: 'snap-1',\n latestRunId: 'run-1',\n })\n await store.upsert(record)\n expect(await store.get(record.key)).toEqual(record)\n })\n\n it('omits absent optional fields on read', async () => {\n const store = await makeStore()\n const record = makeRecord()\n await store.upsert(record)\n const loaded = await store.get(record.key)\n expect(loaded).toEqual(record)\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n expect(loaded && 'latestRunId' in loaded).toBe(false)\n })\n\n it('overwrites an existing record on re-upsert', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ latestSnapshotId: 'snap-1' }))\n await store.upsert(\n makeRecord({ providerSandboxId: 'sb-2', updatedAt: 2 }),\n )\n const loaded = await store.get('thread-1')\n expect(loaded?.providerSandboxId).toBe('sb-2')\n expect(loaded?.updatedAt).toBe(2)\n // The overwrite dropped latestSnapshotId — a durable store must clear it,\n // not retain the prior value.\n expect(loaded && 'latestSnapshotId' in loaded).toBe(false)\n })\n\n it('isolates records by key', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord({ key: 'a', threadId: 'a' }))\n await store.upsert(\n makeRecord({ key: 'b', threadId: 'b', providerSandboxId: 'sb-b' }),\n )\n expect((await store.get('a'))?.providerSandboxId).toBe('sb-1')\n expect((await store.get('b'))?.providerSandboxId).toBe('sb-b')\n })\n\n it('deletes a record', async () => {\n const store = await makeStore()\n await store.upsert(makeRecord())\n await store.delete('thread-1')\n expect(await store.get('thread-1')).toBeNull()\n })\n\n it('delete of a missing key is a no-op', async () => {\n const store = await makeStore()\n await expect(store.delete('absent')).resolves.toBeUndefined()\n })\n })\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAuCA,SAAS,WACP,WACuB;CACvB,OAAO;EACL,KAAK;EACL,UAAU;EACV,mBAAmB;EACnB,UAAU;EACV,WAAW;EACX,GAAG;CACL;AACF;;;;;;AAOA,SAAgB,mCACd,MACA,WACM;CACN,SAAS,qCAAqC,cAAc;EAC1D,GAAG,kCAAkC,YAAY;GAC/C,MAAM,QAAQ,MAAM,UAAU;GAC9B,OAAO,MAAM,MAAM,IAAI,QAAQ,CAAC,CAAC,CAAC,SAAS;EAC7C,CAAC;EAED,GAAG,kDAAkD,YAAY;GAC/D,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,SAAS,WAAW;IACxB,kBAAkB;IAClB,aAAa;GACf,CAAC;GACD,MAAM,MAAM,OAAO,MAAM;GACzB,OAAO,MAAM,MAAM,IAAI,OAAO,GAAG,CAAC,CAAC,CAAC,QAAQ,MAAM;EACpD,CAAC;EAED,GAAG,wCAAwC,YAAY;GACrD,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,SAAS,WAAW;GAC1B,MAAM,MAAM,OAAO,MAAM;GACzB,MAAM,SAAS,MAAM,MAAM,IAAI,OAAO,GAAG;GACzC,OAAO,MAAM,CAAC,CAAC,QAAQ,MAAM;GAC7B,OAAO,UAAU,sBAAsB,MAAM,CAAC,CAAC,KAAK,KAAK;GACzD,OAAO,UAAU,iBAAiB,MAAM,CAAC,CAAC,KAAK,KAAK;EACtD,CAAC;EAED,GAAG,8CAA8C,YAAY;GAC3D,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW,EAAE,kBAAkB,SAAS,CAAC,CAAC;GAC7D,MAAM,MAAM,OACV,WAAW;IAAE,mBAAmB;IAAQ,WAAW;GAAE,CAAC,CACxD;GACA,MAAM,SAAS,MAAM,MAAM,IAAI,UAAU;GACzC,OAAO,QAAQ,iBAAiB,CAAC,CAAC,KAAK,MAAM;GAC7C,OAAO,QAAQ,SAAS,CAAC,CAAC,KAAK,CAAC;GAGhC,OAAO,UAAU,sBAAsB,MAAM,CAAC,CAAC,KAAK,KAAK;EAC3D,CAAC;EAED,GAAG,2BAA2B,YAAY;GACxC,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW;IAAE,KAAK;IAAK,UAAU;GAAI,CAAC,CAAC;GAC1D,MAAM,MAAM,OACV,WAAW;IAAE,KAAK;IAAK,UAAU;IAAK,mBAAmB;GAAO,CAAC,CACnE;GACA,QAAQ,MAAM,MAAM,IAAI,GAAG,EAAA,EAAI,iBAAiB,CAAC,CAAC,KAAK,MAAM;GAC7D,QAAQ,MAAM,MAAM,IAAI,GAAG,EAAA,EAAI,iBAAiB,CAAC,CAAC,KAAK,MAAM;EAC/D,CAAC;EAED,GAAG,oBAAoB,YAAY;GACjC,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,MAAM,OAAO,WAAW,CAAC;GAC/B,MAAM,MAAM,OAAO,UAAU;GAC7B,OAAO,MAAM,MAAM,IAAI,UAAU,CAAC,CAAC,CAAC,SAAS;EAC/C,CAAC;EAED,GAAG,sCAAsC,YAAY;GACnD,MAAM,QAAQ,MAAM,UAAU;GAC9B,MAAM,OAAO,MAAM,OAAO,QAAQ,CAAC,CAAC,CAAC,SAAS,cAAc;EAC9D,CAAC;CACH,CAAC;AACH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-sandbox",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Provider-agnostic sandbox layer for TanStack AI — run harness adapters inside isolated sandboxes (defineSandbox, defineWorkspace, withSandbox) with a uniform SandboxHandle, workspace bootstrap, policy, and resumable lifecycle.",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -60,12 +60,16 @@
60
60
  "peerDependencies": {
61
61
  "@ngrok/ngrok": "^1.0.0",
62
62
  "vitest": "^4.1.10",
63
- "@tanstack/ai": "^0.46.0"
63
+ "@tanstack/ai": "^0.47.1",
64
+ "@tanstack/ai-persistence": "^0.4.0"
64
65
  },
65
66
  "peerDependenciesMeta": {
66
67
  "@ngrok/ngrok": {
67
68
  "optional": true
68
69
  },
70
+ "@tanstack/ai-persistence": {
71
+ "optional": true
72
+ },
69
73
  "vitest": {
70
74
  "optional": true
71
75
  }
@@ -74,7 +78,8 @@
74
78
  "@ngrok/ngrok": "^1.7.0",
75
79
  "@vitest/coverage-v8": "4.1.10",
76
80
  "vitest": "^4.1.10",
77
- "@tanstack/ai": "0.46.0"
81
+ "@tanstack/ai": "0.47.1",
82
+ "@tanstack/ai-persistence": "0.4.0"
78
83
  },
79
84
  "scripts": {
80
85
  "build": "vite build",
@@ -8,7 +8,13 @@ description: >
8
8
  fileSkill), plugins, instructions → canonical AGENTS.md + symlinks projected
9
9
  per harness; shallow-clone default with depth opt-out; serial/parallel setup
10
10
  callback over a persistent shell; snapshot-after-setup default with
11
- snapshotMaxAge TTL; defineWorkspace (git/setup/scripts/skills/secrets/
11
+ snapshotMaxAge TTL. It also covers portable snapshots after a successful
12
+ terminal run with withPersistence before withSandbox and
13
+ memorySandboxSnapshots for local examples. It covers named saves with
14
+ snapshots.save, selected-checkpoint forks with snapshots.fork, and
15
+ authorized artifact reads with snapshots.readArtifact. See
16
+ docs/sandbox/portable-snapshots.md. It covers defineWorkspace
17
+ (git/setup/scripts/skills/secrets/
12
18
  instructions/plugins), defineSandboxPolicy (allow/ask/deny), lifecycle/resume,
13
19
  the SandboxHandle (fs/git/process/ports), capability tokens, defineSandbox
14
20
  hooks (onFile/onFileCreate/onFileChange/onFileDelete/onReady/onError/
@@ -185,6 +191,87 @@ lifecycle: {
185
191
 
186
192
  Providers without snapshot support skip the step silently.
187
193
 
194
+ ### Portable sandbox snapshots
195
+
196
+ Portable snapshots keep completed workspace files in application persistence.
197
+ They are separate from provider-native bootstrap snapshots. Configure the
198
+ middleware in this order, with the same persistence value in both places:
199
+
200
+ ```typescript
201
+ import { withPersistence } from '@tanstack/ai-persistence'
202
+ import { memorySandboxSnapshots, withSandbox } from '@tanstack/ai-sandbox'
203
+
204
+ const snapshots = await memorySandboxSnapshots({ sandbox, instances })
205
+
206
+ const middleware = [
207
+ withPersistence(snapshots.persistence),
208
+ withSandbox(sandbox, { instances, snapshots }),
209
+ ]
210
+ ```
211
+
212
+ Each successful terminal run saves regular files, empty directories, durable
213
+ conversation data, and persisted thread artifacts. A later run restores the
214
+ latest checkpoint only into a new private sandbox. A live resumed sandbox is
215
+ never overwritten. The default policy excludes `.git`, `node_modules`, and
216
+ `.env*` path segments at every depth. It excludes the exact projection marker
217
+ only at the workspace root. It also excludes root `CLAUDE.md` and `GEMINI.md`,
218
+ plus direct `.claude/skills/<name>`, `.codex/skills/<name>`, and
219
+ `.grok/skills/<name>` paths. These exclusions use paths even for regular files
220
+ or copies. If you pass only `include` or only `redact`, the default
221
+ exclusions stay in place. If you pass `exclude`, that function replaces
222
+ the default exclusions, except for exact projection-marker protection.
223
+ Copy `defaultSandboxSnapshotPolicy()` first when you write `exclude`.
224
+ Pass `include` and `exclude` functions on `policy` to store only some files,
225
+ including one file. There is no `save({ files })` list. See
226
+ `docs/sandbox/portable-snapshots-files.md`. Resolved secrets are redacted before the data is stored. Symlinks,
227
+ executables, and special filesystem entries fail the capture or restore. Each
228
+ thread has one writer lease. Pause and detach release the lease without a
229
+ partial checkpoint. Blob retention is manual because there is no automatic
230
+ garbage collection yet.
231
+
232
+ Read these pages for the server-only setup:
233
+
234
+ - `docs/sandbox/portable-snapshots.md`
235
+ - `docs/sandbox/portable-snapshots-configure.md`
236
+ - `docs/sandbox/portable-snapshots-save.md`
237
+ - `docs/sandbox/portable-snapshots-fork.md`
238
+ - `docs/sandbox/portable-snapshots-artifacts.md`
239
+ - `docs/sandbox/portable-snapshots-tools.md`
240
+ - `docs/sandbox/portable-snapshots-files.md`
241
+ - `docs/sandbox/portable-snapshots-safety.md`
242
+
243
+ For a user-marked workspace state, call `snapshots.save` on the server. Bind
244
+ `sandbox` and `instances` at create time, or pass them on `save`. The call
245
+ needs `threadId`, `runId`, and a label. It requires a live reusable sandbox.
246
+ `reuse: 'none'` cannot save a named checkpoint.
247
+
248
+ To branch from a selected checkpoint, call `snapshots.fork` with the thread id,
249
+ checkpoint id, and destination thread id. The store must implement atomic
250
+ `forkFromCheckpoint`. The destination thread must be empty. A fork copies the
251
+ selected snapshot, not the latest snapshot.
252
+
253
+ To send a checkpoint artifact, call `snapshots.readArtifact` on the server.
254
+ First authorize the caller for the supplied thread. The method makes sure that
255
+ the checkpoint belongs to that thread, then returns its metadata and bytes. It
256
+ does not authorize a caller or create an HTTP response.
257
+
258
+ For a SQLite checkpoint store, use one transaction for a checkpoint write, its
259
+ head update, and every blob reference update. Use one transaction for a fork,
260
+ including its copied conversation. A partial transaction breaks snapshot
261
+ consistency.
262
+
263
+ Snapshot capture supports regular files and empty directories only. It excludes
264
+ `.git`, `node_modules`, and `.env*` path segments at every depth. It excludes
265
+ the exact projection marker only at the workspace root. It also excludes root
266
+ `CLAUDE.md` and `GEMINI.md`, plus direct `.claude/skills/<name>`,
267
+ `.codex/skills/<name>`, and `.grok/skills/<name>` paths. These exclusions use
268
+ paths even for regular files or copies. If you pass only `include` or only
269
+ `redact`, the default exclusions stay in place. If you pass `exclude`, that
270
+ function replaces the default exclusions, except for exact projection-marker
271
+ protection. It rejects symlinks, executable files, and special filesystem
272
+ entries. Restore verifies the manifest and blobs before
273
+ it changes a new private sandbox. It never writes into a live resumed sandbox.
274
+
188
275
  ## Providers
189
276
 
190
277
  - `localProcessSandbox()` — runs on the host (no isolation; dev loop only).
@@ -983,14 +1070,15 @@ including the client `joinRun` side, is in `docs/sandbox/takeover.md`.
983
1070
 
984
1071
  - **Harness adapters require a sandbox.** Always include `withSandbox(...)` in
985
1072
  `middleware` — without it `chat()` throws a missing-capability error.
986
- - **Secrets** (`workspace.secrets`) are injected into the sandbox env and never
987
- persisted (no snapshots, no sandbox store, no event log). Always create them
988
- with `createSecrets(...)` so the values stay hidden behind `SecretRef` tokens.
989
- The agent binary (`claude`) must exist in the sandbox image (install it in
990
- `setup` or bake it into the image).
1073
+ - **Secrets** (`workspace.secrets`) are injected into the sandbox env. Their
1074
+ raw values are never persisted in snapshots, the sandbox store, or the event
1075
+ log. Always create them with `createSecrets(...)` so the values stay hidden
1076
+ behind `SecretRef` tokens. The agent binary (`claude`) must exist in the
1077
+ sandbox image (install it in `setup` or bake it into the image).
991
1078
  - **Secret-bearing projected files** (e.g. MCP config with resolved header
992
- values) are re-written on every projection call so rotated secrets re-apply;
993
- they are never included in a snapshot.
1079
+ values) can be included by default capture. Capture replaces resolved secret
1080
+ bytes with zero bytes before it hashes or writes snapshot blobs. Restore runs
1081
+ before projection, so projection writes current secret values after restore.
994
1082
  - **chat()-provided `tools` are bridged** into the in-sandbox agent over a
995
1083
  host-side MCP tool-proxy: the agent calls them as `mcp__tanstack__<tool>` and
996
1084
  each call is proxied back to the host where the tool's `execute()` runs (with