@tanstack/ai-sandbox 0.2.3 → 0.3.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 (158) hide show
  1. package/dist/esm/agents-file.js +53 -34
  2. package/dist/esm/agents-file.js.map +1 -1
  3. package/dist/esm/align.d.ts +121 -0
  4. package/dist/esm/align.js +197 -0
  5. package/dist/esm/align.js.map +1 -0
  6. package/dist/esm/approvals.js +63 -29
  7. package/dist/esm/approvals.js.map +1 -1
  8. package/dist/esm/attach-preflight.d.ts +85 -0
  9. package/dist/esm/attach-preflight.js +189 -0
  10. package/dist/esm/attach-preflight.js.map +1 -0
  11. package/dist/esm/bootstrap.js +103 -117
  12. package/dist/esm/bootstrap.js.map +1 -1
  13. package/dist/esm/bridge-events.js +96 -71
  14. package/dist/esm/bridge-events.js.map +1 -1
  15. package/dist/esm/capabilities.d.ts +0 -5
  16. package/dist/esm/capabilities.js +32 -28
  17. package/dist/esm/capabilities.js.map +1 -1
  18. package/dist/esm/chunk-identity.d.ts +52 -0
  19. package/dist/esm/chunk-identity.js +102 -0
  20. package/dist/esm/chunk-identity.js.map +1 -0
  21. package/dist/esm/claim.d.ts +187 -0
  22. package/dist/esm/claim.js +349 -0
  23. package/dist/esm/claim.js.map +1 -0
  24. package/dist/esm/contracts.d.ts +13 -0
  25. package/dist/esm/driver.d.ts +83 -0
  26. package/dist/esm/driver.js +138 -0
  27. package/dist/esm/driver.js.map +1 -0
  28. package/dist/esm/durability.d.ts +263 -0
  29. package/dist/esm/durability.js +230 -0
  30. package/dist/esm/durability.js.map +1 -0
  31. package/dist/esm/errors.js +28 -24
  32. package/dist/esm/errors.js.map +1 -1
  33. package/dist/esm/file-diff.js +151 -135
  34. package/dist/esm/file-diff.js.map +1 -1
  35. package/dist/esm/git-exec.js +51 -62
  36. package/dist/esm/git-exec.js.map +1 -1
  37. package/dist/esm/harness-cwd.js +24 -19
  38. package/dist/esm/harness-cwd.js.map +1 -1
  39. package/dist/esm/index.d.ts +30 -8
  40. package/dist/esm/index.js +23 -91
  41. package/dist/esm/instance-store.d.ts +88 -0
  42. package/dist/esm/instance-store.js +67 -0
  43. package/dist/esm/instance-store.js.map +1 -0
  44. package/dist/esm/journal-bytes.d.ts +67 -0
  45. package/dist/esm/journal-bytes.js +110 -0
  46. package/dist/esm/journal-bytes.js.map +1 -0
  47. package/dist/esm/journal-reader.d.ts +66 -0
  48. package/dist/esm/journal-reader.js +228 -0
  49. package/dist/esm/journal-reader.js.map +1 -0
  50. package/dist/esm/journal-sweep.d.ts +113 -0
  51. package/dist/esm/journal-sweep.js +309 -0
  52. package/dist/esm/journal-sweep.js.map +1 -0
  53. package/dist/esm/journal.d.ts +542 -0
  54. package/dist/esm/journal.js +679 -0
  55. package/dist/esm/journal.js.map +1 -0
  56. package/dist/esm/key.js +36 -33
  57. package/dist/esm/key.js.map +1 -1
  58. package/dist/esm/middleware.d.ts +50 -2
  59. package/dist/esm/middleware.js +335 -208
  60. package/dist/esm/middleware.js.map +1 -1
  61. package/dist/esm/ngrok.js +75 -49
  62. package/dist/esm/ngrok.js.map +1 -1
  63. package/dist/esm/policy.js +43 -34
  64. package/dist/esm/policy.js.map +1 -1
  65. package/dist/esm/projection.js +16 -8
  66. package/dist/esm/projection.js.map +1 -1
  67. package/dist/esm/reap.d.ts +238 -0
  68. package/dist/esm/reap.js +355 -0
  69. package/dist/esm/reap.js.map +1 -0
  70. package/dist/esm/reclaim.d.ts +84 -0
  71. package/dist/esm/reclaim.js +106 -0
  72. package/dist/esm/reclaim.js.map +1 -0
  73. package/dist/esm/remote-tools.js +73 -62
  74. package/dist/esm/remote-tools.js.map +1 -1
  75. package/dist/esm/run.d.ts +93 -25
  76. package/dist/esm/run.js +274 -79
  77. package/dist/esm/run.js.map +1 -1
  78. package/dist/esm/runner.d.ts +119 -2
  79. package/dist/esm/runner.js +270 -51
  80. package/dist/esm/runner.js.map +1 -1
  81. package/dist/esm/sandbox.d.ts +3 -2
  82. package/dist/esm/sandbox.js +139 -123
  83. package/dist/esm/sandbox.js.map +1 -1
  84. package/dist/esm/secrets.js +39 -47
  85. package/dist/esm/secrets.js.map +1 -1
  86. package/dist/esm/setup-plan.js +22 -14
  87. package/dist/esm/setup-plan.js.map +1 -1
  88. package/dist/esm/shell.d.ts +8 -0
  89. package/dist/esm/shell.js +197 -158
  90. package/dist/esm/shell.js.map +1 -1
  91. package/dist/esm/testkit/conformance.d.ts +16 -0
  92. package/dist/esm/testkit/conformance.js +97 -0
  93. package/dist/esm/testkit/conformance.js.map +1 -0
  94. package/dist/esm/testkit/durable-run-fields-conformance.d.ts +4 -0
  95. package/dist/esm/testkit/durable-run-fields-conformance.js +95 -0
  96. package/dist/esm/testkit/durable-run-fields-conformance.js.map +1 -0
  97. package/dist/esm/testkit/journal-conformance.d.ts +51 -0
  98. package/dist/esm/testkit/journal-conformance.js +378 -0
  99. package/dist/esm/testkit/journal-conformance.js.map +1 -0
  100. package/dist/esm/testkit/reaper-conformance.d.ts +37 -0
  101. package/dist/esm/testkit/reaper-conformance.js +847 -0
  102. package/dist/esm/testkit/reaper-conformance.js.map +1 -0
  103. package/dist/esm/testkit/shell-spawn.d.ts +2 -0
  104. package/dist/esm/testkit/shell-spawn.js +60 -0
  105. package/dist/esm/testkit/shell-spawn.js.map +1 -0
  106. package/dist/esm/testkit/takeover-conformance.d.ts +24 -0
  107. package/dist/esm/testkit/takeover-conformance.js +685 -0
  108. package/dist/esm/testkit/takeover-conformance.js.map +1 -0
  109. package/dist/esm/tool-bridge.js +227 -180
  110. package/dist/esm/tool-bridge.js.map +1 -1
  111. package/dist/esm/tool-history.d.ts +62 -0
  112. package/dist/esm/tool-history.js +171 -0
  113. package/dist/esm/tool-history.js.map +1 -0
  114. package/dist/esm/watch.js +310 -236
  115. package/dist/esm/watch.js.map +1 -1
  116. package/dist/esm/workspace.d.ts +1 -1
  117. package/dist/esm/workspace.js +49 -28
  118. package/dist/esm/workspace.js.map +1 -1
  119. package/package.json +16 -6
  120. package/skills/ai-sandbox/SKILL.md +658 -20
  121. package/src/align.ts +297 -0
  122. package/src/attach-preflight.ts +292 -0
  123. package/src/capabilities.ts +4 -13
  124. package/src/chunk-identity.ts +154 -0
  125. package/src/claim.ts +479 -0
  126. package/src/contracts.ts +13 -0
  127. package/src/driver.ts +205 -0
  128. package/src/durability.ts +380 -0
  129. package/src/index.ts +212 -27
  130. package/src/instance-store.ts +122 -0
  131. package/src/journal-bytes.ts +136 -0
  132. package/src/journal-reader.ts +359 -0
  133. package/src/journal-sweep.ts +406 -0
  134. package/src/journal.ts +875 -0
  135. package/src/middleware.ts +470 -30
  136. package/src/reap.ts +723 -0
  137. package/src/reclaim.ts +191 -0
  138. package/src/run.ts +365 -75
  139. package/src/runner.ts +347 -3
  140. package/src/sandbox.ts +38 -8
  141. package/src/shell.ts +106 -38
  142. package/src/testkit/conformance.ts +117 -0
  143. package/src/testkit/durable-run-fields-conformance.ts +147 -0
  144. package/src/testkit/journal-conformance.ts +676 -0
  145. package/src/testkit/reaper-conformance.ts +1201 -0
  146. package/src/testkit/shell-spawn.ts +67 -0
  147. package/src/testkit/takeover-conformance.ts +1040 -0
  148. package/src/tool-history.ts +245 -0
  149. package/src/workspace.ts +1 -1
  150. package/dist/esm/index.js.map +0 -1
  151. package/dist/esm/run-log.d.ts +0 -81
  152. package/dist/esm/run-log.js +0 -107
  153. package/dist/esm/run-log.js.map +0 -1
  154. package/dist/esm/store.d.ts +0 -53
  155. package/dist/esm/store.js +0 -34
  156. package/dist/esm/store.js.map +0 -1
  157. package/src/run-log.ts +0 -224
  158. package/src/store.ts +0 -83
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.js","sources":["../../src/middleware.ts"],"sourcesContent":["/**\n * `withSandbox(definition)` — 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 optional SandboxStore/Locks\n * capabilities when a persistence middleware supplied them (in-memory\n * fallback otherwise). If `fileEvents` is not false, starts a watcher\n * that dispatches to sandbox-scoped hooks and forwards to the runtime 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 { defineChatMiddleware } from '@tanstack/ai'\nimport { getSandboxRuntime } from '@tanstack/ai/adapter-internals'\nimport {\n LocksCapability,\n SandboxCapability,\n SandboxStoreCapability,\n provideSandbox,\n provideSandboxPolicy,\n} from './capabilities'\nimport { computeWorkspaceHash } from './key'\nimport { buildFileHookEvent, resolveFileEvents } from './file-diff'\nimport { ProjectionCapability, provideWorkspaceProjection } from './projection'\nimport { resolveSecret } from './secrets'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\nimport type { InternalLogger } from '@tanstack/ai/adapter-internals'\nimport type {\n AbortInfo,\n ChatMiddlewareContext,\n DefinedChatMiddleware,\n SandboxFileEvent,\n SandboxFileHookEvent,\n} from '@tanstack/ai'\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 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\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/** 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\nfunction buildEnsureCtx(ctx: ChatMiddlewareContext): SandboxEnsureContext {\n return {\n threadId: ctx.threadId,\n runId: ctx.runId,\n store: ctx.getOptional(SandboxStoreCapability),\n locks: ctx.getOptional(LocksCapability),\n tenant: tenantFrom(ctx.context),\n signal: ctx.signal,\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(\n definition: SandboxDefinition,\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`.\n optionalRequires: [SandboxStoreCapability, LocksCapability],\n\n async setup(ctx) {\n const ensureCtx = buildEnsureCtx(ctx)\n const handle = await definition.ensure(ensureCtx)\n provideSandbox(ctx, handle)\n if (definition.policy) provideSandboxPolicy(ctx, definition.policy)\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 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 root = workspace.root ?? DEFAULT_WORKSPACE_ROOT\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 const pendingDiffs: Array<Promise<void>> = []\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 runState.set(ctx, {\n handle,\n ensureCtx,\n pendingDiffs,\n ...(watcher ? { watcher } : {}),\n ...(logger !== undefined ? { logger } : {}),\n })\n },\n\n async onFinish(ctx) {\n const state = runState.get(ctx)\n if (!state) return\n const { handle, ensureCtx } = state\n\n await drainWatcher(state, 'finish')\n\n const lifecycle = definition.lifecycle\n\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 await drainWatcher(state, 'abort')\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"],"names":[],"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 { resolveSecret } from './secrets'\nimport {\n createToolHistoryRecorder,\n stripObservedToolCalls,\n} from './tool-history'\nimport { watchWorkspace } from './watch'\nimport { DEFAULT_WORKSPACE_ROOT } from './bootstrap'\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 }\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 root = workspace.root ?? DEFAULT_WORKSPACE_ROOT\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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"}
package/dist/esm/ngrok.js CHANGED
@@ -1,54 +1,80 @@
1
- import { defineChatMiddleware } from "@tanstack/ai";
2
1
  import { provideToolBridgeProvisioner } from "./capabilities.js";
3
2
  import { startHostToolBridge } from "./tool-bridge.js";
3
+ import { defineChatMiddleware } from "@tanstack/ai";
4
+ //#region src/ngrok.ts
5
+ /**
6
+ * ngrok-backed tool-bridge provisioner — make the host tool bridge reachable
7
+ * from REMOTE sandboxes (Daytona, Vercel, …) while developing locally.
8
+ *
9
+ * The default bridge ({@link nodeHttpBridgeProvisioner}) binds `localhost`, so
10
+ * only same-machine providers (local-process, Docker) can reach it. A cloud
11
+ * sandbox is a remote VM and can't dial your machine's loopback. This
12
+ * provisioner stands up the normal loopback bridge, opens an ngrok tunnel to its
13
+ * port, and advertises the public `https://…/mcp` URL (with the same per-run
14
+ * bearer token) to the sandbox — so bridged tools / code mode work there too.
15
+ *
16
+ * In PRODUCTION you don't need this: a deployed orchestrator already has a public
17
+ * URL, so a provisioner can advertise that directly (derived from the request).
18
+ * ngrok is the local-dev stand-in for "the orchestrator is reachable".
19
+ *
20
+ * `@ngrok/ngrok` is an OPTIONAL peer dependency — it's loaded lazily, so this
21
+ * subpath imports cleanly without it; only {@link withNgrokBridge} /
22
+ * {@link ngrokBridgeProvisioner} require it at run time. Set `NGROK_AUTHTOKEN`.
23
+ */
24
+ /** Whether ngrok tunnelling is configured (an authtoken is present). */
4
25
  function ngrokConfigured() {
5
- return Boolean(process.env.NGROK_AUTHTOKEN);
26
+ return Boolean(process.env.NGROK_AUTHTOKEN);
6
27
  }
