@tanstack/ai-sandbox 0.3.3 → 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
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.js","names":[],"sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSandbox(definition, options?)` — the middleware that PROVIDES the\n * {@link SandboxCapability} a harness adapter requires.\n *\n * - `setup`: resume-or-create the sandbox (via the definition's ensure\n * algorithm), provide the handle, using the durability seams from\n * {@link SandboxMiddlewareOptions} (or, failing that, a bus-provided\n * SandboxInstanceStoreCapability / LocksCapability, then an in-memory\n * fallback). If `fileEvents` is not false, starts a\n * watcher that dispatches to sandbox-scoped hooks and forwards to the runtime\n * sink.\n * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`)\n * and/or destroy per lifecycle.\n *\n * NOTE: streamed sandbox lifecycle events (sandbox.created, workspace.setup.*)\n * are emitted by the harness adapter's chatStream (which can yield CUSTOM\n * chunks), not from here — middleware setup runs before streaming begins.\n */\nimport {\n defineChatMiddleware,\n provideDetachableRun,\n provideRunDetached,\n wasCancelRequested,\n} from '@tanstack/ai'\nimport { InMemoryLockStore, LocksCapability } from '@tanstack/ai/locks'\nimport {\n getPendingTurn,\n getRunDisconnect,\n getSandboxRuntime,\n} from '@tanstack/ai/adapter-internals'\nimport {\n SandboxCapability,\n provideSandbox,\n provideSandboxPolicy,\n} from './capabilities'\nimport {\n provideSandboxDurability,\n resolveSandboxDurability,\n} from './durability'\nimport { SandboxInstanceStoreCapability } from './instance-store'\nimport { computeWorkspaceHash } from './key'\nimport { buildFileHookEvent, resolveFileEvents } from './file-diff'\nimport { ProjectionCapability, provideWorkspaceProjection } from './projection'\nimport { resolveSecret } from './secrets'\nimport {\n createToolHistoryRecorder,\n stripObservedToolCalls,\n} from './tool-history'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport { resolveHarnessCwd } from './harness-cwd'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\nimport type { LockStore } from '@tanstack/ai/locks'\nimport type {\n AbortInfo,\n ChatMiddlewareContext,\n DefinedChatMiddleware,\n RunStore,\n SandboxFileEvent,\n SandboxFileHookEvent,\n} from '@tanstack/ai'\nimport type {\n SandboxDurabilityOptions,\n SandboxRunDurability,\n} from './durability'\nimport type { SandboxInstanceStore } from './instance-store'\nimport type { ToolHistoryRecorder } from './tool-history'\nimport type { SandboxHandle } from './contracts'\nimport type {\n SandboxDefinition,\n SandboxEnsureContext,\n SandboxHooks,\n} from './sandbox'\nimport type { SandboxWatchHandle } from './watch'\n\n/** Per-request state we need to carry from `setup` to the terminal hooks. */\ninterface SandboxRunState {\n /**\n * OPTIONAL because the state is registered BEFORE `definition.ensure()` is\n * awaited, and `ensure` is the slowest thing in the whole run — cloning a repo\n * into a fresh sandbox is minutes wide. That window is where the most common\n * disconnect of all lands (a user starts a run and switches away while the UI\n * still says \"starting the sandbox\"), so it is the one window the teardown and\n * disconnect hooks most need to be able to act in. Registering only after the\n * handle exists left exactly that window uncovered.\n *\n * Nothing the disconnect path does needs the handle: `detachedSince` and\n * `sandboxKey` come from `ensureCtx`, which is built before `ensure` is called.\n * Only `onFinish`'s snapshot needs it, and that cannot run before `setup` has\n * completed.\n */\n handle?: SandboxHandle\n ensureCtx: SandboxEnsureContext\n watcher?: SandboxWatchHandle\n /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`\n * watcher callback, awaited before teardown so a pending diff isn't\n * dropped when the run finishes/aborts/errors mid-computation. */\n pendingDiffs: Array<Promise<void>>\n /** Logger captured at setup, so terminal hooks can log watcher teardown. */\n logger?: InternalLogger\n /**\n * Durability resolved once at setup (absent when the run is not durable), so\n * `onAbort` cannot reach a different verdict than the one `setup` published\n * on the capability bus.\n */\n durability?: SandboxRunDurability\n /**\n * Records the harness's own tool calls into the transcript, so a finished run\n * restores its tool cards from the message store instead of only from the (live,\n * rejoin-only) delivery log. See `./tool-history`.\n */\n toolHistory: ToolHistoryRecorder\n}\n\nconst runState = new WeakMap<object, SandboxRunState>()\n\n/**\n * Stop the watcher and drain any in-flight `diff()` promises before teardown,\n * so the final file's diff isn't dropped when a run finishes/aborts/errors\n * mid-computation. The `pendingDiffs` await is the load-bearing line — without\n * it a deferred diff resolves after the run is gone and its chunk is lost.\n */\nasync function drainWatcher(\n state: SandboxRunState,\n phase: 'finish' | 'abort' | 'error',\n): Promise<void> {\n // Guard `stop()`: a rejecting watcher teardown must NOT propagate out of\n // here, or the caller skips the `definition.destroy(...)` that follows —\n // leaking the sandbox on exactly the abort path that must ALWAYS tear down.\n try {\n await state.watcher?.stop()\n } catch (error) {\n state.logger?.warn('sandbox watcher stop failed', { phase, error })\n }\n await Promise.allSettled(state.pendingDiffs)\n if (state.watcher) state.logger?.sandbox('sandbox watcher stopped', { phase })\n}\n\n/**\n * Record the two facts a later attach and the reaper both need, then publish the\n * detach verdict core reads.\n *\n * Shared by the DISCONNECT subscriber registered in `setup` (the run is still\n * going — the normal case) and `onAbort`'s detach branch (the run is being torn\n * down while detachable), so the two can never write a different shape of detach.\n *\n * GUARDED, and reports failure rather than throwing. `update` is a documented\n * no-op for an unknown runId, so a vanished record does not turn teardown into a\n * throw; a genuinely rejecting store is the caller's to react to — `onAbort` falls\n * through to destroying the sandbox, because a DESTROYED sandbox beats an\n * unreachable one, while the disconnect subscriber has nothing to fall back to\n * (the run is alive and still using the sandbox) and simply leaves the verdict\n * unpublished.\n *\n * The verdict is published ONLY on success. Publishing it after a failed record\n * write would leave core holding the log open for a takeover that can never be\n * found, since nothing in the store points at the run.\n */\nasync function recordDetach(\n definition: SandboxDefinition,\n state: SandboxRunState,\n durability: SandboxRunDurability,\n ctx: ChatMiddlewareContext,\n phase: 'disconnect' | 'abort',\n): Promise<boolean> {\n try {\n // The record already exists: `setup` pre-creates it for every durable run\n // BEFORE `ensure`, precisely so this stamp cannot land on a runId the store has\n // never heard of — `RunStore.update` is a documented no-op for an unknown\n // runId, which is how the detach used to be lost silently (measured against the\n // browser repro: `detached_since` and `sandbox_key` both stayed NULL for a run\n // that had genuinely detached). If it has since vanished, that no-op is the\n // correct outcome and this must not throw.\n await durability.runs.update(ctx.runId, {\n detachedSince: Date.now(),\n sandboxKey: definition.key(state.ensureCtx),\n })\n } catch (error) {\n state.logger?.warn('sandbox detach record write failed', {\n runId: ctx.runId,\n phase,\n error,\n })\n return false\n }\n // Core's durable delivery sink reads this (see `RunDetachedCapability`) and\n // leaves the run's log OPEN instead of appending a synthetic terminal\n // `RUN_ERROR` and closing it — a terminalized log ends a later attach's replay\n // at the prefix and diverges the takeover's journal replay, which recorded a\n // healthy detached run as `'failed'`.\n provideRunDetached(ctx, true)\n return true\n}\n\n/**\n * Whether an out-of-band cancel has been recorded for this run, in EITHER band.\n * A user pressing Stop and a user closing the tab produce the IDENTICAL\n * connection close, so intent is never inferred from the disconnect itself: it\n * arrives in-process (the abort reason carried the cancel sentinel) or durably\n * (another host recorded it on the run record).\n */\nasync function cancelIntent(\n durability: SandboxRunDurability | undefined,\n runId: string,\n inProcess: boolean,\n): Promise<boolean> {\n if (inProcess) return true\n if (durability === undefined) return false\n // No guard needed here, and one would be dead code: `wasCancelRequested` already\n // answers `false` for a store read that rejects. That matters on this path,\n // because a rejection escaping into `onAbort` would skip BOTH of its branches at\n // once, leaving a sandbox that is neither reclaimable nor destroyed. The test\n // 'DETACHES when the cancel probe REJECTS' pins the composition.\n return wasCancelRequested(durability.runs, runId)\n}\n\n/** Defensively pull tenant scoping out of the runtime context, if present. */\nfunction tenantFrom(\n context: unknown,\n): { userId?: string; orgId?: string } | undefined {\n if (context === null || typeof context !== 'object') return undefined\n const c = context as Record<string, unknown>\n const userId = typeof c.userId === 'string' ? c.userId : undefined\n const orgId = typeof c.orgId === 'string' ? c.orgId : undefined\n if (userId === undefined && orgId === undefined) return undefined\n return { userId, orgId }\n}\n\n/**\n * Durability seams for a sandboxed run. Both are optional; each independently\n * falls back to a process-lifetime in-memory default, which is correct for a\n * single process but NOT across replicas.\n */\nexport interface SandboxMiddlewareOptions<TOffset extends string = string> {\n /**\n * Durable instance map (which provider sandbox to resume for a key). Pass\n * your own store to make resume survive across processes/replicas.\n *\n * Takes precedence over a store provided on the capability bus (see\n * `provideSandboxInstanceStore`), so the call site wins over ambient wiring.\n */\n instances?: SandboxInstanceStore\n /**\n * Distributed lock serializing resume-or-create for one key. Needed for\n * multi-replica correctness so two concurrent runs don't both create.\n *\n * Prefer `withLocks` from `@tanstack/ai/locks` when other middleware also\n * needs the lock; use this option to scope one to this sandbox. Takes\n * precedence over a bus-provided lock.\n */\n locks?: LockStore\n /**\n * Run lifecycle records. Pair with `durability.adapter` to make a run\n * DETACHABLE: a client disconnect then leaves the agent running and records\n * `detachedSince` instead of destroying the sandbox.\n *\n * Pass the SAME store chat persistence uses (`persistence.stores.runs`) so\n * one record describes the run instead of two that can disagree.\n *\n * Defaults to `undefined`: an app that passes neither this nor `durability`\n * keeps today's destroy-on-disconnect behavior exactly.\n */\n runs?: RunStore\n /**\n * Delivery durability for the run's event log, plus the journal and detach\n * knobs. Requires `runs`; either alone is not durable.\n *\n * `TOffset` is inferred from the adapter passed here, so a branded-cursor\n * backend (`durableStream`) wires without a cast and without the call site\n * ever naming the parameter.\n */\n durability?: SandboxDurabilityOptions<TOffset>\n}\n\n/**\n * Resolve the ensure seams. Precedence is explicit option → capability bus →\n * (in `ensure`) the in-memory fallback. The option wins because it is visible\n * at the call site; the bus remains for platform/framework injection.\n */\nfunction buildEnsureCtx(\n ctx: ChatMiddlewareContext,\n // Narrowed to the two seams it reads rather than taking the whole options\n // object: `SandboxMiddlewareOptions` is now generic in the durability offset,\n // and `SandboxMiddlewareOptions<TOffset>` is not assignable to\n // `SandboxMiddlewareOptions<string>`. Both members here are offset-free, so\n // the narrowing keeps this helper independent of that parameter entirely.\n options: Pick<SandboxMiddlewareOptions, 'instances' | 'locks'> | undefined,\n): SandboxEnsureContext {\n return {\n threadId: ctx.threadId,\n runId: ctx.runId,\n store:\n options?.instances ?? ctx.getOptional(SandboxInstanceStoreCapability),\n locks: options?.locks ?? ctx.getOptional(LocksCapability),\n tenant: tenantFrom(ctx.context),\n signal: ctx.signal,\n adapterName: ctx.provider,\n }\n}\n\n/**\n * Dispatch a sandbox file event to the per-type hooks declared on the\n * definition. Errors in individual hooks are swallowed so one bad hook\n * cannot break the run — but are logged under the `errors` category first, so\n * a throwing hook is observable (matching the run-scoped path in the engine\n * and the behavior the observability docs promise).\n */\nasync function dispatchDefinitionHooks(\n hooks: SandboxHooks | undefined,\n event: SandboxFileHookEvent,\n logger?: InternalLogger,\n): Promise<void> {\n if (!hooks) return\n const typed = (\n {\n create: 'onFileCreate',\n change: 'onFileChange',\n delete: 'onFileDelete',\n } as const\n )[event.type]\n for (const fn of [hooks.onFile, hooks[typed]]) {\n if (!fn) continue\n try {\n await fn(event)\n } catch (error) {\n // swallowed — one bad hook must not break the run — but logged so the\n // failure isn't invisible.\n logger?.errors('sandbox file hook failed', {\n path: event.path,\n type: event.type,\n error,\n })\n }\n }\n}\n\nexport function withSandbox<TOffset extends string = string>(\n definition: SandboxDefinition,\n options?: SandboxMiddlewareOptions<TOffset>,\n): DefinedChatMiddleware<\n unknown,\n readonly [],\n readonly [typeof SandboxCapability, typeof ProjectionCapability]\n> {\n return defineChatMiddleware({\n name: 'sandbox',\n provides: [SandboxCapability, ProjectionCapability],\n // SandboxPolicyCapability is provided conditionally (only when the\n // definition has a policy), so it is intentionally NOT declared here —\n // consumers read it via `getOptional`. SandboxDurabilityCapability and\n // DetachableRunCapability are conditional for the same reason (only when\n // `runs` + `durability` are both wired), so they are intentionally NOT\n // declared here either.\n optionalRequires: [SandboxInstanceStoreCapability, LocksCapability],\n\n async setup(ctx) {\n const ensureCtx = buildEnsureCtx(ctx, options)\n\n // Resolving here (not lazily on the abort path) is what keeps `setup` and\n // `onAbort` on one verdict: the payload the bus carries is the same object\n // the teardown path consults.\n // `TOffset` is passed explicitly: `options` is possibly `undefined` here,\n // so inference has nothing to work from on that branch and would fall\n // back to the `= string` default, re-erecting the very wall this\n // parameter exists to remove.\n const durability = resolveSandboxDurability<TOffset>(options)\n if (durability !== undefined) {\n provideSandboxDurability(ctx, durability)\n // A neutral boolean core owns, so `@tanstack/ai-persistence` can ask\n // \"is this run detachable?\" without depending on this package.\n provideDetachableRun(ctx, true)\n }\n\n // Pull the runtime (and its logger) up front so `baseSha` capture and\n // hook dispatch below can log through the same `sandbox`/`errors`\n // categories the engine uses.\n const runtime = getSandboxRuntime(ctx, { optional: true })\n const logger = runtime?.logger\n\n // REGISTER THE RUN STATE NOW — before `definition.ensure()`, not merely\n // before the end of `setup`.\n //\n // `onAbort` and the disconnect subscriber both need this state, so until\n // this map is populated they are silent no-ops. `ensure` is the LONGEST\n // await in the entire run (create a sandbox, clone a repo — minutes), and it\n // is where the most common disconnect of all lands: a user starts a run and\n // switches away while the UI still says \"starting the sandbox\". Registering\n // after `ensure` returned still left that whole window uncovered.\n //\n // Leaving it uncovered loses every teardown behavior at once: no\n // `detachedSince`/`sandboxKey`, so `listReclaimable` can never surface the\n // run and the reaper can never reclaim it; no `definition.destroy`, so the\n // sandbox leaks; and no detach verdict for core to read.\n //\n // Everything those hooks read is already resolved above: the ensure context\n // (which is all `definition.key` needs), the durability verdict, and the\n // logger. The fields discovered later (`handle`, `watcher`) are ASSIGNED onto\n // this same object as they become available, so the teardown path always\n // sees the most complete state that exists at the moment it runs.\n const state: SandboxRunState = {\n ensureCtx,\n pendingDiffs: [],\n toolHistory: createToolHistoryRecorder(),\n ...(logger ? { logger } : {}),\n ...(durability ? { durability } : {}),\n }\n runState.set(ctx, state)\n\n // MAKE THE RUN FINDABLE BEFORE `ensure`, not after the run finally streams.\n //\n // Chat persistence creates the run record from `onConfig`, which runs after\n // EVERY middleware `setup` — so for the whole of `definition.ensure` (create a\n // sandbox, clone a repo: minutes) the run has no record at all, and\n // `findActiveRun` answers \"no active run\" for a run that is demonstrably\n // starting. Measured: a status sidebar read straight off `findActiveRun`\n // reported `idle` for 6.5 minutes while the sandbox was being built, and a\n // client returning to the thread in that window had nothing to tell it a run\n // was in flight — so it rendered an empty pane instead of \"starting sandbox\".\n //\n // A crash in the same window is worse: no record means `listReclaimable` can\n // never surface the run, so the sandbox leaks with no recovery path.\n //\n // `createOrResume` is idempotent and never resurrects a finished run, so\n // persistence's own later call stays correct and simply finds this record.\n if (durability !== undefined) {\n try {\n await durability.runs.createOrResume({\n runId: ctx.runId,\n threadId: ctx.threadId,\n startedAt: Date.now(),\n })\n } catch (error) {\n // Best-effort: a store blip must not stop a run that is otherwise fine.\n // The run is simply invisible until persistence's own `onConfig` call.\n logger?.warn('sandbox run record pre-create failed', {\n runId: ctx.runId,\n error,\n })\n }\n\n // NO ATTACH MARKER HERE. A joiner does need a chunk in the log before the\n // harness has emitted anything — an empty log fails every joiner's\n // fast-fail (`memoryStream`'s first-chunk deadline, the client's rejoin\n // connect deadline) and flushes no HTTP headers, so a reload during\n // `ensure` reads a live run as gone. Core does it: a fresh durable producer\n // appends `RUN_ACCEPTED_EVENT` before the producer stream is first pulled,\n // for EVERY durable run rather than only sandboxed ones, and never on an\n // attach. A second marker from here would only land mid-stream in a run\n // that is already producing.\n\n // STORE THE USER'S TURN NOW, before `ensure` takes minutes.\n //\n // Chat persistence stores it from `onStart`, which runs after every\n // middleware `setup` — so without this the thread holds NOTHING for the\n // whole sandbox build. Measured: a reload during the build asked the server\n // for the conversation and got `{\"messages\":[],…}`, so the user saw no sign\n // of the message they had just sent, and a second device saw an empty\n // thread.\n //\n // The persistence layer owns WHAT gets stored (see `PendingTurnCapability`):\n // `saveThread` replaces the thread, so deciding the list here would risk\n // deleting the history. Absent when the app wires no persistence, which is\n // simply a run with no transcript to store.\n try {\n await getPendingTurn(ctx, { optional: true })?.snapshot()\n } catch (error) {\n // Best-effort: the run is still worth doing, and `onStart` stores the\n // turn again once setup completes.\n logger?.warn('sandbox pending-turn snapshot failed', {\n runId: ctx.runId,\n error,\n })\n }\n }\n\n // SUBSCRIBE BEFORE `ensure`, for the same reason the state is registered\n // before it: `ensure` is the minutes-wide await a disconnect actually lands\n // in. Core calls back immediately if the socket has already closed, so\n // subscribing here cannot miss a disconnect that beat us to it.\n //\n // This is what makes a durable run SURVIVE losing its viewer. The only route\n // a disconnect previously had into this middleware was the application\n // mirroring `request.signal` into `chat()`'s `abortController` — which aborts\n // the run, so `chat()` returned right after this `setup` and the harness\n // adapter's `chatStream` was never called: the agent in the sandbox we just\n // spent minutes creating was NEVER LAUNCHED, and no takeover could recover it\n // because an agent that never ran wrote no journal to replay.\n if (durability !== undefined && durability.detachOnDisconnect) {\n getRunDisconnect(ctx, { optional: true })?.subscribe(async () => {\n // BOOKKEEPING ONLY — the run is still executing. Deliberately absent:\n // `drainWatcher` (would blind a live agent's file events for the whole\n // remainder) and `definition.destroy` (the run is still using the\n // sandbox). Both belong to the terminal hooks, which still run exactly\n // once afterwards.\n //\n // A run with a cancel already recorded is left alone: that is `onAbort`'s\n // path, and stamping `detachedSince` on a deliberately-stopped run would\n // hand it to the reaper as reclaimable work.\n if (await cancelIntent(durability, ctx.runId, false)) return\n if (\n await recordDetach(definition, state, durability, ctx, 'disconnect')\n ) {\n state.logger?.sandbox(\n 'sandbox run detached on disconnect; the run continues',\n { runId: ctx.runId },\n )\n }\n })\n }\n\n const handle = await definition.ensure(ensureCtx)\n // MUTATE, don't re-`set`: a disconnect that landed during `ensure` already\n // captured this object.\n state.handle = handle\n provideSandbox(ctx, handle)\n if (definition.policy) provideSandboxPolicy(ctx, definition.policy)\n\n // Deliberately placed AFTER `logger` is in scope rather than next to the\n // `provideSandboxDurability` call above — there is no logger to warn\n // through until the runtime has been read.\n //\n // `ensureCtx.locks === undefined` counts as in-memory: `defineSandbox`'s\n // `ensure` falls back to a process-lifetime `InMemoryLockStore` when no\n // lock is wired, so an unwired lock has exactly the deficiency being\n // warned about — it is the MOST in-memory case, not an exempt one.\n if (\n durability !== undefined &&\n (ensureCtx.locks === undefined ||\n ensureCtx.locks instanceof InMemoryLockStore)\n ) {\n logger?.warn(\n 'sandbox durability is wired over an InMemoryLockStore: run claims are ' +\n 'serialized within this process only and the lease never signals loss, ' +\n 'so two hosts can drive one run and duplicate its event log. Use a ' +\n 'distributed LockStore via withLocks for any multi-replica deploy.',\n { runId: ctx.runId },\n )\n }\n\n const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT\n let baseSha = ''\n try {\n const shaRes = await handle.process.exec('git rev-parse HEAD', {\n cwd: watchRoot,\n })\n if (shaRes.exitCode === 0) {\n baseSha = shaRes.stdout.trim()\n logger?.sandbox('sandbox git baseline captured', {\n root: watchRoot,\n baseSha,\n })\n } else {\n // Non-zero exit: either not a git repository (non-git workspace) or a\n // repo with no commits (no HEAD). Expected, but it silently degrades\n // every subsequent diff to a full-file add-patch, so surface it\n // under `sandbox` (with stderr) rather than leaving nothing to grep.\n logger?.sandbox('sandbox git baseline unavailable (non-zero exit)', {\n root: watchRoot,\n exitCode: shaRes.exitCode,\n stderr: shaRes.stderr,\n })\n }\n } catch (error) {\n // exec rejected (git not on PATH, exec seam broken) → baseSha stays ''\n // and accessors fall back, but this is a real anomaly, not a plain\n // non-git workspace, so warn.\n logger?.warn('sandbox git baseline capture failed', {\n root: watchRoot,\n error,\n })\n }\n\n const workspace = definition.workspace\n if (workspace !== undefined) {\n const virtualRoot = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n const root = resolveHarnessCwd(handle, virtualRoot)\n const workspaceHash = computeWorkspaceHash(workspace)\n const secrets = workspace.secrets\n provideWorkspaceProjection(ctx, {\n skills: workspace.skills ?? [],\n plugins: workspace.plugins ?? [],\n resolveSecret: (ref) => {\n if (secrets === undefined) {\n throw new Error(\n `resolveSecret: no secrets defined on this workspace (ref: \"${ref.__secretName}\")`,\n )\n }\n return resolveSecret(secrets, ref)\n },\n markerPath: `${root}/.tanstack-projected-${workspaceHash}`,\n root,\n ...(workspace.scripts !== undefined\n ? { scripts: workspace.scripts }\n : {}),\n })\n }\n\n const hooks = definition.hooks\n await hooks?.onReady?.(handle)\n\n const fe = resolveFileEvents(definition.fileEvents)\n // THE SAME array the run state already holds, not a fresh one. The watcher\n // callback below closes over this reference, and `drainWatcher` awaits\n // `state.pendingDiffs` — a second array would silently drop every in-flight\n // diff from the teardown drain.\n const pendingDiffs = state.pendingDiffs\n let watcher: SandboxWatchHandle | undefined\n if (fe.enabled) {\n watcher = await watchWorkspace(handle, {\n onEvent: (event: SandboxFileEvent) => {\n const enriched = buildFileHookEvent(\n handle,\n watchRoot,\n baseSha,\n event,\n logger,\n )\n void dispatchDefinitionHooks(hooks, enriched, logger)\n runtime?.emit(enriched)\n if (fe.diff) {\n pendingDiffs.push(\n enriched\n .diff()\n .then((diff) => {\n runtime?.emitFileDiff({ path: event.path, diff })\n })\n .catch((error: unknown) => {\n logger?.warn('sandbox file diff emit failed', {\n path: event.path,\n error,\n })\n }),\n )\n }\n },\n // Watch the SAME root the enrichment layer relativizes against\n // (`buildFileHookEvent(handle, watchRoot, …)` and the `baseSha`\n // capture). Without this the watcher defaults to `/workspace` while\n // enrichment uses `watchRoot`, so a custom `workspace.root` makes the\n // two look at different directories and git pathspecs break.\n root: watchRoot,\n ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),\n ...(logger !== undefined ? { logger } : {}),\n })\n logger?.sandbox('sandbox watcher started', {\n root: watchRoot,\n diff: fe.diff,\n })\n }\n\n // MUTATE the object registered above rather than `set`-ing a second one: an\n // abort that landed mid-setup already captured a reference to it (and may\n // already be draining `pendingDiffs`), so replacing the entry would hand the\n // teardown path a different object than the watcher writes into.\n // `pendingDiffs` needs no copying — it IS `state.pendingDiffs`.\n if (watcher) state.watcher = watcher\n },\n\n // Keep the recorded tool history OUT of the request to the model. It is stored\n // history for the next turn, it names tools the provider was never given, and one\n // triage-sized run is hundreds of kilobytes — so replaying it is wasteful at best\n // and rejected at worst. `ctx.messages` keeps it (that is what gets stored and\n // rendered); only `config.messages` loses it.\n onConfig(_ctx, config) {\n const messages = stripObservedToolCalls(config.messages)\n if (messages.length === config.messages.length) return\n return { messages }\n },\n\n // The engine re-syncs `middlewareCtx.messages` from its own array once per agent\n // iteration, which drops whatever the recorder appended during the previous\n // iteration's stream. Restoring it here — AFTER that sync — is what makes a\n // multi-iteration run keep its full history without depending on where this\n // middleware sits relative to persistence in the middleware array.\n onIteration(ctx) {\n runState.get(ctx)?.toolHistory.reconcile(ctx)\n },\n\n // Record the harness's own tool calls as transcript messages. Observe only:\n // returning nothing passes every chunk through untouched.\n onChunk(ctx, chunk) {\n runState.get(ctx)?.toolHistory.observe(chunk, ctx)\n },\n\n async onFinish(ctx) {\n const state = runState.get(ctx)\n if (!state) return\n const { handle, ensureCtx } = state\n\n // Last chance before persistence writes the transcript. Only matters if a\n // config sync landed after the final tool chunk; the recorder is idempotent, so\n // in the normal case this changes nothing.\n state.toolHistory.reconcile(ctx)\n\n await drainWatcher(state, 'finish')\n\n const lifecycle = definition.lifecycle\n\n // `handle` is absent only if `setup` never got past `definition.ensure`, in\n // which case there is no sandbox to snapshot.\n if (\n lifecycle?.snapshot === 'after-run' &&\n handle?.capabilities.snapshots &&\n handle.snapshot\n ) {\n const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)\n const store = ensureCtx.store\n if (store) {\n const key = definition.key(ensureCtx)\n const existing = await store.get(key)\n if (existing) {\n await store.upsert({\n ...existing,\n latestSnapshotId: snapshot.id,\n updatedAt: Date.now(),\n })\n }\n }\n }\n\n if (lifecycle?.destroyOnComplete) {\n await definition.destroy(ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n\n async onAbort(ctx, info: AbortInfo) {\n const state = runState.get(ctx)\n if (!state) return\n\n // First on BOTH branches: a diff still in flight must be drained whether\n // the sandbox is about to be destroyed or merely detached, or the final\n // file's diff is dropped.\n await drainWatcher(state, 'abort')\n\n const durability = state.durability\n const cancelled = await cancelIntent(\n durability,\n ctx.runId,\n info.cancelRequested === true,\n )\n\n if (\n durability !== undefined &&\n !cancelled &&\n durability.detachOnDisconnect\n ) {\n // DETACH on the teardown path. Reached when the run is aborted for a\n // reason that is NOT an out-of-band cancel while detachable — a genuine\n // stop from elsewhere, or a host going down. The ordinary disconnect is\n // handled by the disconnect subscriber in `setup`, which does not end the\n // run at all.\n //\n // On a failed record write this branch is ABANDONED for the destroy one\n // below, because a rejection here is the worst shape available: the\n // verdict is unpublished, so core terminalizes the log and records a\n // healthy detached run as failed; `detachedSince`/`sandboxKey` are\n // unwritten, so `listReclaimable` can never surface the run and\n // `reapDetachedRuns` can never reclaim it. A DESTROYED sandbox beats an\n // unreachable one — the same reasoning `drainWatcher` applies to its own\n // guarded `stop()`.\n if (await recordDetach(definition, state, durability, ctx, 'abort')) {\n return\n }\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n return\n }\n\n // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.\n // The in-sandbox agent process is not killed by closing its IO stream\n // (e.g. a Docker exec survives client disconnect), so the only reliable way\n // to stop it — and the token/cost drain of its ongoing API calls — is to\n // destroy the sandbox (stop the container/VM). `keepAlive` /\n // `destroyOnComplete:false` governs *successful completion*, never cancel.\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n },\n\n async onError(ctx, info) {\n const state = runState.get(ctx)\n if (!state) return\n\n await drainWatcher(state, 'error')\n await definition.hooks?.onError?.(info.error)\n\n // On failure, only tear down when the lifecycle says so; otherwise leave\n // the sandbox for a resumed retry.\n if (definition.lifecycle?.destroyOnComplete) {\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n },\n })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"middleware.js","names":[],"sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSandbox(definition, options?)` — the middleware that PROVIDES the\n * {@link SandboxCapability} a harness adapter requires.\n *\n * - `setup`: resume-or-create the sandbox (via the definition's ensure\n * algorithm), provide the handle, using the durability seams from\n * {@link SandboxMiddlewareOptions} (or, failing that, a bus-provided\n * SandboxInstanceStoreCapability / LocksCapability, then an in-memory\n * fallback). If `fileEvents` is not false, starts a\n * watcher that dispatches to sandbox-scoped hooks and forwards to the runtime\n * sink.\n * - `onFinish`/`onAbort`/`onError`: stop the watcher, snapshot (`after-run`)\n * and/or destroy per lifecycle.\n *\n * NOTE: streamed sandbox lifecycle events (sandbox.created, workspace.setup.*)\n * are emitted by the harness adapter's chatStream (which can yield CUSTOM\n * chunks), not from here — middleware setup runs before streaming begins.\n */\nimport {\n defineChatMiddleware,\n provideDetachableRun,\n provideRunDetached,\n wasCancelRequested,\n} from '@tanstack/ai'\nimport { InMemoryLockStore, LocksCapability } from '@tanstack/ai/locks'\nimport {\n getPendingTurn,\n getRunDisconnect,\n getSandboxRuntime,\n} from '@tanstack/ai/adapter-internals'\nimport {\n SandboxCapability,\n provideSandbox,\n provideSandboxPolicy,\n} from './capabilities'\nimport {\n provideSandboxDurability,\n resolveSandboxDurability,\n} from './durability'\nimport { SandboxInstanceStoreCapability } from './instance-store'\nimport { computeWorkspaceHash } from './key'\nimport { buildFileHookEvent, resolveFileEvents } from './file-diff'\nimport { ProjectionCapability, provideWorkspaceProjection } from './projection'\nimport { resolveAllSecrets, resolveSecret } from './secrets'\nimport {\n createToolHistoryRecorder,\n stripObservedToolCalls,\n} from './tool-history'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport { resolveHarnessCwd } from './harness-cwd'\nimport { ensureSandboxWithOutcome } from './sandbox'\nimport {\n restoreSandboxFiles,\n captureSandboxFiles,\n captureSandboxArtifacts,\n resolveSandboxSnapshotPolicy,\n} from './snapshots'\nimport type { SandboxSnapshotPolicy } from './snapshots'\nimport type {\n SandboxCheckpointStore,\n SandboxCheckpointWriterLease,\n} from './checkpoint-store'\nimport { SandboxCheckpointError } from './checkpoint-store'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\nimport type { LockStore } from '@tanstack/ai/locks'\nimport type {\n AbortInfo,\n ChatMiddlewareContext,\n DefinedChatMiddleware,\n ModelMessage,\n RunStore,\n SandboxFileEvent,\n SandboxFileHookEvent,\n} from '@tanstack/ai'\nimport type {\n SandboxDurabilityOptions,\n SandboxRunDurability,\n} from './durability'\nimport type { SandboxInstanceStore } from './instance-store'\nimport type { ToolHistoryRecorder } from './tool-history'\nimport type { SandboxHandle } from './contracts'\nimport type {\n SandboxDefinition,\n SandboxEnsureContext,\n SandboxHooks,\n} from './sandbox'\nimport type { SandboxWatchHandle } from './watch'\n\n/** Per-request state we need to carry from `setup` to the terminal hooks. */\ninterface SandboxRunState {\n snapshotLease?: SandboxCheckpointWriterLease\n snapshotRenewal?: ReturnType<typeof setTimeout>\n snapshotRenewTask?: Promise<void>\n snapshotCaptureTask?: Promise<void>\n snapshotRenewalGeneration: number\n snapshotStop?: Promise<void>\n /** A detached or paused run cannot later publish portable state. */\n snapshotClosed?: boolean\n snapshotLost?: Error\n snapshotCleaned?: boolean\n snapshotConfig?: NonNullable<SandboxMiddlewareOptions['snapshots']>\n snapshotPolicy?: SandboxSnapshotPolicy\n snapshotRuntime?: {\n persistence: NonNullable<\n SandboxMiddlewareOptions['snapshots']\n >['persistence']\n completion: { waitForRunCompletion: () => Promise<void> }\n }\n /**\n * OPTIONAL because the state is registered BEFORE `definition.ensure()` is\n * awaited, and `ensure` is the slowest thing in the whole run — cloning a repo\n * into a fresh sandbox is minutes wide. That window is where the most common\n * disconnect of all lands (a user starts a run and switches away while the UI\n * still says \"starting the sandbox\"), so it is the one window the teardown and\n * disconnect hooks most need to be able to act in. Registering only after the\n * handle exists left exactly that window uncovered.\n *\n * Nothing the disconnect path does needs the handle: `detachedSince` and\n * `sandboxKey` come from `ensureCtx`, which is built before `ensure` is called.\n * Only `onFinish`'s snapshot needs it, and that cannot run before `setup` has\n * completed.\n */\n handle?: SandboxHandle\n privateHandle?: boolean\n ensureCtx: SandboxEnsureContext\n watcher?: SandboxWatchHandle\n /** In-flight `enriched.diff()` promises queued by the `fileEvents.diff`\n * watcher callback, awaited before teardown so a pending diff isn't\n * dropped when the run finishes/aborts/errors mid-computation. */\n pendingDiffs: Array<Promise<void>>\n /** Logger captured at setup, so terminal hooks can log watcher teardown. */\n logger?: InternalLogger\n /**\n * Durability resolved once at setup (absent when the run is not durable), so\n * `onAbort` cannot reach a different verdict than the one `setup` published\n * on the capability bus.\n */\n durability?: SandboxRunDurability\n /**\n * Records the harness's own tool calls into the transcript, so a finished run\n * restores its tool cards from the message store instead of only from the (live,\n * rejoin-only) delivery log. See `./tool-history`.\n */\n toolHistory: ToolHistoryRecorder\n}\n\nconst runState = new WeakMap<object, SandboxRunState>()\n\nfunction stopSnapshotLease(\n state: SandboxRunState,\n options: { closePortable?: boolean } = {},\n): Promise<void> {\n if (options.closePortable) state.snapshotClosed = true\n if (state.snapshotStop) return state.snapshotStop\n if (state.snapshotCleaned) return Promise.resolve()\n state.snapshotCleaned = true\n state.snapshotRenewalGeneration++\n if (state.snapshotRenewal !== undefined) clearTimeout(state.snapshotRenewal)\n state.snapshotRenewal = undefined\n const renewTask = state.snapshotRenewTask\n const captureTask = state.snapshotCaptureTask\n const lease = state.snapshotLease\n state.snapshotLease = undefined\n state.snapshotStop = (async () => {\n await renewTask?.catch(() => {})\n await captureTask?.catch(() => {})\n await lease?.release()\n })()\n return state.snapshotStop\n}\n\nfunction startSnapshotRenewal(state: SandboxRunState): void {\n const lease = state.snapshotLease\n if (!lease) return\n const schedule = (): void => {\n const generation = state.snapshotRenewalGeneration\n state.snapshotRenewal = setTimeout(() => {\n void (async (): Promise<void> => {\n state.snapshotRenewal = undefined\n if (\n state.snapshotCleaned ||\n generation !== state.snapshotRenewalGeneration\n )\n return\n const renewal = Promise.resolve().then(async (): Promise<void> => {\n await lease.renew()\n })\n state.snapshotRenewTask = renewal\n try {\n await renewal\n } catch (error) {\n state.snapshotLost =\n error instanceof Error ? error : new Error(String(error))\n } finally {\n if (state.snapshotRenewTask === renewal)\n state.snapshotRenewTask = undefined\n }\n if (state.snapshotLost) await stopSnapshotLease(state).catch(() => {})\n else if (\n !state.snapshotCleaned &&\n generation === state.snapshotRenewalGeneration\n )\n schedule()\n })()\n }, lease.renewAfterMs)\n }\n schedule()\n}\n\n/**\n * Stop the watcher and drain any in-flight `diff()` promises before teardown,\n * so the final file's diff isn't dropped when a run finishes/aborts/errors\n * mid-computation. The `pendingDiffs` await is the load-bearing line — without\n * it a deferred diff resolves after the run is gone and its chunk is lost.\n */\nasync function drainWatcher(\n state: SandboxRunState,\n phase: 'finish' | 'pause' | 'abort' | 'error',\n): Promise<void> {\n // Guard `stop()`: a rejecting watcher teardown must NOT propagate out of\n // here, or the caller skips the `definition.destroy(...)` that follows —\n // leaking the sandbox on exactly the abort path that must ALWAYS tear down.\n try {\n await state.watcher?.stop()\n } catch (error) {\n state.logger?.warn('sandbox watcher stop failed', { phase, error })\n }\n await Promise.allSettled(state.pendingDiffs)\n if (state.watcher) state.logger?.sandbox('sandbox watcher stopped', { phase })\n}\n\nfunction canPublishPortableSnapshot(\n state: SandboxRunState,\n lease: SandboxCheckpointWriterLease,\n): boolean {\n if (state.snapshotLost) throw state.snapshotLost\n return (\n !state.snapshotClosed &&\n !state.snapshotCleaned &&\n state.snapshotLease === lease\n )\n}\n\n/**\n * Record the two facts a later attach and the reaper both need, then publish the\n * detach verdict core reads.\n *\n * Shared by the DISCONNECT subscriber registered in `setup` (the run is still\n * going — the normal case) and `onAbort`'s detach branch (the run is being torn\n * down while detachable), so the two can never write a different shape of detach.\n *\n * GUARDED, and reports failure rather than throwing. `update` is a documented\n * no-op for an unknown runId, so a vanished record does not turn teardown into a\n * throw; a genuinely rejecting store is the caller's to react to — `onAbort` falls\n * through to destroying the sandbox, because a DESTROYED sandbox beats an\n * unreachable one, while the disconnect subscriber has nothing to fall back to\n * (the run is alive and still using the sandbox) and simply leaves the verdict\n * unpublished.\n *\n * The verdict is published ONLY on success. Publishing it after a failed record\n * write would leave core holding the log open for a takeover that can never be\n * found, since nothing in the store points at the run.\n */\nasync function recordDetach(\n definition: SandboxDefinition,\n state: SandboxRunState,\n durability: SandboxRunDurability,\n ctx: ChatMiddlewareContext,\n phase: 'disconnect' | 'abort',\n): Promise<boolean> {\n try {\n // The record already exists: `setup` pre-creates it for every durable run\n // BEFORE `ensure`, precisely so this stamp cannot land on a runId the store has\n // never heard of — `RunStore.update` is a documented no-op for an unknown\n // runId, which is how the detach used to be lost silently (measured against the\n // browser repro: `detached_since` and `sandbox_key` both stayed NULL for a run\n // that had genuinely detached). If it has since vanished, that no-op is the\n // correct outcome and this must not throw.\n await durability.runs.update(ctx.runId, {\n detachedSince: Date.now(),\n sandboxKey: definition.key(state.ensureCtx),\n })\n } catch (error) {\n state.logger?.warn('sandbox detach record write failed', {\n runId: ctx.runId,\n phase,\n error,\n })\n return false\n }\n // Core's durable delivery sink reads this (see `RunDetachedCapability`) and\n // leaves the run's log OPEN instead of appending a synthetic terminal\n // `RUN_ERROR` and closing it — a terminalized log ends a later attach's replay\n // at the prefix and diverges the takeover's journal replay, which recorded a\n // healthy detached run as `'failed'`.\n provideRunDetached(ctx, true)\n return true\n}\n\n/**\n * Whether an out-of-band cancel has been recorded for this run, in EITHER band.\n * A user pressing Stop and a user closing the tab produce the IDENTICAL\n * connection close, so intent is never inferred from the disconnect itself: it\n * arrives in-process (the abort reason carried the cancel sentinel) or durably\n * (another host recorded it on the run record).\n */\nasync function cancelIntent(\n durability: SandboxRunDurability | undefined,\n runId: string,\n inProcess: boolean,\n): Promise<boolean> {\n if (inProcess) return true\n if (durability === undefined) return false\n // No guard needed here, and one would be dead code: `wasCancelRequested` already\n // answers `false` for a store read that rejects. That matters on this path,\n // because a rejection escaping into `onAbort` would skip BOTH of its branches at\n // once, leaving a sandbox that is neither reclaimable nor destroyed. The test\n // 'DETACHES when the cancel probe REJECTS' pins the composition.\n return wasCancelRequested(durability.runs, runId)\n}\n\n/** Defensively pull tenant scoping out of the runtime context, if present. */\nfunction tenantFrom(\n context: unknown,\n): { userId?: string; orgId?: string } | undefined {\n if (context === null || typeof context !== 'object') return undefined\n const c = context as Record<string, unknown>\n const userId = typeof c.userId === 'string' ? c.userId : undefined\n const orgId = typeof c.orgId === 'string' ? c.orgId : undefined\n if (userId === undefined && orgId === undefined) return undefined\n return { userId, orgId }\n}\n\n/**\n * Durability seams for a sandboxed run. Both are optional; each independently\n * falls back to a process-lifetime in-memory default, which is correct for a\n * single process but NOT across replicas.\n */\nexport interface SandboxMiddlewareOptions<TOffset extends string = string> {\n snapshots?: {\n persistence: {\n stores: {\n messages: {\n loadThread: (threadId: string) => Promise<ReadonlyArray<ModelMessage>>\n }\n artifacts: {\n listForThread: (threadId: string) => Promise<\n ReadonlyArray<{\n artifactId: string\n runId: string\n threadId: string\n blobKey?: string\n name: string\n mimeType: string\n size: number\n createdAt: number\n }>\n >\n }\n blobs: {\n get: (key: string) => Promise<{\n arrayBuffer: () => Promise<ArrayBuffer>\n } | null>\n head: (key: string) => Promise<unknown>\n put: (key: string, body: Uint8Array) => Promise<unknown>\n }\n }\n }\n checkpoints: SandboxCheckpointStore\n policy?: SandboxSnapshotPolicy\n }\n /**\n * Durable instance map (which provider sandbox to resume for a key). Pass\n * your own store to make resume survive across processes/replicas.\n *\n * Takes precedence over a store provided on the capability bus (see\n * `provideSandboxInstanceStore`), so the call site wins over ambient wiring.\n */\n instances?: SandboxInstanceStore\n /**\n * Distributed lock serializing resume-or-create for one key. Needed for\n * multi-replica correctness so two concurrent runs don't both create.\n *\n * Prefer `withLocks` from `@tanstack/ai/locks` when other middleware also\n * needs the lock; use this option to scope one to this sandbox. Takes\n * precedence over a bus-provided lock.\n */\n locks?: LockStore\n /**\n * Run lifecycle records. Pair with `durability.adapter` to make a run\n * DETACHABLE: a client disconnect then leaves the agent running and records\n * `detachedSince` instead of destroying the sandbox.\n *\n * Pass the SAME store chat persistence uses (`persistence.stores.runs`) so\n * one record describes the run instead of two that can disagree.\n *\n * Defaults to `undefined`: an app that passes neither this nor `durability`\n * keeps today's destroy-on-disconnect behavior exactly.\n */\n runs?: RunStore\n /**\n * Delivery durability for the run's event log, plus the journal and detach\n * knobs. Requires `runs`; either alone is not durable.\n *\n * `TOffset` is inferred from the adapter passed here, so a branded-cursor\n * backend (`durableStream`) wires without a cast and without the call site\n * ever naming the parameter.\n */\n durability?: SandboxDurabilityOptions<TOffset>\n}\n\n/**\n * Resolve the ensure seams. Precedence is explicit option → capability bus →\n * (in `ensure`) the in-memory fallback. The option wins because it is visible\n * at the call site; the bus remains for platform/framework injection.\n */\nfunction buildEnsureCtx(\n ctx: ChatMiddlewareContext,\n // Narrowed to the two seams it reads rather than taking the whole options\n // object: `SandboxMiddlewareOptions` is now generic in the durability offset,\n // and `SandboxMiddlewareOptions<TOffset>` is not assignable to\n // `SandboxMiddlewareOptions<string>`. Both members here are offset-free, so\n // the narrowing keeps this helper independent of that parameter entirely.\n options: Pick<SandboxMiddlewareOptions, 'instances' | 'locks'> | undefined,\n): SandboxEnsureContext {\n return {\n threadId: ctx.threadId,\n runId: ctx.runId,\n store:\n options?.instances ?? ctx.getOptional(SandboxInstanceStoreCapability),\n locks: options?.locks ?? ctx.getOptional(LocksCapability),\n tenant: tenantFrom(ctx.context),\n signal: ctx.signal,\n adapterName: ctx.provider,\n }\n}\n\n/**\n * Dispatch a sandbox file event to the per-type hooks declared on the\n * definition. Errors in individual hooks are swallowed so one bad hook\n * cannot break the run — but are logged under the `errors` category first, so\n * a throwing hook is observable (matching the run-scoped path in the engine\n * and the behavior the observability docs promise).\n */\nasync function dispatchDefinitionHooks(\n hooks: SandboxHooks | undefined,\n event: SandboxFileHookEvent,\n logger?: InternalLogger,\n): Promise<void> {\n if (!hooks) return\n const typed = (\n {\n create: 'onFileCreate',\n change: 'onFileChange',\n delete: 'onFileDelete',\n } as const\n )[event.type]\n for (const fn of [hooks.onFile, hooks[typed]]) {\n if (!fn) continue\n try {\n await fn(event)\n } catch (error) {\n // swallowed — one bad hook must not break the run — but logged so the\n // failure isn't invisible.\n logger?.errors('sandbox file hook failed', {\n path: event.path,\n type: event.type,\n error,\n })\n }\n }\n}\n\nexport function withSandbox<TOffset extends string = string>(\n definition: SandboxDefinition,\n options?: SandboxMiddlewareOptions<TOffset>,\n): DefinedChatMiddleware<\n unknown,\n readonly [],\n readonly [typeof SandboxCapability, typeof ProjectionCapability]\n> {\n return defineChatMiddleware({\n name: 'sandbox',\n provides: [SandboxCapability, ProjectionCapability],\n // SandboxPolicyCapability is provided conditionally (only when the\n // definition has a policy), so it is intentionally NOT declared here —\n // consumers read it via `getOptional`. SandboxDurabilityCapability and\n // DetachableRunCapability are conditional for the same reason (only when\n // `runs` + `durability` are both wired), so they are intentionally NOT\n // declared here either.\n optionalRequires: [SandboxInstanceStoreCapability, LocksCapability],\n\n async setup(ctx) {\n const ensureCtx = buildEnsureCtx(ctx, options)\n const snapshotConfig = options?.snapshots\n const snapshotWorkspaceHash = definition.workspace\n ? computeWorkspaceHash(definition.workspace)\n : undefined\n const snapshotPolicy = snapshotConfig\n ? resolveSandboxSnapshotPolicy(\n snapshotConfig.policy,\n snapshotWorkspaceHash,\n )\n : undefined\n let snapshotRuntime:\n | {\n persistence: {\n stores: {\n messages: {\n loadThread: (\n id: string,\n ) => Promise<ReadonlyArray<ModelMessage>>\n }\n artifacts: {\n listForThread: (id: string) => Promise<\n ReadonlyArray<{\n artifactId: string\n runId: string\n threadId: string\n blobKey?: string\n name: string\n mimeType: string\n size: number\n createdAt: number\n }>\n >\n }\n blobs: {\n get: (key: string) => Promise<{\n arrayBuffer: () => Promise<ArrayBuffer>\n } | null>\n head: (key: string) => Promise<unknown>\n put: (key: string, body: Uint8Array) => Promise<unknown>\n }\n }\n }\n completion: { waitForRunCompletion: () => Promise<void> }\n }\n | undefined\n let snapshotLease: SandboxCheckpointWriterLease | undefined\n if (snapshotConfig) {\n if (\n !snapshotConfig.persistence?.stores?.messages ||\n !snapshotConfig.persistence.stores.artifacts ||\n !snapshotConfig.persistence.stores.blobs\n )\n throw new Error(\n 'Sandbox snapshots require persistence stores.messages, stores.artifacts, and stores.blobs',\n )\n const persistenceModule = await import('@tanstack/ai-persistence')\n const persistence = ctx.getOptional(\n persistenceModule.PersistenceCapability,\n )\n if (persistence === undefined)\n throw new Error(\n 'Sandbox snapshots require withPersistence(snapshots.persistence) before withSandbox',\n )\n if (persistence !== snapshotConfig.persistence)\n throw new Error(\n 'Sandbox snapshots require the same persistence instance passed to withPersistence',\n )\n const completion = ctx.getOptional(\n persistenceModule.PersistenceCompletionCapability,\n )\n if (!completion)\n throw new Error(\n 'Sandbox snapshots require withPersistence before withSandbox',\n )\n snapshotRuntime = {\n persistence: snapshotConfig.persistence,\n completion,\n }\n snapshotLease = await snapshotConfig.checkpoints.acquireWriter(\n ctx.threadId,\n )\n }\n\n // Resolving here (not lazily on the abort path) is what keeps `setup` and\n // `onAbort` on one verdict: the payload the bus carries is the same object\n // the teardown path consults.\n // `TOffset` is passed explicitly: `options` is possibly `undefined` here,\n // so inference has nothing to work from on that branch and would fall\n // back to the `= string` default, re-erecting the very wall this\n // parameter exists to remove.\n const durability = resolveSandboxDurability<TOffset>(options)\n if (durability !== undefined) {\n provideSandboxDurability(ctx, durability)\n // A neutral boolean core owns, so `@tanstack/ai-persistence` can ask\n // \"is this run detachable?\" without depending on this package.\n provideDetachableRun(ctx, true)\n }\n\n // Pull the runtime (and its logger) up front so `baseSha` capture and\n // hook dispatch below can log through the same `sandbox`/`errors`\n // categories the engine uses.\n const runtime = getSandboxRuntime(ctx, { optional: true })\n const logger = runtime?.logger\n\n // REGISTER THE RUN STATE NOW — before `definition.ensure()`, not merely\n // before the end of `setup`.\n //\n // `onAbort` and the disconnect subscriber both need this state, so until\n // this map is populated they are silent no-ops. `ensure` is the LONGEST\n // await in the entire run (create a sandbox, clone a repo — minutes), and it\n // is where the most common disconnect of all lands: a user starts a run and\n // switches away while the UI still says \"starting the sandbox\". Registering\n // after `ensure` returned still left that whole window uncovered.\n //\n // Leaving it uncovered loses every teardown behavior at once: no\n // `detachedSince`/`sandboxKey`, so `listReclaimable` can never surface the\n // run and the reaper can never reclaim it; no `definition.destroy`, so the\n // sandbox leaks; and no detach verdict for core to read.\n //\n // Everything those hooks read is already resolved above: the ensure context\n // (which is all `definition.key` needs), the durability verdict, and the\n // logger. The fields discovered later (`handle`, `watcher`) are ASSIGNED onto\n // this same object as they become available, so the teardown path always\n // sees the most complete state that exists at the moment it runs.\n const state: SandboxRunState = {\n ensureCtx,\n snapshotRenewalGeneration: 0,\n pendingDiffs: [],\n toolHistory: createToolHistoryRecorder(),\n ...(logger ? { logger } : {}),\n ...(durability ? { durability } : {}),\n }\n runState.set(ctx, state)\n if (snapshotLease) {\n state.snapshotLease = snapshotLease\n startSnapshotRenewal(state)\n }\n\n // MAKE THE RUN FINDABLE BEFORE `ensure`, not after the run finally streams.\n //\n // Chat persistence creates the run record from `onConfig`, which runs after\n // EVERY middleware `setup` — so for the whole of `definition.ensure` (create a\n // sandbox, clone a repo: minutes) the run has no record at all, and\n // `findActiveRun` answers \"no active run\" for a run that is demonstrably\n // starting. Measured: a status sidebar read straight off `findActiveRun`\n // reported `idle` for 6.5 minutes while the sandbox was being built, and a\n // client returning to the thread in that window had nothing to tell it a run\n // was in flight — so it rendered an empty pane instead of \"starting sandbox\".\n //\n // A crash in the same window is worse: no record means `listReclaimable` can\n // never surface the run, so the sandbox leaks with no recovery path.\n //\n // `createOrResume` is idempotent and never resurrects a finished run, so\n // persistence's own later call stays correct and simply finds this record.\n if (durability !== undefined) {\n try {\n await durability.runs.createOrResume({\n runId: ctx.runId,\n threadId: ctx.threadId,\n startedAt: Date.now(),\n })\n } catch (error) {\n // Best-effort: a store blip must not stop a run that is otherwise fine.\n // The run is simply invisible until persistence's own `onConfig` call.\n logger?.warn('sandbox run record pre-create failed', {\n runId: ctx.runId,\n error,\n })\n }\n\n // NO ATTACH MARKER HERE. A joiner does need a chunk in the log before the\n // harness has emitted anything — an empty log fails every joiner's\n // fast-fail (`memoryStream`'s first-chunk deadline, the client's rejoin\n // connect deadline) and flushes no HTTP headers, so a reload during\n // `ensure` reads a live run as gone. Core does it: a fresh durable producer\n // appends `RUN_ACCEPTED_EVENT` before the producer stream is first pulled,\n // for EVERY durable run rather than only sandboxed ones, and never on an\n // attach. A second marker from here would only land mid-stream in a run\n // that is already producing.\n\n // STORE THE USER'S TURN NOW, before `ensure` takes minutes.\n //\n // Chat persistence stores it from `onStart`, which runs after every\n // middleware `setup` — so without this the thread holds NOTHING for the\n // whole sandbox build. Measured: a reload during the build asked the server\n // for the conversation and got `{\"messages\":[],…}`, so the user saw no sign\n // of the message they had just sent, and a second device saw an empty\n // thread.\n //\n // The persistence layer owns WHAT gets stored (see `PendingTurnCapability`):\n // `saveThread` replaces the thread, so deciding the list here would risk\n // deleting the history. Absent when the app wires no persistence, which is\n // simply a run with no transcript to store.\n try {\n await getPendingTurn(ctx, { optional: true })?.snapshot()\n } catch (error) {\n // Best-effort: the run is still worth doing, and `onStart` stores the\n // turn again once setup completes.\n logger?.warn('sandbox pending-turn snapshot failed', {\n runId: ctx.runId,\n error,\n })\n }\n }\n\n // SUBSCRIBE BEFORE `ensure`, for the same reason the state is registered\n // before it: `ensure` is the minutes-wide await a disconnect actually lands\n // in. Core calls back immediately if the socket has already closed, so\n // subscribing here cannot miss a disconnect that beat us to it.\n //\n // This is what makes a durable run SURVIVE losing its viewer. The only route\n // a disconnect previously had into this middleware was the application\n // mirroring `request.signal` into `chat()`'s `abortController` — which aborts\n // the run, so `chat()` returned right after this `setup` and the harness\n // adapter's `chatStream` was never called: the agent in the sandbox we just\n // spent minutes creating was NEVER LAUNCHED, and no takeover could recover it\n // because an agent that never ran wrote no journal to replay.\n if (durability !== undefined && durability.detachOnDisconnect) {\n getRunDisconnect(ctx, { optional: true })?.subscribe(async () => {\n const snapshotStop = stopSnapshotLease(state, {\n closePortable: true,\n })\n void snapshotStop.catch(() => {})\n // BOOKKEEPING ONLY — the run is still executing. Deliberately absent:\n // `drainWatcher` (would blind a live agent's file events for the whole\n // remainder) and `definition.destroy` (the run is still using the\n // sandbox). Both belong to the terminal hooks, which still run exactly\n // once afterwards.\n //\n // A run with a cancel already recorded is left alone: that is `onAbort`'s\n // path, and stamping `detachedSince` on a deliberately-stopped run would\n // hand it to the reaper as reclaimable work.\n if (await cancelIntent(durability, ctx.runId, false)) {\n await snapshotStop.catch((error: unknown) => {\n state.logger?.warn('sandbox snapshot writer release failed', {\n runId: ctx.runId,\n phase: 'disconnect',\n error,\n })\n })\n return\n }\n if (\n await recordDetach(definition, state, durability, ctx, 'disconnect')\n ) {\n try {\n await snapshotStop\n } catch (error) {\n state.logger?.warn('sandbox snapshot writer release failed', {\n runId: ctx.runId,\n phase: 'disconnect',\n error,\n })\n }\n state.logger?.sandbox(\n 'sandbox run detached on disconnect; the run continues',\n { runId: ctx.runId },\n )\n } else {\n await snapshotStop.catch((error: unknown) => {\n state.logger?.warn('sandbox snapshot writer release failed', {\n runId: ctx.runId,\n phase: 'disconnect',\n error,\n })\n })\n }\n })\n }\n\n let outcome: 'resumed' | 'native-restored' | 'created' = 'created'\n let handle: SandboxHandle\n try {\n if (snapshotConfig)\n ({ handle, outcome } = await ensureSandboxWithOutcome(\n definition,\n ensureCtx,\n ))\n else handle = await definition.ensure(ensureCtx)\n state.handle = handle\n state.privateHandle = snapshotConfig ? outcome !== 'resumed' : true\n if (snapshotConfig && outcome !== 'resumed') {\n const head = await snapshotConfig.checkpoints.getHead(ctx.threadId)\n if (head) {\n const checkpoint = await snapshotConfig.checkpoints.get(head)\n if (!checkpoint)\n throw new SandboxCheckpointError(\n 'SANDBOX_SNAPSHOT_CHECKPOINT_NOT_FOUND',\n `Checkpoint '${head}' was not found`,\n )\n await restoreSandboxFiles(\n handle,\n {\n blobs: snapshotConfig.persistence.stores.blobs,\n workspaceRoot:\n definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT,\n },\n checkpoint,\n snapshotPolicy,\n )\n }\n }\n } catch (error) {\n await stopSnapshotLease(state).catch(() => {})\n if (state.handle && state.privateHandle)\n await definition.destroy(ensureCtx).catch(() => {})\n throw error\n }\n // MUTATE, don't re-`set`: a disconnect that landed during `ensure` already\n // captured this object.\n state.handle = handle\n if (snapshotConfig) {\n state.snapshotConfig = snapshotConfig\n state.snapshotPolicy = snapshotPolicy\n state.snapshotRuntime = snapshotRuntime\n }\n try {\n provideSandbox(ctx, handle)\n if (definition.policy) provideSandboxPolicy(ctx, definition.policy)\n\n // Deliberately placed AFTER `logger` is in scope rather than next to the\n // `provideSandboxDurability` call above — there is no logger to warn\n // through until the runtime has been read.\n //\n // `ensureCtx.locks === undefined` counts as in-memory: `defineSandbox`'s\n // `ensure` falls back to a process-lifetime `InMemoryLockStore` when no\n // lock is wired, so an unwired lock has exactly the deficiency being\n // warned about — it is the MOST in-memory case, not an exempt one.\n if (\n durability !== undefined &&\n (ensureCtx.locks === undefined ||\n ensureCtx.locks instanceof InMemoryLockStore)\n ) {\n logger?.warn(\n 'sandbox durability is wired over an InMemoryLockStore: run claims are ' +\n 'serialized within this process only and the lease never signals loss, ' +\n 'so two hosts can drive one run and duplicate its event log. Use a ' +\n 'distributed LockStore via withLocks for any multi-replica deploy.',\n { runId: ctx.runId },\n )\n }\n\n const watchRoot = definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT\n let baseSha = ''\n try {\n const shaRes = await handle.process.exec('git rev-parse HEAD', {\n cwd: watchRoot,\n })\n if (shaRes.exitCode === 0) {\n baseSha = shaRes.stdout.trim()\n logger?.sandbox('sandbox git baseline captured', {\n root: watchRoot,\n baseSha,\n })\n } else {\n // Non-zero exit: either not a git repository (non-git workspace) or a\n // repo with no commits (no HEAD). Expected, but it silently degrades\n // every subsequent diff to a full-file add-patch, so surface it\n // under `sandbox` (with stderr) rather than leaving nothing to grep.\n logger?.sandbox(\n 'sandbox git baseline unavailable (non-zero exit)',\n {\n root: watchRoot,\n exitCode: shaRes.exitCode,\n stderr: shaRes.stderr,\n },\n )\n }\n } catch (error) {\n // exec rejected (git not on PATH, exec seam broken) → baseSha stays ''\n // and accessors fall back, but this is a real anomaly, not a plain\n // non-git workspace, so warn.\n logger?.warn('sandbox git baseline capture failed', {\n root: watchRoot,\n error,\n })\n }\n\n const workspace = definition.workspace\n if (workspace !== undefined) {\n const virtualRoot = workspace.root ?? DEFAULT_WORKSPACE_ROOT\n const root = resolveHarnessCwd(handle, virtualRoot)\n const workspaceHash = computeWorkspaceHash(workspace)\n const secrets = workspace.secrets\n provideWorkspaceProjection(ctx, {\n skills: workspace.skills ?? [],\n plugins: workspace.plugins ?? [],\n resolveSecret: (ref) => {\n if (secrets === undefined) {\n throw new Error(\n `resolveSecret: no secrets defined on this workspace (ref: \"${ref.__secretName}\")`,\n )\n }\n return resolveSecret(secrets, ref)\n },\n markerPath: `${root}/.tanstack-projected-${workspaceHash}`,\n root,\n ...(workspace.scripts !== undefined\n ? { scripts: workspace.scripts }\n : {}),\n })\n }\n\n const hooks = definition.hooks\n await hooks?.onReady?.(handle)\n\n const fe = resolveFileEvents(definition.fileEvents)\n // THE SAME array the run state already holds, not a fresh one. The watcher\n // callback below closes over this reference, and `drainWatcher` awaits\n // `state.pendingDiffs` — a second array would silently drop every in-flight\n // diff from the teardown drain.\n const pendingDiffs = state.pendingDiffs\n let watcher: SandboxWatchHandle | undefined\n if (fe.enabled) {\n watcher = await watchWorkspace(handle, {\n onEvent: (event: SandboxFileEvent) => {\n const enriched = buildFileHookEvent(\n handle,\n watchRoot,\n baseSha,\n event,\n logger,\n )\n void dispatchDefinitionHooks(hooks, enriched, logger)\n runtime?.emit(enriched)\n if (fe.diff) {\n pendingDiffs.push(\n enriched\n .diff()\n .then((diff) => {\n runtime?.emitFileDiff({ path: event.path, diff })\n })\n .catch((error: unknown) => {\n logger?.warn('sandbox file diff emit failed', {\n path: event.path,\n error,\n })\n }),\n )\n }\n },\n // Watch the SAME root the enrichment layer relativizes against\n // (`buildFileHookEvent(handle, watchRoot, …)` and the `baseSha`\n // capture). Without this the watcher defaults to `/workspace` while\n // enrichment uses `watchRoot`, so a custom `workspace.root` makes the\n // two look at different directories and git pathspecs break.\n root: watchRoot,\n ...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),\n ...(logger !== undefined ? { logger } : {}),\n })\n logger?.sandbox('sandbox watcher started', {\n root: watchRoot,\n diff: fe.diff,\n })\n }\n\n // MUTATE the object registered above rather than `set`-ing a second one: an\n // abort that landed mid-setup already captured a reference to it (and may\n // already be draining `pendingDiffs`), so replacing the entry would hand the\n // teardown path a different object than the watcher writes into.\n // `pendingDiffs` needs no copying — it IS `state.pendingDiffs`.\n if (watcher) state.watcher = watcher\n } catch (error) {\n await drainWatcher(state, 'error')\n await stopSnapshotLease(state).catch(() => {})\n if (state.privateHandle)\n await definition.destroy(ensureCtx).catch(() => {})\n throw error\n }\n },\n\n // Keep the recorded tool history OUT of the request to the model. It is stored\n // history for the next turn, it names tools the provider was never given, and one\n // triage-sized run is hundreds of kilobytes — so replaying it is wasteful at best\n // and rejected at worst. `ctx.messages` keeps it (that is what gets stored and\n // rendered); only `config.messages` loses it.\n onConfig(_ctx, config) {\n const messages = stripObservedToolCalls(config.messages)\n if (messages.length === config.messages.length) return\n return { messages }\n },\n\n // The engine re-syncs `middlewareCtx.messages` from its own array once per agent\n // iteration, which drops whatever the recorder appended during the previous\n // iteration's stream. Restoring it here — AFTER that sync — is what makes a\n // multi-iteration run keep its full history without depending on where this\n // middleware sits relative to persistence in the middleware array.\n onIteration(ctx) {\n runState.get(ctx)?.toolHistory.reconcile(ctx)\n },\n\n // Record the harness's own tool calls as transcript messages. Observe only:\n // returning nothing passes every chunk through untouched.\n async onChunk(ctx, chunk) {\n const state = runState.get(ctx)\n state?.toolHistory.observe(chunk, ctx)\n if (\n state &&\n chunk.type === 'RUN_FINISHED' &&\n chunk.outcome?.type === 'interrupt'\n ) {\n await drainWatcher(state, 'pause')\n await stopSnapshotLease(state, { closePortable: true })\n }\n },\n\n async onFinish(ctx) {\n const state = runState.get(ctx)\n if (!state) return\n const { handle, ensureCtx } = state\n\n // Last chance before persistence writes the transcript. Only matters if a\n // config sync landed after the final tool chunk; the recorder is idempotent, so\n // in the normal case this changes nothing.\n state.toolHistory.reconcile(ctx)\n\n await drainWatcher(state, 'finish')\n\n let primaryError: unknown\n try {\n const snapshotCaptureTask = Promise.resolve().then(\n async (): Promise<void> => {\n const config = state.snapshotConfig\n const runtime = state.snapshotRuntime\n const lease = state.snapshotLease\n if (!config || !runtime || !handle || !lease) {\n if (state.snapshotLost) throw state.snapshotLost\n return\n }\n if (!canPublishPortableSnapshot(state, lease)) return\n\n await runtime.completion.waitForRunCompletion()\n if (!canPublishPortableSnapshot(state, lease)) return\n\n const conversation =\n await runtime.persistence.stores.messages.loadThread(ctx.threadId)\n if (!canPublishPortableSnapshot(state, lease)) return\n\n const files = await captureSandboxFiles(\n handle,\n {\n blobs: config.persistence.stores.blobs,\n workspaceRoot:\n definition.workspace?.root ?? DEFAULT_WORKSPACE_ROOT,\n },\n state.snapshotPolicy,\n definition.workspace?.secrets !== undefined\n ? resolveAllSecrets(definition.workspace.secrets)\n : {},\n )\n if (!canPublishPortableSnapshot(state, lease)) return\n\n const artifacts = await captureSandboxArtifacts(\n {\n blobs: config.persistence.stores.blobs,\n artifacts: config.persistence.stores.artifacts,\n },\n ctx.threadId,\n definition.workspace?.secrets !== undefined\n ? resolveAllSecrets(definition.workspace.secrets)\n : {},\n )\n if (!canPublishPortableSnapshot(state, lease)) return\n\n const parentCheckpointId = await config.checkpoints.getHead(\n ctx.threadId,\n )\n if (!canPublishPortableSnapshot(state, lease)) return\n\n try {\n await config.checkpoints.append({\n checkpoint: {\n id: `checkpoint-${ctx.runId}`,\n threadId: ctx.threadId,\n parentCheckpointId,\n createdAt: Date.now(),\n reason: 'automatic',\n sourceRunId: ctx.runId,\n files: files.files,\n conversation,\n artifacts,\n },\n expectedHeadId: parentCheckpointId,\n writer: lease,\n })\n } catch (error) {\n if (state.snapshotLost) throw state.snapshotLost\n throw error\n }\n if (state.snapshotLost) throw state.snapshotLost\n },\n )\n state.snapshotCaptureTask = snapshotCaptureTask\n try {\n await snapshotCaptureTask\n } finally {\n if (state.snapshotCaptureTask === snapshotCaptureTask)\n state.snapshotCaptureTask = undefined\n }\n\n const lifecycle = definition.lifecycle\n\n // `handle` is absent only if `setup` never got past `definition.ensure`, in\n // which case there is no sandbox to snapshot.\n if (\n lifecycle?.snapshot === 'after-run' &&\n handle?.capabilities.snapshots &&\n handle.snapshot\n ) {\n const snapshot = await handle.snapshot(`after-run-${ctx.runId}`)\n const store = ensureCtx.store\n if (store) {\n const key = definition.key(ensureCtx)\n const existing = await store.get(key)\n if (existing) {\n await store.upsert({\n ...existing,\n latestSnapshotId: snapshot.id,\n updatedAt: Date.now(),\n })\n }\n }\n }\n\n if (lifecycle?.destroyOnComplete) {\n await definition.destroy(ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n } catch (error) {\n primaryError = error\n if (definition.lifecycle?.destroyOnComplete) {\n try {\n await definition.destroy(ensureCtx)\n await definition.hooks?.onDestroy?.()\n } catch (cleanupError) {\n state.logger?.warn(\n 'sandbox destroy after terminal failure failed',\n {\n runId: ctx.runId,\n phase: 'finish',\n error: cleanupError,\n },\n )\n }\n }\n }\n\n let snapshotCleanupError: unknown\n try {\n await stopSnapshotLease(state, { closePortable: true })\n } catch (error) {\n snapshotCleanupError = error\n }\n if (primaryError !== undefined) {\n if (snapshotCleanupError !== undefined)\n state.logger?.warn('sandbox snapshot writer release failed', {\n runId: ctx.runId,\n phase: 'finish',\n error: snapshotCleanupError,\n })\n throw primaryError\n }\n if (snapshotCleanupError !== undefined) throw snapshotCleanupError\n },\n\n async onAbort(ctx, info: AbortInfo) {\n const state = runState.get(ctx)\n if (!state) return\n\n // First on BOTH branches: a diff still in flight must be drained whether\n // the sandbox is about to be destroyed or merely detached, or the final\n // file's diff is dropped.\n await drainWatcher(state, 'abort')\n let releaseError: unknown\n try {\n await stopSnapshotLease(state, { closePortable: true })\n } catch (error) {\n releaseError = error\n }\n\n const durability = state.durability\n const cancelled = await cancelIntent(\n durability,\n ctx.runId,\n info.cancelRequested === true,\n )\n\n if (\n durability !== undefined &&\n !cancelled &&\n durability.detachOnDisconnect\n ) {\n // DETACH on the teardown path. Reached when the run is aborted for a\n // reason that is NOT an out-of-band cancel while detachable — a genuine\n // stop from elsewhere, or a host going down. The ordinary disconnect is\n // handled by the disconnect subscriber in `setup`, which does not end the\n // run at all.\n //\n // On a failed record write this branch is ABANDONED for the destroy one\n // below, because a rejection here is the worst shape available: the\n // verdict is unpublished, so core terminalizes the log and records a\n // healthy detached run as failed; `detachedSince`/`sandboxKey` are\n // unwritten, so `listReclaimable` can never surface the run and\n // `reapDetachedRuns` can never reclaim it. A DESTROYED sandbox beats an\n // unreachable one — the same reasoning `drainWatcher` applies to its own\n // guarded `stop()`.\n if (await recordDetach(definition, state, durability, ctx, 'abort')) {\n if (releaseError) throw releaseError\n return\n }\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n if (releaseError) throw releaseError\n return\n }\n\n // ALWAYS tear down on an explicit abort, regardless of `destroyOnComplete`.\n // The in-sandbox agent process is not killed by closing its IO stream\n // (e.g. a Docker exec survives client disconnect), so the only reliable way\n // to stop it — and the token/cost drain of its ongoing API calls — is to\n // destroy the sandbox (stop the container/VM). `keepAlive` /\n // `destroyOnComplete:false` governs *successful completion*, never cancel.\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n if (releaseError) throw releaseError\n },\n\n async onError(ctx, info) {\n const state = runState.get(ctx)\n if (!state) return\n\n await drainWatcher(state, 'error')\n let releaseError: unknown\n try {\n await stopSnapshotLease(state)\n } catch (error) {\n releaseError = error\n }\n await definition.hooks?.onError?.(info.error)\n\n // On failure, only tear down when the lifecycle says so; otherwise leave\n // the sandbox for a resumed retry.\n if (definition.lifecycle?.destroyOnComplete) {\n await definition.destroy(state.ensureCtx)\n await definition.hooks?.onDestroy?.()\n }\n if (releaseError) throw releaseError\n },\n })\n}\n"],"mappings":"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"}
@@ -13,4 +13,4 @@ export declare const ngrokBridgeProvisioner: ToolBridgeProvisioner;
13
13
  * host tools. Not needed for local-process / Docker (they reach the bridge
14
14
  * directly) — just don't add it there.
