@tanstack/ai-sandbox 0.1.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 (109) hide show
  1. package/README.md +182 -0
  2. package/dist/esm/agents-file.d.ts +36 -0
  3. package/dist/esm/agents-file.js +44 -0
  4. package/dist/esm/agents-file.js.map +1 -0
  5. package/dist/esm/approvals.d.ts +38 -0
  6. package/dist/esm/approvals.js +36 -0
  7. package/dist/esm/approvals.js.map +1 -0
  8. package/dist/esm/bootstrap.d.ts +17 -0
  9. package/dist/esm/bootstrap.js +124 -0
  10. package/dist/esm/bootstrap.js.map +1 -0
  11. package/dist/esm/bridge-events.d.ts +21 -0
  12. package/dist/esm/bridge-events.js +76 -0
  13. package/dist/esm/bridge-events.js.map +1 -0
  14. package/dist/esm/capabilities.d.ts +26 -0
  15. package/dist/esm/capabilities.js +29 -0
  16. package/dist/esm/capabilities.js.map +1 -0
  17. package/dist/esm/contracts.d.ts +211 -0
  18. package/dist/esm/errors.d.ts +16 -0
  19. package/dist/esm/errors.js +25 -0
  20. package/dist/esm/errors.js.map +1 -0
  21. package/dist/esm/git-exec.d.ts +2 -0
  22. package/dist/esm/git-exec.js +68 -0
  23. package/dist/esm/git-exec.js.map +1 -0
  24. package/dist/esm/harness-cwd.d.ts +2 -0
  25. package/dist/esm/harness-cwd.js +24 -0
  26. package/dist/esm/harness-cwd.js.map +1 -0
  27. package/dist/esm/index.d.ts +39 -0
  28. package/dist/esm/index.js +103 -0
  29. package/dist/esm/index.js.map +1 -0
  30. package/dist/esm/key.d.ts +20 -0
  31. package/dist/esm/key.js +41 -0
  32. package/dist/esm/key.js.map +1 -0
  33. package/dist/esm/middleware.d.ts +5 -0
  34. package/dist/esm/middleware.js +140 -0
  35. package/dist/esm/middleware.js.map +1 -0
  36. package/dist/esm/ngrok.d.ts +16 -0
  37. package/dist/esm/ngrok.js +54 -0
  38. package/dist/esm/ngrok.js.map +1 -0
  39. package/dist/esm/policy.d.ts +47 -0
  40. package/dist/esm/policy.js +44 -0
  41. package/dist/esm/policy.js.map +1 -0
  42. package/dist/esm/projection.d.ts +31 -0
  43. package/dist/esm/projection.js +9 -0
  44. package/dist/esm/projection.js.map +1 -0
  45. package/dist/esm/remote-tools.d.ts +48 -0
  46. package/dist/esm/remote-tools.js +76 -0
  47. package/dist/esm/remote-tools.js.map +1 -0
  48. package/dist/esm/run-log.d.ts +81 -0
  49. package/dist/esm/run-log.js +107 -0
  50. package/dist/esm/run-log.js.map +1 -0
  51. package/dist/esm/run.d.ts +58 -0
  52. package/dist/esm/run.js +89 -0
  53. package/dist/esm/run.js.map +1 -0
  54. package/dist/esm/runner.d.ts +21 -0
  55. package/dist/esm/runner.js +54 -0
  56. package/dist/esm/runner.js.map +1 -0
  57. package/dist/esm/sandbox.d.ts +79 -0
  58. package/dist/esm/sandbox.js +125 -0
  59. package/dist/esm/sandbox.js.map +1 -0
  60. package/dist/esm/secrets.d.ts +37 -0
  61. package/dist/esm/secrets.js +59 -0
  62. package/dist/esm/secrets.js.map +1 -0
  63. package/dist/esm/setup-plan.d.ts +13 -0
  64. package/dist/esm/setup-plan.js +16 -0
  65. package/dist/esm/setup-plan.js.map +1 -0
  66. package/dist/esm/shell.d.ts +45 -0
  67. package/dist/esm/shell.js +164 -0
  68. package/dist/esm/shell.js.map +1 -0
  69. package/dist/esm/store.d.ts +53 -0
  70. package/dist/esm/store.js +34 -0
  71. package/dist/esm/store.js.map +1 -0
  72. package/dist/esm/tool-bridge.d.ts +130 -0
  73. package/dist/esm/tool-bridge.js +197 -0
  74. package/dist/esm/tool-bridge.js.map +1 -0
  75. package/dist/esm/watch.d.ts +36 -0
  76. package/dist/esm/watch.js +144 -0
  77. package/dist/esm/watch.js.map +1 -0
  78. package/dist/esm/workspace.d.ts +128 -0
  79. package/dist/esm/workspace.js +42 -0
  80. package/dist/esm/workspace.js.map +1 -0
  81. package/package.json +72 -0
  82. package/skills/ai-sandbox/SKILL.md +366 -0
  83. package/src/agents-file.ts +101 -0
  84. package/src/approvals.ts +96 -0
  85. package/src/bootstrap.ts +196 -0
  86. package/src/bridge-events.ts +112 -0
  87. package/src/capabilities.ts +47 -0
  88. package/src/contracts.ts +236 -0
  89. package/src/errors.ts +31 -0
  90. package/src/git-exec.ts +114 -0
  91. package/src/harness-cwd.ts +38 -0
  92. package/src/index.ts +222 -0
  93. package/src/key.ts +70 -0
  94. package/src/middleware.ts +233 -0
  95. package/src/ngrok.ts +85 -0
  96. package/src/policy.ts +111 -0
  97. package/src/projection.ts +46 -0
  98. package/src/remote-tools.ts +180 -0
  99. package/src/run-log.ts +224 -0
  100. package/src/run.ts +167 -0
  101. package/src/runner.ts +99 -0
  102. package/src/sandbox.ts +259 -0
  103. package/src/secrets.ts +101 -0
  104. package/src/setup-plan.ts +25 -0
  105. package/src/shell.ts +288 -0
  106. package/src/store.ts +83 -0
  107. package/src/tool-bridge.ts +399 -0
  108. package/src/watch.ts +256 -0
  109. package/src/workspace.ts +151 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Universal AGENTS.md writer with per-CLI symlink projection, plus the