7
- const ngrokBridgeProvisioner = {
8
- async provision(tools, options) {
9
- const { default: ngrok } = await import("@ngrok/ngrok");
10
- const { provider: _provider, ...core } = options;
11
- const bridge = await startHostToolBridge(tools, {
12
- hostForSandbox: "127.0.0.1",
13
- bindAddress: "127.0.0.1",
14
- ...core
15
- });
16
- try {
17
- const port = Number(new URL(bridge.url).port);
18
- const listener = await ngrok.forward({
19
- addr: port,
20
- authtoken_from_env: true
21
- });
22
- const publicUrl = listener.url();
23
- if (!publicUrl) {
24
- throw new Error("ngrok did not return a public URL");
25
- }
26
- return {
27
- ...bridge,
28
- url: `${publicUrl}/mcp`,
29
- close: async () => {
30
- try {
31
- await listener.close();
32
- } finally {
33
- await bridge.close();
34
- }
35
- }
36
- };
37
- } catch (error) {
38
- await bridge.close();
39
- throw error;
40
- }
41
- }
42
- };
43
- const withNgrokBridge = defineChatMiddleware({
44
- name: "ngrok-bridge",
45
- setup(ctx) {
46
- provideToolBridgeProvisioner(ctx, ngrokBridgeProvisioner);
47
- }
28
+ /**
29
+ * A {@link ToolBridgeProvisioner} that tunnels the loopback bridge through ngrok
30
+ * (one ephemeral tunnel per run; both are torn down together). Requires the
31
+ * optional `@ngrok/ngrok` peer dependency and `NGROK_AUTHTOKEN`.
32
+ */
33
+ var ngrokBridgeProvisioner = { async provision(tools, options) {
34
+ const { default: ngrok } = await import("@ngrok/ngrok");
35
+ const { provider: _provider, ...core } = options;
36
+ const bridge = await startHostToolBridge(tools, {
37
+ hostForSandbox: "127.0.0.1",
38
+ bindAddress: "127.0.0.1",
39
+ ...core
40
+ });
41
+ try {
42
+ const port = Number(new URL(bridge.url).port);
43
+ const listener = await ngrok.forward({
44
+ addr: port,
45
+ authtoken_from_env: true
46
+ });
47
+ const publicUrl = listener.url();
48
+ if (!publicUrl) throw new Error("ngrok did not return a public URL");
49
+ return {
50
+ ...bridge,
51
+ url: `${publicUrl}/mcp`,
52
+ close: async () => {
53
+ try {
54
+ await listener.close();
55
+ } finally {
56
+ await bridge.close();
57
+ }
58
+ }
59
+ };
60
+ } catch (error) {
61
+ await bridge.close();
62
+ throw error;
63
+ }
64
+ } };
65
+ /**
66
+ * Chat middleware that routes the tool bridge through ngrok. Add it AFTER
67
+ * `withSandbox(...)` for cloud providers so the in-sandbox harness can reach the
68
+ * host tools. Not needed for local-process / Docker (they reach the bridge
69
+ * directly) — just don't add it there.
70
+ */
71
+ var withNgrokBridge = defineChatMiddleware({
72
+ name: "ngrok-bridge",
73
+ setup(ctx) {
74
+ provideToolBridgeProvisioner(ctx, ngrokBridgeProvisioner);
75
+ }
48
76
  });
49
- export {
50
- ngrokBridgeProvisioner,
51
- ngrokConfigured,
52
- withNgrokBridge
53
- };
54
- //# sourceMappingURL=ngrok.js.map
77
+ //#endregion
78
+ export { ngrokBridgeProvisioner, ngrokConfigured, withNgrokBridge };
79
+
80
+ //# sourceMappingURL=ngrok.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ngrok.js","sources":["../../src/ngrok.ts"],"sourcesContent":["/**\n * ngrok-backed tool-bridge provisioner — make the host tool bridge reachable\n * from REMOTE sandboxes (Daytona, Vercel, …) while developing locally.\n *\n * The default bridge ({@link nodeHttpBridgeProvisioner}) binds `localhost`, so\n * only same-machine providers (local-process, Docker) can reach it. A cloud\n * sandbox is a remote VM and can't dial your machine's loopback. This\n * provisioner stands up the normal loopback bridge, opens an ngrok tunnel to its\n * port, and advertises the public `https://…/mcp` URL (with the same per-run\n * bearer token) to the sandbox — so bridged tools / code mode work there too.\n *\n * In PRODUCTION you don't need this: a deployed orchestrator already has a public\n * URL, so a provisioner can advertise that directly (derived from the request).\n * ngrok is the local-dev stand-in for \"the orchestrator is reachable\".\n *\n * `@ngrok/ngrok` is an OPTIONAL peer dependency — it's loaded lazily, so this\n * subpath imports cleanly without it; only {@link withNgrokBridge} /\n * {@link ngrokBridgeProvisioner} require it at run time. Set `NGROK_AUTHTOKEN`.\n */\nimport { defineChatMiddleware } from '@tanstack/ai'\nimport { provideToolBridgeProvisioner } from './capabilities'\nimport { startHostToolBridge } from './tool-bridge'\nimport type { ToolBridgeProvisioner } from './tool-bridge'\n\n/** Whether ngrok tunnelling is configured (an authtoken is present). */\nexport function ngrokConfigured(): boolean {\n return Boolean(process.env.NGROK_AUTHTOKEN)\n}\n\n/**\n * A {@link ToolBridgeProvisioner} that tunnels the loopback bridge through ngrok\n * (one ephemeral tunnel per run; both are torn down together). Requires the\n * optional `@ngrok/ngrok` peer dependency and `NGROK_AUTHTOKEN`.\n */\nexport const ngrokBridgeProvisioner: ToolBridgeProvisioner = {\n async provision(tools, options) {\n // Lazy + optional: only needed when this provisioner actually runs.\n const { default: ngrok } = await import('@ngrok/ngrok')\n const { provider: _provider, ...core } = options\n const bridge = await startHostToolBridge(tools, {\n hostForSandbox: '127.0.0.1',\n bindAddress: '127.0.0.1',\n ...core,\n })\n try {\n const port = Number(new URL(bridge.url).port)\n const listener = await ngrok.forward({\n addr: port,\n authtoken_from_env: true,\n })\n const publicUrl = listener.url()\n if (!publicUrl) {\n throw new Error('ngrok did not return a public URL')\n }\n return {\n ...bridge,\n url: `${publicUrl}/mcp`,\n close: async () => {\n try {\n await listener.close()\n } finally {\n await bridge.close()\n }\n },\n }\n } catch (error) {\n // Don't leak the loopback bridge if the tunnel couldn't be opened.\n await bridge.close()\n throw error\n }\n },\n}\n\n/**\n * Chat middleware that routes the tool bridge through ngrok. Add it AFTER\n * `withSandbox(...)` for cloud providers so the in-sandbox harness can reach the\n * host tools. Not needed for local-process / Docker (they reach the bridge\n * directly) — just don't add it there.\n */\nexport const withNgrokBridge = defineChatMiddleware({\n name: 'ngrok-bridge',\n setup(ctx) {\n provideToolBridgeProvisioner(ctx, ngrokBridgeProvisioner)\n },\n})\n"],"names":[],"mappings":";;;AAyBO,SAAS,kBAA2B;AACzC,SAAO,QAAQ,QAAQ,IAAI,eAAe;AAC5C;AAOO,MAAM,yBAAgD;AAAA,EAC3D,MAAM,UAAU,OAAO,SAAS;AAE9B,UAAM,EAAE,SAAS,UAAU,MAAM,OAAO,cAAc;AACtD,UAAM,EAAE,UAAU,WAAW,GAAG,SAAS;AACzC,UAAM,SAAS,MAAM,oBAAoB,OAAO;AAAA,MAC9C,gBAAgB;AAAA,MAChB,aAAa;AAAA,MACb,GAAG;AAAA,IAAA,CACJ;AACD,QAAI;AACF,YAAM,OAAO,OAAO,IAAI,IAAI,OAAO,GAAG,EAAE,IAAI;AAC5C,YAAM,WAAW,MAAM,MAAM,QAAQ;AAAA,QACnC,MAAM;AAAA,QACN,oBAAoB;AAAA,MAAA,CACrB;AACD,YAAM,YAAY,SAAS,IAAA;AAC3B,UAAI,CAAC,WAAW;AACd,cAAM,IAAI,MAAM,mCAAmC;AAAA,MACrD;AACA,aAAO;AAAA,QACL,GAAG;AAAA,QACH,KAAK,GAAG,SAAS;AAAA,QACjB,OAAO,YAAY;AACjB,cAAI;AACF,kBAAM,SAAS,MAAA;AAAA,UACjB,UAAA;AACE,kBAAM,OAAO,MAAA;AAAA,UACf;AAAA,QACF;AAAA,MAAA;AAAA,IAEJ,SAAS,OAAO;AAEd,YAAM,OAAO,MAAA;AACb,YAAM;AAAA,IACR;AAAA,EACF;AACF;AAQO,MAAM,kBAAkB,qBAAqB;AAAA,EAClD,MAAM;AAAA,EACN,MAAM,KAAK;AACT,iCAA6B,KAAK,sBAAsB;AAAA,EAC1D;AACF,CAAC;"}
1
+ {"version":3,"file":"ngrok.js","names":[],"sources":["../../src/ngrok.ts"],"sourcesContent":["/**\n * ngrok-backed tool-bridge provisioner — make the host tool bridge reachable\n * from REMOTE sandboxes (Daytona, Vercel, …) while developing locally.\n *\n * The default bridge ({@link nodeHttpBridgeProvisioner}) binds `localhost`, so\n * only same-machine providers (local-process, Docker) can reach it. A cloud\n * sandbox is a remote VM and can't dial your machine's loopback. This\n * provisioner stands up the normal loopback bridge, opens an ngrok tunnel to its\n * port, and advertises the public `https://…/mcp` URL (with the same per-run\n * bearer token) to the sandbox — so bridged tools / code mode work there too.\n *\n * In PRODUCTION you don't need this: a deployed orchestrator already has a public\n * URL, so a provisioner can advertise that directly (derived from the request).\n * ngrok is the local-dev stand-in for \"the orchestrator is reachable\".\n *\n * `@ngrok/ngrok` is an OPTIONAL peer dependency — it's loaded lazily, so this\n * subpath imports cleanly without it; only {@link withNgrokBridge} /\n * {@link ngrokBridgeProvisioner} require it at run time. Set `NGROK_AUTHTOKEN`.\n */\nimport { defineChatMiddleware } from '@tanstack/ai'\nimport { provideToolBridgeProvisioner } from './capabilities'\nimport { startHostToolBridge } from './tool-bridge'\nimport type { ToolBridgeProvisioner } from './tool-bridge'\n\n/** Whether ngrok tunnelling is configured (an authtoken is present). */\nexport function ngrokConfigured(): boolean {\n return Boolean(process.env.NGROK_AUTHTOKEN)\n}\n\n/**\n * A {@link ToolBridgeProvisioner} that tunnels the loopback bridge through ngrok\n * (one ephemeral tunnel per run; both are torn down together). Requires the\n * optional `@ngrok/ngrok` peer dependency and `NGROK_AUTHTOKEN`.\n */\nexport const ngrokBridgeProvisioner: ToolBridgeProvisioner = {\n async provision(tools, options) {\n // Lazy + optional: only needed when this provisioner actually runs.\n const { default: ngrok } = await import('@ngrok/ngrok')\n const { provider: _provider, ...core } = options\n const bridge = await startHostToolBridge(tools, {\n hostForSandbox: '127.0.0.1',\n bindAddress: '127.0.0.1',\n ...core,\n })\n try {\n const port = Number(new URL(bridge.url).port)\n const listener = await ngrok.forward({\n addr: port,\n authtoken_from_env: true,\n })\n const publicUrl = listener.url()\n if (!publicUrl) {\n throw new Error('ngrok did not return a public URL')\n }\n return {\n ...bridge,\n url: `${publicUrl}/mcp`,\n close: async () => {\n try {\n await listener.close()\n } finally {\n await bridge.close()\n }\n },\n }\n } catch (error) {\n // Don't leak the loopback bridge if the tunnel couldn't be opened.\n await bridge.close()\n throw error\n }\n },\n}\n\n/**\n * Chat middleware that routes the tool bridge through ngrok. Add it AFTER\n * `withSandbox(...)` for cloud providers so the in-sandbox harness can reach the\n * host tools. Not needed for local-process / Docker (they reach the bridge\n * directly) — just don't add it there.\n */\nexport const withNgrokBridge = defineChatMiddleware({\n name: 'ngrok-bridge',\n setup(ctx) {\n provideToolBridgeProvisioner(ctx, ngrokBridgeProvisioner)\n },\n})\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,kBAA2B;CACzC,OAAO,QAAQ,QAAQ,IAAI,eAAe;AAC5C;;;;;;AAOA,IAAa,yBAAgD,EAC3D,MAAM,UAAU,OAAO,SAAS;CAE9B,MAAM,EAAE,SAAS,UAAU,MAAM,OAAO;CACxC,MAAM,EAAE,UAAU,WAAW,GAAG,SAAS;CACzC,MAAM,SAAS,MAAM,oBAAoB,OAAO;EAC9C,gBAAgB;EAChB,aAAa;EACb,GAAG;CACL,CAAC;CACD,IAAI;EACF,MAAM,OAAO,OAAO,IAAI,IAAI,OAAO,GAAG,CAAC,CAAC,IAAI;EAC5C,MAAM,WAAW,MAAM,MAAM,QAAQ;GACnC,MAAM;GACN,oBAAoB;EACtB,CAAC;EACD,MAAM,YAAY,SAAS,IAAI;EAC/B,IAAI,CAAC,WACH,MAAM,IAAI,MAAM,mCAAmC;EAErD,OAAO;GACL,GAAG;GACH,KAAK,GAAG,UAAU;GAClB,OAAO,YAAY;IACjB,IAAI;KACF,MAAM,SAAS,MAAM;IACvB,UAAU;KACR,MAAM,OAAO,MAAM;IACrB;GACF;EACF;CACF,SAAS,OAAO;EAEd,MAAM,OAAO,MAAM;EACnB,MAAM;CACR;AACF,EACF;;;;;;;AAQA,IAAa,kBAAkB,qBAAqB;CAClD,MAAM;CACN,MAAM,KAAK;EACT,6BAA6B,KAAK,sBAAsB;CAC1D;AACF,CAAC"}
@@ -1,44 +1,53 @@
1
+ //#region src/policy.ts
1
2
  function defineSandboxPolicy(policy) {
2
- return policy;
3
+ return policy;
3
4
  }
5
+ /** Convert a glob/prefix pattern to a RegExp anchored to the full command. */
4
6
  function patternToRegExp(pattern) {
5
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
6
- return new RegExp(`^${escaped}$`);
7
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
8
+ return new RegExp(`^${escaped}$`);
7
9
  }
10
+ /**
11
+ * All equivalent forms of a command line when workspace scripts are defined:
12
+ * the literal command, its expanded script value, and any script name that
13
+ * expands to the same value.
14
+ */
8
15
  function commandAliases(command, scripts) {
9
- const trimmed = command.trim();
10
- const aliases = /* @__PURE__ */ new Set([trimmed]);
11
- if (scripts === void 0) return [...aliases];
12
- const expanded = scripts[trimmed];
13
- if (expanded !== void 0) aliases.add(expanded);
14
- for (const [name, value] of Object.entries(scripts)) {
15
- if (value === trimmed) aliases.add(name);
16
- }
17
- return [...aliases];
16
+ const trimmed = command.trim();
17
+ const aliases = /* @__PURE__ */ new Set([trimmed]);
18
+ if (scripts === void 0) return [...aliases];
19
+ const expanded = scripts[trimmed];
20
+ if (expanded !== void 0) aliases.add(expanded);
21
+ for (const [name, value] of Object.entries(scripts)) if (value === trimmed) aliases.add(name);
22
+ return [...aliases];
18
23
  }
19
24
  function patternMatchesCommand(pattern, command, scripts) {
20
- const commandForms = commandAliases(command, scripts);
21
- for (const patternForm of commandAliases(pattern, scripts)) {
22
- const re = patternToRegExp(patternForm);
23
- if (commandForms.some((form) => re.test(form))) return true;
24
- }
25
- return false;
25
+ const commandForms = commandAliases(command, scripts);
26
+ for (const patternForm of commandAliases(pattern, scripts)) {
27
+ const re = patternToRegExp(patternForm);
28
+ if (commandForms.some((form) => re.test(form))) return true;
29
+ }
30
+ return false;
26
31
  }
32
+ /**
33
+ * Resolve a command line against the policy. Precedence: deny > ask > allow,
34
+ * then `default` (defaults to `'ask'`). Exported for adapter permission
35
+ * mappers and unit tests.
36
+ *
37
+ * When `scripts` is provided, policy patterns may match either a script name
38
+ * or its expanded command value (and vice versa for the command under test).
39
+ */
27
40
  function evaluateCommand(command, policy, scripts) {
28
- const fallback = policy?.default ?? "ask";
29
- const rules = policy?.commands;
30
- if (!rules) return fallback;
31
- const matches = (patterns) => (patterns ?? []).some(
32
- (pattern) => patternMatchesCommand(pattern, command, scripts)
33
- );
34
- if (matches(rules.deny)) return "deny";
35
- if (matches(rules.ask)) return "ask";
36
- if (matches(rules.allow)) return "allow";
37
- return fallback;
41
+ const fallback = policy?.default ?? "ask";
42
+ const rules = policy?.commands;
43
+ if (!rules) return fallback;
44
+ const matches = (patterns) => (patterns ?? []).some((pattern) => patternMatchesCommand(pattern, command, scripts));
45
+ if (matches(rules.deny)) return "deny";
46
+ if (matches(rules.ask)) return "ask";
47
+ if (matches(rules.allow)) return "allow";
48
+ return fallback;
38
49
  }
39
- export {
40
- commandAliases,
41
- defineSandboxPolicy,
42
- evaluateCommand
43
- };
44
- //# sourceMappingURL=policy.js.map
50
+ //#endregion
51
+ export { commandAliases, defineSandboxPolicy, evaluateCommand };
52
+
53
+ //# sourceMappingURL=policy.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"policy.js","sources":["../../src/policy.ts"],"sourcesContent":["/**\n * Sandbox policy — a portable, harness-agnostic description of what the agent\n * may do. Each harness adapter MAPS this onto its native permission system\n * (Claude Code → canUseTool + allowedTools/disallowedTools/permissionMode).\n *\n * Command rules are matched as glob/prefix patterns against the command line.\n * Precedence is deny > ask > allow; unmatched commands fall to `default`.\n * `'ask'` surfaces the existing resume-based `approval-requested` flow.\n */\n\nexport type PolicyDecision = 'allow' | 'ask' | 'deny'\n\nexport interface CommandRules {\n /** Glob/prefix patterns to allow outright (e.g. 'pnpm *', 'git diff'). */\n allow?: Array<string>\n /** Patterns that require approval before running. */\n ask?: Array<string>\n /** Patterns to refuse (e.g. 'sudo *', 'rm -rf *'). */\n deny?: Array<string>\n}\n\n/** Coarse, non-command capability gates for tools like Write/Edit and network. */\nexport interface CapabilityRules {\n /** File-modifying tools (Write/Edit). Defaults to the policy `default`. */\n fileWrite?: PolicyDecision\n /** Outbound network access. Defaults to the policy `default`. */\n network?: PolicyDecision\n}\n\nexport interface SandboxPolicy {\n commands?: CommandRules\n capabilities?: CapabilityRules\n /** Decision for anything not matched by a rule. Defaults to `'ask'`. */\n default?: PolicyDecision\n}\n\nexport function defineSandboxPolicy(policy: SandboxPolicy): SandboxPolicy {\n return policy\n}\n\n/** Convert a glob/prefix pattern to a RegExp anchored to the full command. */\nfunction patternToRegExp(pattern: string): RegExp {\n // Escape regex metacharacters except '*', then turn '*' into '.*'.\n const escaped = pattern\n .replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&')\n .replace(/\\*/g, '.*')\n return new RegExp(`^${escaped}$`)\n}\n\n/**\n * All equivalent forms of a command line when workspace scripts are defined:\n * the literal command, its expanded script value, and any script name that\n * expands to the same value.\n */\nexport function commandAliases(\n command: string,\n scripts: Record<string, string> | undefined,\n): Array<string> {\n const trimmed = command.trim()\n const aliases = new Set<string>([trimmed])\n if (scripts === undefined) return [...aliases]\n\n const expanded = scripts[trimmed]\n if (expanded !== undefined) aliases.add(expanded)\n\n for (const [name, value] of Object.entries(scripts)) {\n if (value === trimmed) aliases.add(name)\n }\n return [...aliases]\n}\n\nfunction patternMatchesCommand(\n pattern: string,\n command: string,\n scripts: Record<string, string> | undefined,\n): boolean {\n const commandForms = commandAliases(command, scripts)\n for (const patternForm of commandAliases(pattern, scripts)) {\n const re = patternToRegExp(patternForm)\n if (commandForms.some((form) => re.test(form))) return true\n }\n return false\n}\n\n/**\n * Resolve a command line against the policy. Precedence: deny > ask > allow,\n * then `default` (defaults to `'ask'`). Exported for adapter permission\n * mappers and unit tests.\n *\n * When `scripts` is provided, policy patterns may match either a script name\n * or its expanded command value (and vice versa for the command under test).\n */\nexport function evaluateCommand(\n command: string,\n policy: SandboxPolicy | undefined,\n scripts?: Record<string, string>,\n): PolicyDecision {\n const fallback = policy?.default ?? 'ask'\n const rules = policy?.commands\n if (!rules) return fallback\n\n const matches = (patterns: Array<string> | undefined): boolean =>\n (patterns ?? []).some((pattern) =>\n patternMatchesCommand(pattern, command, scripts),\n )\n\n if (matches(rules.deny)) return 'deny'\n if (matches(rules.ask)) return 'ask'\n if (matches(rules.allow)) return 'allow'\n return fallback\n}\n"],"names":[],"mappings":"AAoCO,SAAS,oBAAoB,QAAsC;AACxE,SAAO;AACT;AAGA,SAAS,gBAAgB,SAAyB;AAEhD,QAAM,UAAU,QACb,QAAQ,sBAAsB,MAAM,EACpC,QAAQ,OAAO,IAAI;AACtB,SAAO,IAAI,OAAO,IAAI,OAAO,GAAG;AAClC;AAOO,SAAS,eACd,SACA,SACe;AACf,QAAM,UAAU,QAAQ,KAAA;AACxB,QAAM,UAAU,oBAAI,IAAY,CAAC,OAAO,CAAC;AACzC,MAAI,YAAY,OAAW,QAAO,CAAC,GAAG,OAAO;AAE7C,QAAM,WAAW,QAAQ,OAAO;AAChC,MAAI,aAAa,OAAW,SAAQ,IAAI,QAAQ;AAEhD,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AACnD,QAAI,UAAU,QAAS,SAAQ,IAAI,IAAI;AAAA,EACzC;AACA,SAAO,CAAC,GAAG,OAAO;AACpB;AAEA,SAAS,sBACP,SACA,SACA,SACS;AACT,QAAM,eAAe,eAAe,SAAS,OAAO;AACpD,aAAW,eAAe,eAAe,SAAS,OAAO,GAAG;AAC1D,UAAM,KAAK,gBAAgB,WAAW;AACtC,QAAI,aAAa,KAAK,CAAC,SAAS,GAAG,KAAK,IAAI,CAAC,EAAG,QAAO;AAAA,EACzD;AACA,SAAO;AACT;AAUO,SAAS,gBACd,SACA,QACA,SACgB;AAChB,QAAM,WAAW,QAAQ,WAAW;AACpC,QAAM,QAAQ,QAAQ;AACtB,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,UAAU,CAAC,cACd,YAAY,CAAA,GAAI;AAAA,IAAK,CAAC,YACrB,sBAAsB,SAAS,SAAS,OAAO;AAAA,EAAA;AAGnD,MAAI,QAAQ,MAAM,IAAI,EAAG,QAAO;AAChC,MAAI,QAAQ,MAAM,GAAG,EAAG,QAAO;AAC/B,MAAI,QAAQ,MAAM,KAAK,EAAG,QAAO;AACjC,SAAO;AACT;"}
1
+ {"version":3,"file":"policy.js","names":[],"sources":["../../src/policy.ts"],"sourcesContent":["/**\n * Sandbox policy — a portable, harness-agnostic description of what the agent\n * may do. Each harness adapter MAPS this onto its native permission system\n * (Claude Code → canUseTool + allowedTools/disallowedTools/permissionMode).\n *\n * Command rules are matched as glob/prefix patterns against the command line.\n * Precedence is deny > ask > allow; unmatched commands fall to `default`.\n * `'ask'` surfaces the existing resume-based `approval-requested` flow.\n */\n\nexport type PolicyDecision = 'allow' | 'ask' | 'deny'\n\nexport interface CommandRules {\n /** Glob/prefix patterns to allow outright (e.g. 'pnpm *', 'git diff'). */\n allow?: Array<string>\n /** Patterns that require approval before running. */\n ask?: Array<string>\n /** Patterns to refuse (e.g. 'sudo *', 'rm -rf *'). */\n deny?: Array<string>\n}\n\n/** Coarse, non-command capability gates for tools like Write/Edit and network. */\nexport interface CapabilityRules {\n /** File-modifying tools (Write/Edit). Defaults to the policy `default`. */\n fileWrite?: PolicyDecision\n /** Outbound network access. Defaults to the policy `default`. */\n network?: PolicyDecision\n}\n\nexport interface SandboxPolicy {\n commands?: CommandRules\n capabilities?: CapabilityRules\n /** Decision for anything not matched by a rule. Defaults to `'ask'`. */\n default?: PolicyDecision\n}\n\nexport function defineSandboxPolicy(policy: SandboxPolicy): SandboxPolicy {\n return policy\n}\n\n/** Convert a glob/prefix pattern to a RegExp anchored to the full command. */\nfunction patternToRegExp(pattern: string): RegExp {\n // Escape regex metacharacters except '*', then turn '*' into '.*'.\n const escaped = pattern\n .replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&')\n .replace(/\\*/g, '.*')\n return new RegExp(`^${escaped}$`)\n}\n\n/**\n * All equivalent forms of a command line when workspace scripts are defined:\n * the literal command, its expanded script value, and any script name that\n * expands to the same value.\n */\nexport function commandAliases(\n command: string,\n scripts: Record<string, string> | undefined,\n): Array<string> {\n const trimmed = command.trim()\n const aliases = new Set<string>([trimmed])\n if (scripts === undefined) return [...aliases]\n\n const expanded = scripts[trimmed]\n if (expanded !== undefined) aliases.add(expanded)\n\n for (const [name, value] of Object.entries(scripts)) {\n if (value === trimmed) aliases.add(name)\n }\n return [...aliases]\n}\n\nfunction patternMatchesCommand(\n pattern: string,\n command: string,\n scripts: Record<string, string> | undefined,\n): boolean {\n const commandForms = commandAliases(command, scripts)\n for (const patternForm of commandAliases(pattern, scripts)) {\n const re = patternToRegExp(patternForm)\n if (commandForms.some((form) => re.test(form))) return true\n }\n return false\n}\n\n/**\n * Resolve a command line against the policy. Precedence: deny > ask > allow,\n * then `default` (defaults to `'ask'`). Exported for adapter permission\n * mappers and unit tests.\n *\n * When `scripts` is provided, policy patterns may match either a script name\n * or its expanded command value (and vice versa for the command under test).\n */\nexport function evaluateCommand(\n command: string,\n policy: SandboxPolicy | undefined,\n scripts?: Record<string, string>,\n): PolicyDecision {\n const fallback = policy?.default ?? 'ask'\n const rules = policy?.commands\n if (!rules) return fallback\n\n const matches = (patterns: Array<string> | undefined): boolean =>\n (patterns ?? []).some((pattern) =>\n patternMatchesCommand(pattern, command, scripts),\n )\n\n if (matches(rules.deny)) return 'deny'\n if (matches(rules.ask)) return 'ask'\n if (matches(rules.allow)) return 'allow'\n return fallback\n}\n"],"mappings":";AAoCA,SAAgB,oBAAoB,QAAsC;CACxE,OAAO;AACT;;AAGA,SAAS,gBAAgB,SAAyB;CAEhD,MAAM,UAAU,QACb,QAAQ,sBAAsB,MAAM,CAAC,CACrC,QAAQ,OAAO,IAAI;CACtB,OAAO,IAAI,OAAO,IAAI,QAAQ,EAAE;AAClC;;;;;;AAOA,SAAgB,eACd,SACA,SACe;CACf,MAAM,UAAU,QAAQ,KAAK;CAC7B,MAAM,0BAAU,IAAI,IAAY,CAAC,OAAO,CAAC;CACzC,IAAI,YAAY,KAAA,GAAW,OAAO,CAAC,GAAG,OAAO;CAE7C,MAAM,WAAW,QAAQ;CACzB,IAAI,aAAa,KAAA,GAAW,QAAQ,IAAI,QAAQ;CAEhD,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,OAAO,GAChD,IAAI,UAAU,SAAS,QAAQ,IAAI,IAAI;CAEzC,OAAO,CAAC,GAAG,OAAO;AACpB;AAEA,SAAS,sBACP,SACA,SACA,SACS;CACT,MAAM,eAAe,eAAe,SAAS,OAAO;CACpD,KAAK,MAAM,eAAe,eAAe,SAAS,OAAO,GAAG;EAC1D,MAAM,KAAK,gBAAgB,WAAW;EACtC,IAAI,aAAa,MAAM,SAAS,GAAG,KAAK,IAAI,CAAC,GAAG,OAAO;CACzD;CACA,OAAO;AACT;;;;;;;;;AAUA,SAAgB,gBACd,SACA,QACA,SACgB;CAChB,MAAM,WAAW,QAAQ,WAAW;CACpC,MAAM,QAAQ,QAAQ;CACtB,IAAI,CAAC,OAAO,OAAO;CAEnB,MAAM,WAAW,cACd,YAAY,CAAC,EAAA,CAAG,MAAM,YACrB,sBAAsB,SAAS,SAAS,OAAO,CACjD;CAEF,IAAI,QAAQ,MAAM,IAAI,GAAG,OAAO;CAChC,IAAI,QAAQ,MAAM,GAAG,GAAG,OAAO;CAC/B,IAAI,QAAQ,MAAM,KAAK,GAAG,OAAO;CACjC,OAAO;AACT"}
@@ -1,9 +1,17 @@
1
1
  import { createCapability } from "@tanstack/ai";
2
- const ProjectionCapability = createCapability()("sandbox-projection");
3
- const [getWorkspaceProjection, provideWorkspaceProjection] = ProjectionCapability;
4
- export {
5
- ProjectionCapability,
6
- getWorkspaceProjection,
7
- provideWorkspaceProjection
8
- };
9
- //# sourceMappingURL=projection.js.map
2
+ //#region src/projection.ts
3
+ /**
4
+ * Workspace projection capability — provided by `withSandbox` and consumed by
5
+ * harness adapters (claude-code, codex, opencode) to idempotently
6
+ * project skills, plugins, and resolved secrets into the native harness format.
7
+ *
8
+ * The capability carries the raw provisioning inputs (skills, plugins, a
9
+ * resolve function for secret refs) together with a marker path that lets
10
+ * adapters guard the projection with a one-time idempotency file.
11
+ */
12
+ var ProjectionCapability = createCapability()("sandbox-projection");
13
+ var [getWorkspaceProjection, provideWorkspaceProjection] = ProjectionCapability;
14
+ //#endregion
15
+ export { ProjectionCapability, getWorkspaceProjection, provideWorkspaceProjection };
16
+
17
+ //# sourceMappingURL=projection.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"projection.js","sources":["../../src/projection.ts"],"sourcesContent":["/**\n * Workspace projection capability — provided by `withSandbox` and consumed by\n * harness adapters (claude-code, codex, opencode) to idempotently\n * project skills, plugins, and resolved secrets into the native harness format.\n *\n * The capability carries the raw provisioning inputs (skills, plugins, a\n * resolve function for secret refs) together with a marker path that lets\n * adapters guard the projection with a one-time idempotency file.\n */\nimport { createCapability } from '@tanstack/ai'\nimport type { SecretRef } from './secrets'\nimport type { WorkspaceSkill } from './workspace'\n\n/**\n * The shape provided to harness adapters via the sandbox projection capability.\n * Harness adapters read this in their `chatStream` setup to project workspace\n * inputs into their native format (MCP config, skills dirs, plugin installs).\n */\nexport interface WorkspaceProjection {\n /** Skills declared on the workspace — MCP servers, file skills, git repos, etc. */\n skills: Array<WorkspaceSkill>\n /** Harness plugin identifiers to install idempotently. */\n plugins: Array<string>\n /**\n * Resolve a SecretRef to its plaintext value. Bound to the workspace's\n * secrets registry; throws when the ref is unknown.\n */\n resolveSecret: (ref: SecretRef) => string\n /**\n * Absolute path to the idempotency marker file. Harness adapters write this\n * file after a successful projection so subsequent runs skip re-projection.\n * The file is NOT included in snapshots — absent on restore, triggering\n * re-projection (which re-writes any secret-bearing config files).\n */\n markerPath: string\n /** Workspace root inside the sandbox (e.g. `/workspace`). */\n root: string\n /** Named commands declared on the workspace (e.g. `{ test: 'pnpm test' }`). */\n scripts?: Record<string, string>\n}\n\nexport const ProjectionCapability =\n createCapability<WorkspaceProjection>()('sandbox-projection')\n\nexport const [getWorkspaceProjection, provideWorkspaceProjection] =\n ProjectionCapability\n"],"names":[],"mappings":";AAyCO,MAAM,uBACX,iBAAA,EAAwC,oBAAoB;AAEvD,MAAM,CAAC,wBAAwB,0BAA0B,IAC9D;"}
1
+ {"version":3,"file":"projection.js","names":[],"sources":["../../src/projection.ts"],"sourcesContent":["/**\n * Workspace projection capability — provided by `withSandbox` and consumed by\n * harness adapters (claude-code, codex, opencode) to idempotently\n * project skills, plugins, and resolved secrets into the native harness format.\n *\n * The capability carries the raw provisioning inputs (skills, plugins, a\n * resolve function for secret refs) together with a marker path that lets\n * adapters guard the projection with a one-time idempotency file.\n */\nimport { createCapability } from '@tanstack/ai'\nimport type { SecretRef } from './secrets'\nimport type { WorkspaceSkill } from './workspace'\n\n/**\n * The shape provided to harness adapters via the sandbox projection capability.\n * Harness adapters read this in their `chatStream` setup to project workspace\n * inputs into their native format (MCP config, skills dirs, plugin installs).\n */\nexport interface WorkspaceProjection {\n /** Skills declared on the workspace — MCP servers, file skills, git repos, etc. */\n skills: Array<WorkspaceSkill>\n /** Harness plugin identifiers to install idempotently. */\n plugins: Array<string>\n /**\n * Resolve a SecretRef to its plaintext value. Bound to the workspace's\n * secrets registry; throws when the ref is unknown.\n */\n resolveSecret: (ref: SecretRef) => string\n /**\n * Absolute path to the idempotency marker file. Harness adapters write this\n * file after a successful projection so subsequent runs skip re-projection.\n * The file is NOT included in snapshots — absent on restore, triggering\n * re-projection (which re-writes any secret-bearing config files).\n */\n markerPath: string\n /** Workspace root inside the sandbox (e.g. `/workspace`). */\n root: string\n /** Named commands declared on the workspace (e.g. `{ test: 'pnpm test' }`). */\n scripts?: Record<string, string>\n}\n\nexport const ProjectionCapability =\n createCapability<WorkspaceProjection>()('sandbox-projection')\n\nexport const [getWorkspaceProjection, provideWorkspaceProjection] =\n ProjectionCapability\n"],"mappings":";;;;;;;;;;;AAyCA,IAAa,uBACX,iBAAsC,CAAC,CAAC,oBAAoB;AAE9D,IAAa,CAAC,wBAAwB,8BACpC"}