15
15
  */
16
- export declare const withNgrokBridge: import('@tanstack/ai').DefinedChatMiddleware<unknown, readonly [], readonly []>;
16
+ export declare const withNgrokBridge: import('@tanstack/ai').DefinedChatMiddleware<unknown, readonly [], readonly [], never>;
@@ -82,7 +82,23 @@ export interface SandboxDefinition {
82
82
  key: (ctx: SandboxEnsureContext) => string;
83
83
  /** Resume-or-create the sandbox for this thread/run. */
84
84
  ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>;
85
+ /** Resume an existing sandbox only. Never creates or restores a sandbox. */
86
+ ensureExisting: (ctx: SandboxEnsureContext) => Promise<SandboxHandle | null>;
85
87
  /** Tear down the sandbox recorded for this key. */
86
88
  destroy: (ctx: SandboxEnsureContext) => Promise<void>;
87
89
  }
90
+ export type SandboxEnsureOutcome = {
91
+ handle: SandboxHandle;
92
+ outcome: 'resumed' | 'native-restored' | 'created';
93
+ };
94
+ interface SandboxEnsureExistingStage {
95
+ key: string;
96
+ workspace: WorkspaceDefinition | undefined;
97
+ resolvedSecrets: Readonly<Record<string, string>> | undefined;
98
+ snapshotMaxAge: string | undefined;
99
+ resume: SandboxProvider['resume'];
100
+ }
101
+ export declare function stageEnsureExistingSandbox(definition: SandboxDefinition): (ctx: SandboxEnsureContext, stage: SandboxEnsureExistingStage) => Promise<SandboxHandle | null>;
102
+ export declare function ensureSandboxWithOutcome(definition: SandboxDefinition, ctx: SandboxEnsureContext): Promise<SandboxEnsureOutcome>;
88
103
  export declare function defineSandbox(config: SandboxConfig): SandboxDefinition;