3
+ * canonical helper for locating cloned gitSkill repositories inside a sandbox.
4
+ *
5
+ * The known-names set below lists the canonical instruction-file names for
6
+ * each AI coding assistant CLI. Keep the list in one place so it is easy to
7
+ * extend. The copy fallback ensures correctness on platforms without symlink
8
+ * support (e.g. Windows).
9
+ *
10
+ * External per-CLI convention: each assistant looks for its own instruction
11
+ * file by name (CLAUDE.md for Claude Code, GEMINI.md for Gemini CLI, …).
12
+ * We write a single authoritative AGENTS.md and point each name at it.
13
+ */
14
+ import type { SandboxHandle } from './contracts'
15
+ import type { WorkspaceSkill } from './workspace'
16
+
17
+ /** CLI instruction-file names that should resolve to AGENTS.md. */
18
+ const SYMLINK_NAMES: ReadonlyArray<string> = ['CLAUDE.md', 'GEMINI.md']
19
+
20
+ /**
21
+ * Resolve the directory a `gitSkill` repo is cloned into when no explicit
22
+ * `into` override is provided. The convention is:
23
+ *
24
+ * `<root>/.tanstack-skills/<basename>`
25
+ *
26
+ * where `basename` is derived from the `repo` field by taking the last
27
+ * path segment and stripping a trailing `.git` suffix.
28
+ *
29
+ * Per-harness projectors (e.g. the Claude Code adapter) import this helper
30
+ * so they can locate cloned skill repos consistently.
31
+ *
32
+ * @param root - Workspace root inside the sandbox (e.g. `/workspace`).
33
+ * @param skill - A `WorkspaceSkill` of `kind === 'git'`.
34
+ */
35
+ export function resolveGitSkillDir(
36
+ root: string,
37
+ skill: Extract<WorkspaceSkill, { kind: 'git' }>,
38
+ ): string {
39
+ const rawBasename = skill.repo.split('/').pop() ?? skill.repo
40
+ const basename = rawBasename.endsWith('.git')
41
+ ? rawBasename.slice(0, -4)
42
+ : rawBasename
43
+ return `${root}/.tanstack-skills/${basename}`
44
+ }
45
+
46
+ /** Format workspace scripts as a `## Workspace scripts` markdown section. */
47
+ export function formatWorkspaceScriptsSection(
48
+ scripts: Record<string, string>,
49
+ ): string {
50
+ const names = Object.keys(scripts).sort()
51
+ if (names.length === 0) return ''
52
+ const lines = names.map((name) => `- ${name} → ${scripts[name]}`)
53
+ return `## Workspace scripts\n\n${lines.join('\n')}`
54
+ }
55
+
56
+ /**
57
+ * Merge base AGENTS.md content with an optional workspace scripts section.
58
+ * Returns `undefined` when there is nothing to write.
59
+ */
60
+ export function mergeAgentsContent(
61
+ base: string | undefined,
62
+ scripts: Record<string, string> | undefined,
63
+ ): string | undefined {
64
+ const scriptsSection =
65
+ scripts !== undefined ? formatWorkspaceScriptsSection(scripts) : ''
66
+ if (base === undefined && scriptsSection.length === 0) return undefined
67
+ if (base === undefined) return scriptsSection
68
+ if (scriptsSection.length === 0) return base
69
+ return `${base.trimEnd()}\n\n${scriptsSection}`
70
+ }
71
+
72
+ /** Escape a string for safe use as a single-quoted shell argument. */
73
+ function sqEscape(value: string): string {
74
+ return value.replace(/'/g, `'\\''`)
75
+ }
76
+
77
+ /**
78
+ * Write `AGENTS.md` under `root` and create per-CLI symlinks (or copies as a
79
+ * fallback when `ln -s` is unavailable).
80
+ *
81
+ * @param handle - The sandbox handle providing `fs` and `process`.
82
+ * @param root - Absolute path inside the sandbox under which to write.
83
+ * @param content - Markdown content for the instruction file.
84
+ */
85
+ export async function writeAgentsFile(
86
+ handle: SandboxHandle,
87
+ root: string,
88
+ content: string,
89
+ ): Promise<void> {
90
+ const agentsPath = `${root}/AGENTS.md`
91
+ await handle.fs.write(agentsPath, content)
92
+
93
+ for (const name of SYMLINK_NAMES) {
94
+ const lnCmd = `ln -s '${sqEscape('AGENTS.md')}' '${sqEscape(name)}'`
95
+ const result = await handle.process.exec(lnCmd, { cwd: root })
96
+ if (result.exitCode !== 0) {
97
+ // Symlinks are not supported on this platform — fall back to a copy.
98
+ await handle.fs.write(`${root}/${name}`, content)
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared interactive-approval logic for harness adapters.
3
+ *
4
+ * Flow (rides chat()'s existing resume-based approval mechanism):
5
+ * 1. The agent (inside the sandbox) asks to run a risky action; the harness's
6
+ * host-side permission callback fires.
7
+ * 2. `resolveApproval` evaluates the sandbox policy: `allow`/`deny` are final;
8
+ * `ask` consults the client's approval decisions (threaded via
9
+ * `TextOptions.approvals`, keyed by a stable `approvalId`).
10
+ * 3. On `ask` with no decision yet, the adapter emits an `approval-requested`
11
+ * CUSTOM event (carrying the `approvalId`) and denies the action this turn.
12
+ * The client shows UI, then re-runs chat() with the decision in the message;
13
+ * the engine surfaces it as `approvals`, and the next run allows it.
14
+ *
15
+ * `approvalId` is stable for a given (provider, kind, target) so a client grant
16
+ * matches the same action on the resumed run.
17
+ */
18
+ import { EventType } from '@tanstack/ai'
19
+ import { evaluateCommand } from './policy'
20
+ import type { SandboxPolicy } from './policy'
21
+ import type { StreamChunk } from '@tanstack/ai'
22
+
23
+ /** CUSTOM event name emitted when a harness action needs client approval. */
24
+ export const APPROVAL_REQUESTED_EVENT = 'approval-requested'
25
+
26
+ /** A stable, opaque approval id for a harness action. */
27
+ export function approvalId(input: {
28
+ provider: string
29
+ kind: 'command' | 'fileWrite' | 'network' | 'tool'
30
+ target: string
31
+ }): string {
32
+ return `${input.provider}:${input.kind}:${input.target}`
33
+ }
34
+
35
+ export interface ResolveApprovalInput {
36
+ policy: SandboxPolicy | undefined
37
+ /** Client approval decisions, keyed by `approvalId`. */
38
+ approvals: ReadonlyMap<string, boolean> | undefined
39
+ /** Precomputed approval id for this action. */
40
+ id: string
41
+ /** A shell command to match against `policy.commands`. */
42
+ command?: string
43
+ /** Named workspace scripts for policy alias resolution. */
44
+ scripts?: Record<string, string>
45
+ /** A coarse capability to match against `policy.capabilities`. */
46
+ capability?: 'fileWrite' | 'network'
47
+ }
48
+
49
+ export interface ApprovalOutcome {
50
+ decision: 'allow' | 'deny'
51
+ /** True when policy said `ask` and the client hasn't decided yet. */
52
+ needsApproval: boolean
53
+ }
54
+
55
+ /** Resolve a harness permission request against policy + client approvals. */
56
+ export function resolveApproval(input: ResolveApprovalInput): ApprovalOutcome {
57
+ const base =
58
+ input.command !== undefined
59
+ ? evaluateCommand(input.command, input.policy, input.scripts)
60
+ : input.capability !== undefined
61
+ ? (input.policy?.capabilities?.[input.capability] ??
62
+ input.policy?.default ??
63
+ 'ask')
64
+ : (input.policy?.default ?? 'ask')
65
+
66
+ if (base === 'allow') return { decision: 'allow', needsApproval: false }
67
+ if (base === 'deny') return { decision: 'deny', needsApproval: false }
68
+
69
+ // base === 'ask' — consult the client's decision.
70
+ const granted = input.approvals?.get(input.id)
71
+ if (granted === true) return { decision: 'allow', needsApproval: false }
72
+ if (granted === false) return { decision: 'deny', needsApproval: false }
73
+ return { decision: 'deny', needsApproval: true }
74
+ }
75
+
76
+ /** Build the AG-UI `approval-requested` CUSTOM event for a harness action. */
77
+ export function buildApprovalRequestedEvent(input: {
78
+ approvalId: string
79
+ title: string
80
+ threadId: string
81
+ runId: string
82
+ detail?: Record<string, unknown>
83
+ }): StreamChunk {
84
+ return {
85
+ type: EventType.CUSTOM,
86
+ name: APPROVAL_REQUESTED_EVENT,
87
+ value: {
88
+ approvalId: input.approvalId,
89
+ title: input.title,
90
+ ...(input.detail ?? {}),
91
+ },
92
+ timestamp: Date.now(),
93
+ threadId: input.threadId,
94
+ runId: input.runId,
95
+ }
96
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Workspace bootstrap engine — provider-agnostic because it only uses the
3
+ * {@link SandboxHandle} contract. Runs once when a sandbox is freshly created
4
+ * (or restored without its working tree): land the source, inject secrets,
5
+ * detect the package manager, and run setup commands.
6
+ *
7
+ * Harness-specific projection (CLAUDE.md, agent skills, MCP config) is NOT done
8
+ * here — that's each adapter's `projectWorkspace()` hook, since the format
9
+ * differs per harness.
10
+ */
11
+ import { buildSetupPlan } from './setup-plan'
12
+ import { createBootstrapShell } from './shell'
13
+ import {
14
+ mergeAgentsContent,
15
+ resolveGitSkillDir,
16
+ writeAgentsFile,
17
+ } from './agents-file'
18
+ import { resolveAllSecrets, resolveSecret } from './secrets'
19
+ import type { SandboxHandle } from './contracts'
20
+ import type { PackageManager, WorkspaceDefinition } from './workspace'
21
+
22
+ const LOCKFILES: Record<Exclude<PackageManager, 'auto'>, string> = {
23
+ pnpm: 'pnpm-lock.yaml',
24
+ yarn: 'yarn.lock',
25
+ bun: 'bun.lockb',
26
+ npm: 'package-lock.json',
27
+ }
28
+
29
+ export const DEFAULT_WORKSPACE_ROOT = '/workspace'
30
+
31
+ /** Resolve the package manager, detecting from a lockfile when `'auto'`. */
32
+ export async function detectPackageManager(
33
+ handle: SandboxHandle,
34
+ workspace: WorkspaceDefinition,
35
+ root: string,
36
+ ): Promise<Exclude<PackageManager, 'auto'> | undefined> {
37
+ const pm = workspace.packageManager ?? 'auto'
38
+ if (pm !== 'auto') return pm
39
+ for (const [manager, lockfile] of Object.entries(LOCKFILES) as Array<
40
+ [Exclude<PackageManager, 'auto'>, string]
41
+ >) {
42
+ if (await handle.fs.exists(`${root}/${lockfile}`)) return manager
43
+ }
44
+ return undefined
45
+ }
46
+
47
+ export interface BootstrapResult {
48
+ packageManager?: Exclude<PackageManager, 'auto'>
49
+ ranSetup: Array<string>
50
+ }
51
+
52
+ /**
53
+ * Bootstrap a freshly created sandbox's workspace. Idempotent enough to be safe
54
+ * on restore: a git clone into a populated dir is skipped by checking for the
55
+ * target dir first.
56
+ */
57
+ export async function bootstrapWorkspace(
58
+ handle: SandboxHandle,
59
+ workspace: WorkspaceDefinition,
60
+ options: { signal?: AbortSignal } = {},
61
+ ): Promise<BootstrapResult> {
62
+ const root = workspace.root ?? DEFAULT_WORKSPACE_ROOT
63
+
64
+ // Secrets live only in the running sandbox env (never persisted).
65
+ if (workspace.secrets !== undefined) {
66
+ const resolved = resolveAllSecrets(workspace.secrets)
67
+ if (Object.keys(resolved).length > 0) {
68
+ await handle.env.set(resolved)
69
+ }
70
+ }
71
+
72
+ // Land the source. Clone into the handle's own default root (each provider
73
+ // maps the conventional `/workspace` virtual root to its real backing dir),
74
+ // rather than passing a virtual `dir` that can't be remapped inside a shell
75
+ // command string.
76
+ if (workspace.source.type === 'git') {
77
+ const alreadyCloned = await handle.fs.exists(`${root}/.git`)
78
+ if (!alreadyCloned) {
79
+ await handle.git.clone({
80
+ url: workspace.source.url,
81
+ ref: workspace.source.ref,
82
+ auth: workspace.source.auth,
83
+ ...(workspace.source.depth !== undefined
84
+ ? { depth: workspace.source.depth }
85
+ : {}),
86
+ })
87
+ }
88
+ }
89
+ // 'local' is provider-pre-populated at create; 'none' starts empty.
90
+
91
+ // Clone git-skill repos so setup steps (and the harness projector) can use
92
+ // them. gitSkill clones are always shallow (depth 1) unless the skill's own
93
+ // repo entry carries a depth override — the WorkspaceSkill `git` variant
94
+ // does not expose one, so depth always defaults to 1 inside git.clone.
95
+ const skills = workspace.skills ?? []
96
+ for (const skill of skills) {
97
+ if (skill.kind === 'git') {
98
+ const url = skill.repo.startsWith('http')
99
+ ? skill.repo
100
+ : `https://github.com/${skill.repo}.git`
101
+ const dir = skill.into ?? resolveGitSkillDir(root, skill)
102
+ const auth =
103
+ skill.secret !== undefined && workspace.secrets !== undefined
104
+ ? { token: resolveSecret(workspace.secrets, skill.secret) }
105
+ : undefined
106
+ await handle.git.clone({
107
+ url,
108
+ dir,
109
+ ...(auth !== undefined ? { auth } : {}),
110
+ depth: 1,
111
+ })
112
+ }
113
+ }
114
+
115
+ // Write AGENTS.md (and its per-CLI symlinks) when instructions are provided
116
+ // directly on the workspace, via a fileSkill whose path is `AGENTS.md`, or
117
+ // when named workspace scripts should be surfaced for the agent.
118
+ let agentsContent: string | undefined
119
+ if (
120
+ workspace.instructions !== undefined &&
121
+ workspace.instructions.length > 0
122
+ ) {
123
+ agentsContent = workspace.instructions
124
+ } else {
125
+ const agentsFileSkill = skills.find(
126
+ (s): s is Extract<typeof s, { kind: 'file' }> =>
127
+ s.kind === 'file' && s.path === 'AGENTS.md',
128
+ )
129
+ if (agentsFileSkill !== undefined) {
130
+ agentsContent = agentsFileSkill.content
131
+ }
132
+ }
133
+ agentsContent = mergeAgentsContent(agentsContent, workspace.scripts)
134
+ if (agentsContent !== undefined) {
135
+ await writeAgentsFile(handle, root, agentsContent)
136
+ }
137
+
138
+ // Write all other fileSkills directly into the workspace root.
139
+ for (const skill of skills) {
140
+ if (skill.kind === 'file' && skill.path !== 'AGENTS.md') {
141
+ await handle.fs.write(`${root}/${skill.path}`, skill.content)
142
+ }
143
+ }
144
+
145
+ const packageManager = await detectPackageManager(handle, workspace, root)
146
+
147
+ // Run setup over a single persistent shell so `cd`/exports persist across
148
+ // serial steps. Parallel groups fork the shell's current cwd+env into
149
+ // concurrent one-shot exec calls.
150
+ const ranSetup: Array<string> = []
151
+ const plan = buildSetupPlan(workspace.setup)
152
+ if (plan.length > 0) {
153
+ const shell = await createBootstrapShell(handle, { cwd: root })
154
+ try {
155
+ for (const group of plan) {
156
+ if (group.kind === 'serial') {
157
+ const result = await shell.run(group.command)
158
+ if (result.exitCode !== 0) {
159
+ const tail = result.stdout.trim().slice(-1500)
160
+ throw new Error(
161
+ `setup step failed: ${group.command} (exit ${result.exitCode})${tail ? `\n${tail}` : ''}`,
162
+ )
163
+ }
164
+ ranSetup.push(group.command)
165
+ } else {
166
+ const { cwd, env } = await shell.forkState()
167
+ const results = await Promise.all(
168
+ group.commands.map((command) =>
169
+ handle.process
170
+ .exec(command, {
171
+ cwd,
172
+ env,
173
+ ...(options.signal ? { signal: options.signal } : {}),
174
+ })
175
+ .then((res) => ({ command, res })),
176
+ ),
177
+ )
178
+ const failed = results.find((entry) => entry.res.exitCode !== 0)
179
+ if (failed !== undefined) {
180
+ const tail = `${failed.res.stdout}\n${failed.res.stderr}`
181
+ .trim()
182
+ .slice(-1500)
183
+ throw new Error(
184
+ `setup step failed: ${failed.command} (exit ${failed.res.exitCode})${tail ? `\n${tail}` : ''}`,
185
+ )
186
+ }
187
+ ranSetup.push(...group.commands)
188
+ }
189
+ }
190
+ } finally {
191
+ await shell.dispose()
192
+ }
193
+ }
194
+
195
+ return { packageManager, ranSetup }
196
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Helpers that let a harness adapter surface custom events emitted by BRIDGED
3
+ * tools (via {@link ToolBridgeCoreOptions.emitCustomEvent}) on its live output
4
+ * stream.
5
+ *
6
+ * The bridge runs out-of-band from the harness's own event stream, so a bridged
7
+ * tool's progress/console events have no path to the client on their own. An
8
+ * adapter creates a {@link BridgeEventChannel}, hands its `emitCustomEvent` to
9
+ * the bridge provisioner, and {@link mergeChunkStreams | merges} the channel's
10
+ * stream into its translated output — so events interleave live while the agent
11
+ * runs (e.g. code mode's `code_mode:console` logs during a long execution).
12
+ */
13
+ import { EventType } from '@tanstack/ai'
14
+ import type { StreamChunk } from '@tanstack/ai'
15
+
16
+ export interface BridgeEventChannel {
17
+ /** Pass as the bridge's `emitCustomEvent`; buffers a CUSTOM chunk for the stream. */
18
+ emitCustomEvent: (eventName: string, value: Record<string, unknown>) => void
19
+ /** Live CUSTOM-chunk stream; ends after {@link close} once drained. */
20
+ stream: AsyncIterable<StreamChunk>
21
+ /** Stop the stream (call when the run's main output is done). */
22
+ close: () => void
23
+ }
24
+
25
+ /** Create a channel whose emitted events become CUSTOM {@link StreamChunk}s. */
26
+ export function createBridgeEventChannel(meta: {
27
+ model: string
28
+ threadId?: string
29
+ runId?: string
30
+ }): BridgeEventChannel {
31
+ const buffer: Array<StreamChunk> = []
32
+ let notify: (() => void) | null = null
33
+ let closed = false
34
+
35
+ async function* stream(): AsyncIterable<StreamChunk> {
36
+ for (;;) {
37
+ const next = buffer.shift()
38
+ if (next !== undefined) {
39
+ yield next
40
+ continue
41
+ }
42
+ if (closed) return
43
+ await new Promise<void>((resolve) => {
44
+ notify = resolve
45
+ })
46
+ notify = null
47
+ }
48
+ }
49
+
50
+ return {
51
+ emitCustomEvent(eventName, value) {
52
+ if (closed) return
53
+ buffer.push({
54
+ type: EventType.CUSTOM,
55
+ name: eventName,
56
+ value,
57
+ timestamp: Date.now(),
58
+ model: meta.model,
59
+ ...(meta.threadId !== undefined && { threadId: meta.threadId }),
60
+ ...(meta.runId !== undefined && { runId: meta.runId }),
61
+ })
62
+ notify?.()
63
+ },
64
+ close() {
65
+ closed = true
66
+ notify?.()
67
+ },
68
+ stream: stream(),
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Merge a `side` chunk stream into a `base` chunk stream, yielding from whichever
74
+ * settles first. Terminates when `base` ends (the run is over), then releases the
75
+ * side iterator — so a never-ending channel (until closed) doesn't hang the merge.
76
+ */
77
+ export async function* mergeChunkStreams(
78
+ base: AsyncIterable<StreamChunk>,
79
+ side: AsyncIterable<StreamChunk>,
80
+ ): AsyncIterable<StreamChunk> {
81
+ const baseIt = base[Symbol.asyncIterator]()
82
+ const sideIt = side[Symbol.asyncIterator]()
83
+ let baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))
84
+ let sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))
85
+ let sideLive = true
86
+ try {
87
+ for (;;) {
88
+ const winner = await Promise.race(
89
+ sideLive ? [baseNext, sideNext] : [baseNext],
90
+ )
91
+ if (winner.from === 'base') {
92
+ if (winner.r.done) return
93
+ yield winner.r.value
94
+ baseNext = baseIt.next().then((r) => ({ from: 'base' as const, r }))
95
+ } else if (winner.r.done) {
96
+ sideLive = false
97
+ } else {
98
+ yield winner.r.value
99
+ sideNext = sideIt.next().then((r) => ({ from: 'side' as const, r }))
100
+ }
101
+ }
102
+ } finally {
103
+ // Fire-and-forget: do NOT await the side return. The channel generator is
104
+ // suspended on a promise that only `close()` resolves, and `close()` runs in
105
+ // the adapter's `finally` AFTER this merge completes — awaiting here would
106
+ // deadlock. The adapter's `close()` lets the generator unwind afterwards.
107
+ const baseReturn = baseIt.return?.(undefined)
108
+ if (baseReturn) void baseReturn.catch(() => {})
109
+ const sideReturn = sideIt.return?.(undefined)
110
+ if (sideReturn) void sideReturn.catch(() => {})
111
+ }
112
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Capability tokens the sandbox layer provides/consumes through the
3
+ * `@tanstack/ai` middleware capability system.
4
+ *
5
+ * - `SandboxCapability` is PROVIDED by `withSandbox` and REQUIRED by harness
6
+ * adapters (`requires: [SandboxCapability]`).
7
+ * - `SandboxStoreCapability` / `LocksCapability` are OPTIONALLY required by
8
+ * `withSandbox`. v1 falls back to in-memory defaults; the future persistence
9
+ * package PROVIDES durable implementations.
10
+ */
11
+ import { createCapability } from '@tanstack/ai'
12
+ import type { SandboxHandle } from './contracts'
13
+ import type { LockStore, SandboxStore } from './store'
14
+ import type { SandboxPolicy } from './policy'
15
+ import type { ToolBridgeProvisioner } from './tool-bridge'
16
+
17
+ export const SandboxCapability = createCapability<SandboxHandle>()('sandbox')
18
+
19
+ export const SandboxStoreCapability =
20
+ createCapability<SandboxStore>()('sandbox-store')
21
+
22
+ export const LocksCapability = createCapability<LockStore>()('locks')
23
+
24
+ /**
25
+ * The active sandbox policy, provided by `withSandbox` from the definition.
26
+ * Harness adapters read it to map allow/ask/deny rules onto their native
27
+ * permission system.
28
+ */
29
+ export const SandboxPolicyCapability =
30
+ createCapability<SandboxPolicy>()('sandbox-policy')
31
+
32
+ /**
33
+ * Provisions the MCP tool-bridge endpoint for a run. OPTIONALLY provided by a
34
+ * serverless/edge orchestrator (e.g. a Durable Object) to override the default
35
+ * `node:http` host transport. Harness adapters read it via `getOptional` and
36
+ * fall back to `nodeHttpBridgeProvisioner` when absent.
37
+ */
38
+ export const ToolBridgeProvisionerCapability =
39
+ createCapability<ToolBridgeProvisioner>()('tool-bridge-provisioner')
40
+
41
+ /** Destructured accessors for adapters: `getSandbox(ctx)` reads the handle. */
42
+ export const [getSandbox, provideSandbox] = SandboxCapability
43
+ export const [getSandboxStore, provideSandboxStore] = SandboxStoreCapability
44
+ export const [getLocks, provideLocks] = LocksCapability
45
+ export const [getSandboxPolicy, provideSandboxPolicy] = SandboxPolicyCapability
46
+ export const [getToolBridgeProvisioner, provideToolBridgeProvisioner] =
47
+ ToolBridgeProvisionerCapability