104
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { InMemorySandboxInstanceStore } from "./instance-store.js";
2
- import { computeSandboxKey } from "./key.js";
3
2
  import { resolveAllSecrets } from "./secrets.js";
3
+ import { computeSandboxKey } from "./key.js";
4
4
  import { bootstrapWorkspace } from "./bootstrap.js";
5
5
  import { InMemoryLockStore } from "@tanstack/ai/locks";
6
6
  //#region src/sandbox.ts
@@ -12,6 +12,19 @@ import { InMemoryLockStore } from "@tanstack/ai/locks";
12
12
  * into a stable instance key and coordinates through the (optional) lock +
13
13
  * sandbox stores.
14
14
  */
15
+ var outcomeEnsure = /* @__PURE__ */ new WeakMap();
16
+ var existingEnsure = /* @__PURE__ */ new WeakMap();
17
+ function stageEnsureExistingSandbox(definition) {
18
+ const fn = existingEnsure.get(definition);
19
+ if (fn) return (ctx, stage) => fn(ctx, stage);
20
+ const ensureExisting = definition.ensureExisting.bind(definition);
21
+ return (ctx) => ensureExisting(ctx);
22
+ }
23
+ function ensureSandboxWithOutcome(definition, ctx) {
24
+ const fn = outcomeEnsure.get(definition);
25
+ if (!fn) throw new Error("Sandbox snapshot mode requires a definition created by defineSandbox()");
26
+ return fn(ctx);
27
+ }
15
28
  /**
16
29
  * Parse a human-readable duration string into milliseconds.
17
30
  * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).
@@ -40,9 +53,9 @@ var fallbackLocks = new InMemoryLockStore();
40
53
  * particular has no Docker Env on resume, so this is the only way secrets
41
54
  * come back for that provider.
42
55
  */
43
- async function applyWorkspaceSecrets(handle, workspace) {
56
+ async function applyWorkspaceSecrets(handle, workspace, stagedSecrets) {
44
57
  if (workspace?.secrets === void 0) return;
45
- const resolved = resolveAllSecrets(workspace.secrets);
58
+ const resolved = stagedSecrets ?? resolveAllSecrets(workspace.secrets);
46
59
  if (Object.keys(resolved).length === 0) return;
47
60
  await handle.env.set(resolved);
48
61
  }
@@ -54,7 +67,7 @@ function defineSandbox(config) {
54
67
  workspace: config.workspace,
55
68
  tenant: ctx.tenant
56
69
  });
57
- const ensure = async (ctx) => {
70
+ const ensureWithOutcome = async (ctx) => {
58
71
  const store = ctx.store ?? fallbackStore;
59
72
  const locks = ctx.locks ?? fallbackLocks;
60
73
  const key = computeSandboxKey(keyInputFor(ctx));
@@ -76,7 +89,10 @@ function defineSandbox(config) {
76
89
  latestRunId: ctx.runId,
77
90
  updatedAt: Date.now()
78
91
  });
79
- return resumed;
92
+ return {
93
+ handle: resumed,
94
+ outcome: "resumed"
95
+ };
80
96
  }
81
97
  if (existing.latestSnapshotId && caps.snapshots && config.provider.restoreSnapshot) {
82
98
  const restored = await config.provider.restoreSnapshot({
@@ -93,7 +109,10 @@ function defineSandbox(config) {
93
109
  latestRunId: ctx.runId,
94
110
  updatedAt: Date.now()
95
111
  });
96
- return restored;
112
+ return {
113
+ handle: restored,
114
+ outcome: "native-restored"
115
+ };
97
116
  }
98
117
  }
99
118
  }
@@ -122,9 +141,39 @@ function defineSandbox(config) {
122
141
  latestRunId: ctx.runId,
123
142
  updatedAt: Date.now()
124
143
  });
125
- return created;
144
+ return {
145
+ handle: created,
146
+ outcome: "created"
147
+ };
148
+ });
149
+ };
150
+ const ensure = async (ctx) => (await ensureWithOutcome(ctx)).handle;
151
+ const ensureExistingWithStage = async (ctx, stage) => {
152
+ const store = ctx.store ?? fallbackStore;
153
+ const locks = ctx.locks ?? fallbackLocks;
154
+ const key = stage?.key ?? computeSandboxKey(keyInputFor(ctx));
155
+ const workspace = stage?.workspace ?? config.workspace;
156
+ const snapshotMaxAge = stage ? stage.snapshotMaxAge : config.lifecycle?.snapshotMaxAge;
157
+ const resume = stage?.resume ?? config.provider.resume.bind(config.provider);
158
+ return locks.withLock(`sandbox:${key}`, async () => {
159
+ const existing = await store.get(key);
160
+ const maxAgeMs = parseMaxAgeMs(snapshotMaxAge);
161
+ if (!existing || maxAgeMs !== void 0 && Date.now() - existing.updatedAt > maxAgeMs) return null;
162
+ const resumed = await resume({
163
+ id: existing.providerSandboxId,
164
+ signal: ctx.signal
165
+ });
166
+ if (!resumed) return null;
167
+ await applyWorkspaceSecrets(resumed, workspace, stage?.resolvedSecrets);
168
+ await store.upsert({
169
+ ...existing,
170
+ latestRunId: ctx.runId,
171
+ updatedAt: Date.now()
172
+ });
173
+ return resumed;
126
174
  });
127
175
  };
176
+ const ensureExisting = (ctx) => ensureExistingWithStage(ctx);
128
177
  const destroy = async (ctx) => {
129
178
  const store = ctx.store ?? fallbackStore;
130
179
  const key = computeSandboxKey(keyInputFor(ctx));
@@ -142,7 +191,7 @@ function defineSandbox(config) {
142
191
  }
143
192
  await store.delete(key);
144
193
  };
145
- return {
194
+ const definition = {
146
195
  id: config.id,
147
196
  provider: config.provider,
148
197
  workspace: config.workspace,
@@ -152,10 +201,14 @@ function defineSandbox(config) {
152
201
  fileEvents: config.fileEvents,
153
202
  key: (ctx) => computeSandboxKey(keyInputFor(ctx)),
154
203
  ensure,
204
+ ensureExisting,
155
205
  destroy
156
206
  };
207
+ outcomeEnsure.set(definition, ensureWithOutcome);
208
+ existingEnsure.set(definition, ensureExistingWithStage);
209
+ return definition;
157
210
  }
158
211
  //#endregion
159
- export { defineSandbox };
212
+ export { defineSandbox, ensureSandboxWithOutcome, stageEnsureExistingSandbox };
160
213
 
161
214
  //# sourceMappingURL=sandbox.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sandbox.js","names":[],"sources":["../../src/sandbox.ts"],"sourcesContent":["/**\n * `defineSandbox()` returns a LAZY controller — it never creates a sandbox at\n * definition time. `withSandbox()` (and advanced users) call `ensure()` to\n * resume-or-create, following: provider.resume → provider.restoreSnapshot →\n * create + bootstrap. The controller folds provider/workspace/policy/lifecycle\n * into a stable instance key and coordinates through the (optional) lock +\n * sandbox stores.\n */\nimport { bootstrapWorkspace } from './bootstrap'\nimport { resolveAllSecrets } from './secrets'\nimport { computeSandboxKey } from './key'\nimport { InMemoryLockStore } from '@tanstack/ai/locks'\nimport type { LockStore } from '@tanstack/ai/locks'\nimport type { SandboxFileHookEvent } from '@tanstack/ai'\nimport { InMemorySandboxInstanceStore } from './instance-store'\nimport type { SandboxInstanceStore } from './instance-store'\nimport type { SandboxHandle, SandboxProvider } from './contracts'\nimport type { SandboxKeyInput } from './key'\nimport type { SandboxPolicy } from './policy'\nimport type { WorkspaceDefinition } from './workspace'\n\n/**\n * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every\n * create/change/delete during a chat run; lifecycle hooks fire server-side.\n */\nexport interface SandboxHooks {\n onFile?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>\n onReady?: (handle: SandboxHandle) => void | Promise<void>\n onError?: (err: unknown) => void | Promise<void>\n onDestroy?: () => void | Promise<void>\n}\n\nexport type ReuseStrategy = 'thread' | 'none'\nexport type SnapshotStrategy = 'after-setup' | 'after-run' | 'none'\n\nexport interface SandboxLifecycle {\n /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */\n reuse?: ReuseStrategy\n /** When to snapshot (provider-permitting). */\n snapshot?: SnapshotStrategy\n /** Hint for how long a provider should keep the sandbox warm between runs. */\n keepAlive?: string\n /** Destroy the sandbox after the run completes. */\n destroyOnComplete?: boolean\n /**\n * Maximum age of a sandbox record before it is discarded and re-created\n * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),\n * e.g. `'2h'` or `'30m'`.\n */\n snapshotMaxAge?: string\n}\n\nexport interface SandboxConfig {\n id: string\n provider: SandboxProvider\n workspace?: WorkspaceDefinition\n policy?: SandboxPolicy\n lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n fileEvents?: boolean | { diff?: boolean }\n}\n\n/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */\nexport interface SandboxEnsureContext {\n threadId: string\n runId: string\n /** Persistence seam; falls back to an in-memory store when absent. */\n store?: SandboxInstanceStore\n /** Lock seam; falls back to an in-memory lock when absent. */\n locks?: LockStore\n tenant?: { userId?: string; orgId?: string }\n signal?: AbortSignal\n /** Harness adapter name (`grok-build`, `claude-code`, `codex`, `opencode`). Optional. */\n adapterName?: string\n}\n\nexport interface SandboxDefinition {\n readonly id: string\n readonly provider: SandboxProvider\n readonly workspace?: WorkspaceDefinition\n readonly policy?: SandboxPolicy\n readonly lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n readonly hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n readonly fileEvents?: boolean | { diff?: boolean }\n /** Compound instance key for a given run context. */\n key: (ctx: SandboxEnsureContext) => string\n /** Resume-or-create the sandbox for this thread/run. */\n ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>\n /** Tear down the sandbox recorded for this key. */\n destroy: (ctx: SandboxEnsureContext) => Promise<void>\n}\n\n/**\n * Parse a human-readable duration string into milliseconds.\n * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).\n * Returns `undefined` when the input is undefined or the format is unrecognised.\n */\nfunction parseMaxAgeMs(value: string | undefined): number | undefined {\n if (value === undefined) return undefined\n const hourMatch = /^(\\d+)h$/.exec(value)\n if (hourMatch) return Number(hourMatch[1]) * 60 * 60 * 1000\n const minuteMatch = /^(\\d+)m$/.exec(value)\n if (minuteMatch) return Number(minuteMatch[1]) * 60 * 1000\n return undefined\n}\n\n/**\n * Bound for the unfenced teardown `destroy` call (see `destroy` below). Long\n * enough that a slow provider API still completes, short enough that a wedged\n * one cannot pin the process forever.\n */\nconst DESTROY_TIMEOUT_MS = 60 * 1000\n\n// Process-lifetime fallbacks shared across all definitions so concurrent\n// ensures for the same key serialize even without an injected store/lock.\nconst fallbackStore = new InMemorySandboxInstanceStore()\nconst fallbackLocks = new InMemoryLockStore()\n\n/**\n * Put workspace secrets onto a live handle. Resume and snapshot restore skip\n * bootstrap, so this is the only path that re-injects them after reconnect.\n * Create injects secrets via `provider.create({ env })`, but resume/restore\n * return a handle whose process env is empty unless we set it here. sbx in\n * particular has no Docker Env on resume, so this is the only way secrets\n * come back for that provider.\n */\nasync function applyWorkspaceSecrets(\n handle: SandboxHandle,\n workspace: WorkspaceDefinition | undefined,\n): Promise<void> {\n if (workspace?.secrets === undefined) return\n const resolved = resolveAllSecrets(workspace.secrets)\n if (Object.keys(resolved).length === 0) return\n await handle.env.set(resolved)\n}\n\nexport function defineSandbox(config: SandboxConfig): SandboxDefinition {\n const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({\n threadId:\n config.lifecycle?.reuse === 'none'\n ? `${ctx.threadId}:${ctx.runId}`\n : ctx.threadId,\n sandboxId: config.id,\n providerName: config.provider.name,\n workspace: config.workspace,\n tenant: ctx.tenant,\n })\n\n const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> => {\n const store = ctx.store ?? fallbackStore\n const locks = ctx.locks ?? fallbackLocks\n const key = computeSandboxKey(keyInputFor(ctx))\n const caps = config.provider.capabilities()\n\n return locks.withLock(`sandbox:${key}`, async () => {\n const effectiveSnapshot: SnapshotStrategy =\n config.lifecycle?.snapshot ?? (caps.snapshots ? 'after-setup' : 'none')\n const maxAgeMs = parseMaxAgeMs(config.lifecycle?.snapshotMaxAge)\n\n const existing = await store.get(key)\n if (existing) {\n // Check whether the record has exceeded snapshotMaxAge; if so,\n // discard and fall through to a fresh create.\n const tooOld =\n maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs\n\n if (!tooOld) {\n // 1) Try to reconnect to the still-running sandbox.\n const resumed = await config.provider.resume({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n if (resumed) {\n await applyWorkspaceSecrets(resumed, config.workspace)\n await store.upsert({\n ...existing,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return resumed\n }\n // 2) Else restore from the latest snapshot, if supported.\n if (\n existing.latestSnapshotId &&\n caps.snapshots &&\n config.provider.restoreSnapshot\n ) {\n const restored = await config.provider.restoreSnapshot({\n snapshotId: existing.latestSnapshotId,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n await applyWorkspaceSecrets(restored, config.workspace)\n await store.upsert({\n ...existing,\n providerSandboxId: restored.id,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return restored\n }\n }\n // 3) Else fall through and re-create under the same identity\n // (capability-aware degradation for ephemeral-disk providers, or\n // snapshotMaxAge TTL exceeded).\n }\n\n const created = await config.provider.create({\n // Deterministic id so consumers can reconstruct the provider sandbox\n // address from run context (not just from the store record).\n id: key,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n adapterName: ctx.adapterName,\n })\n\n if (config.workspace) {\n try {\n await bootstrapWorkspace(created, config.workspace, {\n signal: ctx.signal,\n })\n } catch (error) {\n // Bootstrap failed after the sandbox was created but before it was\n // recorded — destroy the orphan so a failed/retried run doesn't leak\n // a (billed) sandbox, then surface the original error.\n await created.destroy().catch(() => {})\n throw error\n }\n }\n\n let latestSnapshotId: string | undefined\n if (\n effectiveSnapshot === 'after-setup' &&\n caps.snapshots &&\n created.snapshot\n ) {\n latestSnapshotId = (await created.snapshot('after-setup')).id\n }\n\n await store.upsert({\n key,\n provider: config.provider.name,\n providerSandboxId: created.id,\n latestSnapshotId,\n threadId: ctx.threadId,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return created\n })\n }\n\n const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {\n const store = ctx.store ?? fallbackStore\n const key = computeSandboxKey(keyInputFor(ctx))\n const existing = await store.get(key)\n if (!existing) return\n /*\n * TEARDOWN IS DELIBERATELY NOT FENCED BY `ctx.signal`.\n *\n * `destroy` runs on every teardown path INCLUDING the one caused by that\n * very signal aborting, so forwarding it hands the provider a signal that is\n * already aborted: a provider that honors it does nothing and returns\n * successfully, and `store.delete` below then removes the only pointer to a\n * live, billed sandbox. `SandboxInstanceStore` has no `list` (see the note\n * at the top of `reclaim.ts`), so that sandbox is unreachable from then on.\n *\n * Same reasoning as `close()` never being fenced by the run claim (see\n * `fenceDurability` in `claim.ts`): cleanup must outlive whatever cancelled\n * the work. A fresh controller with its own bounded timeout keeps the call\n * from hanging forever without letting the caller's abort cancel it.\n */\n const teardown = new AbortController()\n const timer = setTimeout(() => teardown.abort(), DESTROY_TIMEOUT_MS)\n try {\n await config.provider.destroy({\n id: existing.providerSandboxId,\n signal: teardown.signal,\n })\n } finally {\n clearTimeout(timer)\n }\n await store.delete(key)\n }\n\n return {\n id: config.id,\n provider: config.provider,\n workspace: config.workspace,\n policy: config.policy,\n lifecycle: config.lifecycle,\n hooks: config.hooks,\n fileEvents: config.fileEvents,\n key: (ctx) => computeSandboxKey(keyInputFor(ctx)),\n ensure,\n destroy,\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sandbox.js","names":[],"sources":["../../src/sandbox.ts"],"sourcesContent":["/**\n * `defineSandbox()` returns a LAZY controller — it never creates a sandbox at\n * definition time. `withSandbox()` (and advanced users) call `ensure()` to\n * resume-or-create, following: provider.resume → provider.restoreSnapshot →\n * create + bootstrap. The controller folds provider/workspace/policy/lifecycle\n * into a stable instance key and coordinates through the (optional) lock +\n * sandbox stores.\n */\nimport { bootstrapWorkspace } from './bootstrap'\nimport { resolveAllSecrets } from './secrets'\nimport { computeSandboxKey } from './key'\nimport { InMemoryLockStore } from '@tanstack/ai/locks'\nimport type { LockStore } from '@tanstack/ai/locks'\nimport type { SandboxFileHookEvent } from '@tanstack/ai'\nimport { InMemorySandboxInstanceStore } from './instance-store'\nimport type { SandboxInstanceStore } from './instance-store'\nimport type { SandboxHandle, SandboxProvider } from './contracts'\nimport type { SandboxKeyInput } from './key'\nimport type { SandboxPolicy } from './policy'\nimport type { WorkspaceDefinition } from './workspace'\n\n/**\n * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every\n * create/change/delete during a chat run; lifecycle hooks fire server-side.\n */\nexport interface SandboxHooks {\n onFile?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileCreate?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileChange?: (e: SandboxFileHookEvent) => void | Promise<void>\n onFileDelete?: (e: SandboxFileHookEvent) => void | Promise<void>\n onReady?: (handle: SandboxHandle) => void | Promise<void>\n onError?: (err: unknown) => void | Promise<void>\n onDestroy?: () => void | Promise<void>\n}\n\nexport type ReuseStrategy = 'thread' | 'none'\nexport type SnapshotStrategy = 'after-setup' | 'after-run' | 'none'\n\nexport interface SandboxLifecycle {\n /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */\n reuse?: ReuseStrategy\n /** When to snapshot (provider-permitting). */\n snapshot?: SnapshotStrategy\n /** Hint for how long a provider should keep the sandbox warm between runs. */\n keepAlive?: string\n /** Destroy the sandbox after the run completes. */\n destroyOnComplete?: boolean\n /**\n * Maximum age of a sandbox record before it is discarded and re-created\n * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),\n * e.g. `'2h'` or `'30m'`.\n */\n snapshotMaxAge?: string\n}\n\nexport interface SandboxConfig {\n id: string\n provider: SandboxProvider\n workspace?: WorkspaceDefinition\n policy?: SandboxPolicy\n lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n fileEvents?: boolean | { diff?: boolean }\n}\n\n/** Context passed to `ensure()` by `withSandbox` (or advanced callers). */\nexport interface SandboxEnsureContext {\n threadId: string\n runId: string\n /** Persistence seam; falls back to an in-memory store when absent. */\n store?: SandboxInstanceStore\n /** Lock seam; falls back to an in-memory lock when absent. */\n locks?: LockStore\n tenant?: { userId?: string; orgId?: string }\n signal?: AbortSignal\n /** Harness adapter name (`grok-build`, `claude-code`, `codex`, `opencode`). Optional. */\n adapterName?: string\n}\n\nexport interface SandboxDefinition {\n readonly id: string\n readonly provider: SandboxProvider\n readonly workspace?: WorkspaceDefinition\n readonly policy?: SandboxPolicy\n readonly lifecycle?: SandboxLifecycle\n /** Sandbox-scoped file/lifecycle hooks. */\n readonly hooks?: SandboxHooks\n /** Watch the workspace for file events (default true). `false` disables the\n * watcher; `{ diff: true }` also emits a per-file `sandbox.file.diff` event. */\n readonly fileEvents?: boolean | { diff?: boolean }\n /** Compound instance key for a given run context. */\n key: (ctx: SandboxEnsureContext) => string\n /** Resume-or-create the sandbox for this thread/run. */\n ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>\n /** Resume an existing sandbox only. Never creates or restores a sandbox. */\n ensureExisting: (ctx: SandboxEnsureContext) => Promise<SandboxHandle | null>\n /** Tear down the sandbox recorded for this key. */\n destroy: (ctx: SandboxEnsureContext) => Promise<void>\n}\n\nexport type SandboxEnsureOutcome = {\n handle: SandboxHandle\n outcome: 'resumed' | 'native-restored' | 'created'\n}\n\nconst outcomeEnsure = new WeakMap<\n object,\n (ctx: SandboxEnsureContext) => Promise<SandboxEnsureOutcome>\n>()\n\ninterface SandboxEnsureExistingStage {\n key: string\n workspace: WorkspaceDefinition | undefined\n resolvedSecrets: Readonly<Record<string, string>> | undefined\n snapshotMaxAge: string | undefined\n resume: SandboxProvider['resume']\n}\n\nconst existingEnsure = new WeakMap<\n object,\n (\n ctx: SandboxEnsureContext,\n stage?: SandboxEnsureExistingStage,\n ) => Promise<SandboxHandle | null>\n>()\n\nexport function stageEnsureExistingSandbox(\n definition: SandboxDefinition,\n): (\n ctx: SandboxEnsureContext,\n stage: SandboxEnsureExistingStage,\n) => Promise<SandboxHandle | null> {\n const fn = existingEnsure.get(definition)\n if (fn) return (ctx, stage) => fn(ctx, stage)\n const ensureExisting = definition.ensureExisting.bind(definition)\n return (ctx) => ensureExisting(ctx)\n}\n\nexport function ensureSandboxWithOutcome(\n definition: SandboxDefinition,\n ctx: SandboxEnsureContext,\n) {\n const fn = outcomeEnsure.get(definition)\n if (!fn)\n throw new Error(\n 'Sandbox snapshot mode requires a definition created by defineSandbox()',\n )\n return fn(ctx)\n}\n\n/**\n * Parse a human-readable duration string into milliseconds.\n * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).\n * Returns `undefined` when the input is undefined or the format is unrecognised.\n */\nfunction parseMaxAgeMs(value: string | undefined): number | undefined {\n if (value === undefined) return undefined\n const hourMatch = /^(\\d+)h$/.exec(value)\n if (hourMatch) return Number(hourMatch[1]) * 60 * 60 * 1000\n const minuteMatch = /^(\\d+)m$/.exec(value)\n if (minuteMatch) return Number(minuteMatch[1]) * 60 * 1000\n return undefined\n}\n\n/**\n * Bound for the unfenced teardown `destroy` call (see `destroy` below). Long\n * enough that a slow provider API still completes, short enough that a wedged\n * one cannot pin the process forever.\n */\nconst DESTROY_TIMEOUT_MS = 60 * 1000\n\n// Process-lifetime fallbacks shared across all definitions so concurrent\n// ensures for the same key serialize even without an injected store/lock.\nconst fallbackStore = new InMemorySandboxInstanceStore()\nconst fallbackLocks = new InMemoryLockStore()\n\n/**\n * Put workspace secrets onto a live handle. Resume and snapshot restore skip\n * bootstrap, so this is the only path that re-injects them after reconnect.\n * Create injects secrets via `provider.create({ env })`, but resume/restore\n * return a handle whose process env is empty unless we set it here. sbx in\n * particular has no Docker Env on resume, so this is the only way secrets\n * come back for that provider.\n */\nasync function applyWorkspaceSecrets(\n handle: SandboxHandle,\n workspace: WorkspaceDefinition | undefined,\n stagedSecrets?: Readonly<Record<string, string>>,\n): Promise<void> {\n if (workspace?.secrets === undefined) return\n const resolved = stagedSecrets ?? resolveAllSecrets(workspace.secrets)\n if (Object.keys(resolved).length === 0) return\n await handle.env.set(resolved)\n}\n\nexport function defineSandbox(config: SandboxConfig): SandboxDefinition {\n const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({\n threadId:\n config.lifecycle?.reuse === 'none'\n ? `${ctx.threadId}:${ctx.runId}`\n : ctx.threadId,\n sandboxId: config.id,\n providerName: config.provider.name,\n workspace: config.workspace,\n tenant: ctx.tenant,\n })\n\n const ensureWithOutcome = async (\n ctx: SandboxEnsureContext,\n ): Promise<SandboxEnsureOutcome> => {\n const store = ctx.store ?? fallbackStore\n const locks = ctx.locks ?? fallbackLocks\n const key = computeSandboxKey(keyInputFor(ctx))\n const caps = config.provider.capabilities()\n\n return locks.withLock(`sandbox:${key}`, async () => {\n const effectiveSnapshot: SnapshotStrategy =\n config.lifecycle?.snapshot ?? (caps.snapshots ? 'after-setup' : 'none')\n const maxAgeMs = parseMaxAgeMs(config.lifecycle?.snapshotMaxAge)\n\n const existing = await store.get(key)\n if (existing) {\n // Check whether the record has exceeded snapshotMaxAge; if so,\n // discard and fall through to a fresh create.\n const tooOld =\n maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs\n\n if (!tooOld) {\n // 1) Try to reconnect to the still-running sandbox.\n const resumed = await config.provider.resume({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n if (resumed) {\n await applyWorkspaceSecrets(resumed, config.workspace)\n await store.upsert({\n ...existing,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return { handle: resumed, outcome: 'resumed' }\n }\n // 2) Else restore from the latest snapshot, if supported.\n if (\n existing.latestSnapshotId &&\n caps.snapshots &&\n config.provider.restoreSnapshot\n ) {\n const restored = await config.provider.restoreSnapshot({\n snapshotId: existing.latestSnapshotId,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n })\n await applyWorkspaceSecrets(restored, config.workspace)\n await store.upsert({\n ...existing,\n providerSandboxId: restored.id,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return { handle: restored, outcome: 'native-restored' }\n }\n }\n // 3) Else fall through and re-create under the same identity\n // (capability-aware degradation for ephemeral-disk providers, or\n // snapshotMaxAge TTL exceeded).\n }\n\n const created = await config.provider.create({\n // Deterministic id so consumers can reconstruct the provider sandbox\n // address from run context (not just from the store record).\n id: key,\n workspace: config.workspace,\n policy: config.policy,\n env:\n config.workspace?.secrets !== undefined\n ? resolveAllSecrets(config.workspace.secrets)\n : undefined,\n signal: ctx.signal,\n adapterName: ctx.adapterName,\n })\n\n if (config.workspace) {\n try {\n await bootstrapWorkspace(created, config.workspace, {\n signal: ctx.signal,\n })\n } catch (error) {\n // Bootstrap failed after the sandbox was created but before it was\n // recorded — destroy the orphan so a failed/retried run doesn't leak\n // a (billed) sandbox, then surface the original error.\n await created.destroy().catch(() => {})\n throw error\n }\n }\n\n let latestSnapshotId: string | undefined\n if (\n effectiveSnapshot === 'after-setup' &&\n caps.snapshots &&\n created.snapshot\n ) {\n latestSnapshotId = (await created.snapshot('after-setup')).id\n }\n\n await store.upsert({\n key,\n provider: config.provider.name,\n providerSandboxId: created.id,\n latestSnapshotId,\n threadId: ctx.threadId,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return { handle: created, outcome: 'created' }\n })\n }\n\n const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> =>\n (await ensureWithOutcome(ctx)).handle\n\n const ensureExistingWithStage = async (\n ctx: SandboxEnsureContext,\n stage?: SandboxEnsureExistingStage,\n ): Promise<SandboxHandle | null> => {\n const store = ctx.store ?? fallbackStore\n const locks = ctx.locks ?? fallbackLocks\n const key = stage?.key ?? computeSandboxKey(keyInputFor(ctx))\n const workspace = stage?.workspace ?? config.workspace\n const snapshotMaxAge = stage\n ? stage.snapshotMaxAge\n : config.lifecycle?.snapshotMaxAge\n const resume = stage?.resume ?? config.provider.resume.bind(config.provider)\n return locks.withLock(`sandbox:${key}`, async () => {\n const existing = await store.get(key)\n const maxAgeMs = parseMaxAgeMs(snapshotMaxAge)\n if (\n !existing ||\n (maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs)\n )\n return null\n const resumed = await resume({\n id: existing.providerSandboxId,\n signal: ctx.signal,\n })\n if (!resumed) return null\n await applyWorkspaceSecrets(resumed, workspace, stage?.resolvedSecrets)\n await store.upsert({\n ...existing,\n latestRunId: ctx.runId,\n updatedAt: Date.now(),\n })\n return resumed\n })\n }\n\n const ensureExisting = (\n ctx: SandboxEnsureContext,\n ): Promise<SandboxHandle | null> => ensureExistingWithStage(ctx)\n\n const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {\n const store = ctx.store ?? fallbackStore\n const key = computeSandboxKey(keyInputFor(ctx))\n const existing = await store.get(key)\n if (!existing) return\n /*\n * TEARDOWN IS DELIBERATELY NOT FENCED BY `ctx.signal`.\n *\n * `destroy` runs on every teardown path INCLUDING the one caused by that\n * very signal aborting, so forwarding it hands the provider a signal that is\n * already aborted: a provider that honors it does nothing and returns\n * successfully, and `store.delete` below then removes the only pointer to a\n * live, billed sandbox. `SandboxInstanceStore` has no `list` (see the note\n * at the top of `reclaim.ts`), so that sandbox is unreachable from then on.\n *\n * Same reasoning as `close()` never being fenced by the run claim (see\n * `fenceDurability` in `claim.ts`): cleanup must outlive whatever cancelled\n * the work. A fresh controller with its own bounded timeout keeps the call\n * from hanging forever without letting the caller's abort cancel it.\n */\n const teardown = new AbortController()\n const timer = setTimeout(() => teardown.abort(), DESTROY_TIMEOUT_MS)\n try {\n await config.provider.destroy({\n id: existing.providerSandboxId,\n signal: teardown.signal,\n })\n } finally {\n clearTimeout(timer)\n }\n await store.delete(key)\n }\n\n const definition: SandboxDefinition = {\n id: config.id,\n provider: config.provider,\n workspace: config.workspace,\n policy: config.policy,\n lifecycle: config.lifecycle,\n hooks: config.hooks,\n fileEvents: config.fileEvents,\n key: (ctx) => computeSandboxKey(keyInputFor(ctx)),\n ensure,\n ensureExisting,\n destroy,\n }\n outcomeEnsure.set(definition, ensureWithOutcome)\n existingEnsure.set(definition, ensureExistingWithStage)\n return definition\n}\n"],"mappings":"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"}
@@ -0,0 +1,65 @@
1
+ import { ModelMessage } from '@tanstack/ai';
2
+ import { LockStore } from '@tanstack/ai/locks';
3
+ import { SandboxCheckpoint, SandboxCheckpointStore } from './checkpoint-store.js';
4
+ import { SandboxInstanceStore } from './instance-store.js';
5
+ import { SandboxDefinition } from './sandbox.js';
6
+ import { SandboxSnapshotBundle, SandboxSnapshotPolicy } from './snapshots.js';
7
+ export interface SnapshotPersistence {
8
+ stores: {
9
+ messages: {
10
+ loadThread: (threadId: string) => Promise<ReadonlyArray<ModelMessage>>;
11
+ };
12
+ artifacts: NonNullable<SandboxSnapshotBundle['artifacts']>;
13
+ blobs: SandboxSnapshotBundle['blobs'];
14
+ };
15
+ }
16
+ export interface CreateSandboxSnapshotsInput<TPersistence extends SnapshotPersistence = SnapshotPersistence, TCheckpoints extends SandboxCheckpointStore = SandboxCheckpointStore> {
17
+ persistence: TPersistence;
18
+ checkpoints: TCheckpoints;
19
+ policy?: SandboxSnapshotPolicy;
20
+ sandbox?: SandboxDefinition;
21
+ instances?: SandboxInstanceStore;
22
+ tenant?: {
23
+ userId?: string;
24
+ orgId?: string;
25
+ };
26
+ locks?: LockStore;
27
+ }
28
+ export interface SaveSandboxSnapshotInput {
29
+ threadId: string;
30
+ runId: string;
31
+ label: string;
32
+ sandbox?: SandboxDefinition;
33
+ instances?: SandboxInstanceStore;
34
+ tenant?: {
35
+ userId?: string;
36
+ orgId?: string;
37
+ };
38
+ locks?: LockStore;
39
+ signal?: AbortSignal;
40
+ adapterName?: string;
41
+ }
42
+ export interface ForkSandboxSnapshotInput {
43
+ threadId: string;
44
+ checkpointId: string;
45
+ destinationThreadId: string;
46
+ destinationCheckpointId?: string;
47
+ createdAt?: number;
48
+ }
49
+ export interface ReadSandboxSnapshotArtifactInput {
50
+ threadId: string;
51
+ checkpointId: string;
52
+ artifactId: string;
53
+ }
54
+ export interface SandboxSnapshots<TPersistence extends SnapshotPersistence = SnapshotPersistence, TCheckpoints extends SandboxCheckpointStore = SandboxCheckpointStore> {
55
+ persistence: TPersistence;
56
+ checkpoints: TCheckpoints;
57
+ policy?: SandboxSnapshotPolicy;
58
+ save: (input: SaveSandboxSnapshotInput) => Promise<SandboxCheckpoint>;
59
+ fork: (input: ForkSandboxSnapshotInput) => Promise<SandboxCheckpoint>;
60
+ readArtifact: (input: ReadSandboxSnapshotArtifactInput) => Promise<{
61
+ artifact: SandboxCheckpoint['artifacts'][number];
62
+ bytes: Uint8Array;
63
+ }>;
64
+ }
65
+ export declare function createSandboxSnapshots<TPersistence extends SnapshotPersistence, TCheckpoints extends SandboxCheckpointStore>(input: CreateSandboxSnapshotsInput<TPersistence, TCheckpoints>): SandboxSnapshots<TPersistence, TCheckpoints>;