@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
package/src/watch.ts ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Sandbox file-event hooks — observe create / change / delete of files inside a
3
+ * sandbox (e.g. as an in-sandbox agent edits the workspace).
4
+ *
5
+ * Provider-agnostic: coded against the {@link SandboxHandle} contract only.
6
+ * Two mechanisms, auto-selected:
7
+ *
8
+ * - **Native** — when a provider implements the optional `fs.watch` seam
9
+ * (local-process does, via Node `fs.watch`), OS events drive the feed with low
10
+ * latency.
11
+ * - **Exec-poll** — otherwise (Docker, Cloudflare, any exec-only provider), a
12
+ * single `find … -printf` snapshot of `mtime\tsize\tpath` is taken every
13
+ * `intervalMs` and diffed. Works on any Linux container with GNU findutils
14
+ * (true for `node:*` / debian images) with no extra deps or image changes.
15
+ *
16
+ * The feed intentionally rides only the portable surface, so the same
17
+ * `watchWorkspace` call behaves identically across providers.
18
+ */
19
+ import { DEFAULT_WORKSPACE_ROOT } from './bootstrap'
20
+ import type { SandboxHandle } from './contracts'
21
+ import type { SandboxFileEvent } from '@tanstack/ai'
22
+
23
+ export type { SandboxFileEvent } from '@tanstack/ai'
24
+ /** @deprecated alias retained for the low-level watch API. */
25
+ export type FileEvent = SandboxFileEvent
26
+ export type FileEventType = SandboxFileEvent['type']
27
+
28
+ export interface WatchOptions {
29
+ /** Called for every observed file event. */
30
+ onEvent: (event: SandboxFileEvent) => void
31
+ /** Workspace root to watch. Defaults to `/workspace`. */
32
+ root?: string
33
+ /** Poll interval for the exec-poll fallback, in ms. Defaults to 700. */
34
+ intervalMs?: number
35
+ /**
36
+ * Directory-name fragments to ignore (a path containing `/<entry>/` is
37
+ * skipped). Defaults to `['.git', 'node_modules']`.
38
+ */
39
+ ignore?: Array<string>
40
+ /** Stop watching when this signal aborts. */
41
+ signal?: AbortSignal
42
+ }
43
+
44
+ export interface SandboxWatchHandle {
45
+ /** Stop the watcher and release its resources. */
46
+ stop: () => Promise<void>
47
+ }
48
+
49
+ const DEFAULT_INTERVAL_MS = 700
50
+ const DEFAULT_IGNORE = ['.git', 'node_modules']
51
+
52
+ /** POSIX single-quote escape for embedding values in a shell command. */
53
+ function q(value: string): string {
54
+ return `'${value.replace(/'/g, `'\\''`)}'`
55
+ }
56
+
57
+ /**
58
+ * Diff two file snapshots (`Map<path, signature>`, signature = `mtime\tsize`).
59
+ * Pure — the heart of the exec-poll path, unit-tested in isolation.
60
+ */
61
+ export function diffSnapshots(
62
+ prev: Map<string, string>,
63
+ next: Map<string, string>,
64
+ timestamp: number,
65
+ ): Array<SandboxFileEvent> {
66
+ const events: Array<SandboxFileEvent> = []
67
+ for (const [path, sig] of next) {
68
+ const before = prev.get(path)
69
+ if (before === undefined) events.push({ type: 'create', path, timestamp })
70
+ else if (before !== sig) events.push({ type: 'change', path, timestamp })
71
+ }
72
+ for (const path of prev.keys()) {
73
+ if (!next.has(path)) events.push({ type: 'delete', path, timestamp })
74
+ }
75
+ return events
76
+ }
77
+
78
+ /** Build the `find` command that prints `mtime\tsize\tpath` for every file. */
79
+ function buildFindCommand(root: string, ignore: Array<string>): string {
80
+ const prunes = ignore
81
+ .map((entry) => `-not -path ${q(`*/${entry}/*`)}`)
82
+ .join(' ')
83
+ return `find ${q(root)} -type f ${prunes} -printf '%T@\\t%s\\t%p\\n'`
84
+ }
85
+
86
+ /** Parse `find -printf` output into a `Map<path, signature>`. */
87
+ function parseFindOutput(stdout: string): Map<string, string> {
88
+ const snapshot = new Map<string, string>()
89
+ for (const line of stdout.split('\n')) {
90
+ if (line === '') continue
91
+ const firstTab = line.indexOf('\t')
92
+ const secondTab = line.indexOf('\t', firstTab + 1)
93
+ if (firstTab === -1 || secondTab === -1) continue
94
+ const mtime = line.slice(0, firstTab)
95
+ const size = line.slice(firstTab + 1, secondTab)
96
+ const path = line.slice(secondTab + 1)
97
+ snapshot.set(path, `${mtime}\t${size}`)
98
+ }
99
+ return snapshot
100
+ }
101
+
102
+ /** Whether a path should be ignored (contains a `/<entry>/` fragment). */
103
+ function isIgnored(path: string, ignore: Array<string>): boolean {
104
+ return ignore.some((entry) => path.includes(`/${entry}/`))
105
+ }
106
+
107
+ /**
108
+ * Start watching a sandbox workspace for file events. Picks the native
109
+ * `fs.watch` fast-path when the provider advertises it, otherwise polls via
110
+ * `find`. Returns a handle whose `stop()` tears everything down.
111
+ */
112
+ export async function watchWorkspace(
113
+ handle: SandboxHandle,
114
+ options: WatchOptions,
115
+ ): Promise<SandboxWatchHandle> {
116
+ const root = options.root ?? DEFAULT_WORKSPACE_ROOT
117
+ const ignore = options.ignore ?? DEFAULT_IGNORE
118
+ const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS
119
+
120
+ // Already aborted before we start — don't begin any async work.
121
+ if (options.signal?.aborted) return { stop: () => Promise.resolve() }
122
+
123
+ if (handle.fs.watch) {
124
+ return startNativeWatch(handle, { ...options, root, ignore })
125
+ }
126
+ return startPollWatch(handle, { ...options, root, ignore, intervalMs })
127
+ }
128
+
129
+ /** Native fs.watch path: OS events, disambiguated against a known-path set. */
130
+ async function startNativeWatch(
131
+ handle: SandboxHandle,
132
+ options: WatchOptions & { root: string; ignore: Array<string> },
133
+ ): Promise<SandboxWatchHandle> {
134
+ const { onEvent, root, ignore } = options
135
+ const watch = handle.fs.watch
136
+ if (!watch) throw new Error('native watch is unavailable on this provider')
137
+ // Seed the set of existing files so the first event per path is classified
138
+ // correctly (create vs change).
139
+ const known = await collectPaths(handle, root, ignore)
140
+
141
+ const subscription = await watch(root, (raw) => {
142
+ const path = raw.path
143
+ if (isIgnored(path, ignore)) return
144
+ void (async () => {
145
+ const exists = await handle.fs.exists(path)
146
+ const timestamp = Date.now()
147
+ if (!exists) {
148
+ if (known.delete(path)) onEvent({ type: 'delete', path, timestamp })
149
+ return
150
+ }
151
+ if (known.has(path)) onEvent({ type: 'change', path, timestamp })
152
+ else {
153
+ known.add(path)
154
+ onEvent({ type: 'create', path, timestamp })
155
+ }
156
+ })().catch(() => undefined)
157
+ })
158
+
159
+ const onAbort = (): void => void subscription.stop().catch(() => undefined)
160
+ options.signal?.addEventListener('abort', onAbort, { once: true })
161
+ // The signal may have aborted during the awaits above (the once-listener
162
+ // would have missed it) — tear down now if so.
163
+ if (options.signal?.aborted) void subscription.stop().catch(() => undefined)
164
+
165
+ return {
166
+ stop: async () => {
167
+ options.signal?.removeEventListener('abort', onAbort)
168
+ await subscription.stop()
169
+ },
170
+ }
171
+ }
172
+
173
+ /** Exec-poll path: snapshot `find -printf` on an interval and diff. */
174
+ async function startPollWatch(
175
+ handle: SandboxHandle,
176
+ options: WatchOptions & {
177
+ root: string
178
+ ignore: Array<string>
179
+ intervalMs: number
180
+ },
181
+ ): Promise<SandboxWatchHandle> {
182
+ const { onEvent, root, ignore, intervalMs } = options
183
+ const command = buildFindCommand(root, ignore)
184
+ const controller = new AbortController()
185
+
186
+ const snapshot = async (): Promise<Map<string, string>> => {
187
+ const result = await handle.process.exec(command, {
188
+ cwd: root,
189
+ signal: controller.signal,
190
+ })
191
+ return result.exitCode === 0
192
+ ? parseFindOutput(result.stdout)
193
+ : new Map<string, string>()
194
+ }
195
+
196
+ let previous = await snapshot()
197
+ const state = { running: true }
198
+
199
+ const tick = async (): Promise<void> => {
200
+ if (!state.running) return
201
+ try {
202
+ const next = await snapshot()
203
+ for (const event of diffSnapshots(previous, next, Date.now())) {
204
+ onEvent(event)
205
+ }
206
+ previous = next
207
+ } catch {
208
+ // transient exec failure (e.g. mid-teardown) — try again next tick
209
+ }
210
+ }
211
+
212
+ const timer = setInterval(() => void tick(), intervalMs)
213
+ // Don't keep the event loop alive on the watcher alone.
214
+ if (typeof timer.unref === 'function') timer.unref()
215
+
216
+ const stop = (): Promise<void> => {
217
+ if (state.running) {
218
+ state.running = false
219
+ clearInterval(timer)
220
+ controller.abort()
221
+ options.signal?.removeEventListener('abort', onAbort)
222
+ }
223
+ return Promise.resolve()
224
+ }
225
+ const onAbort = (): void => void stop()
226
+ options.signal?.addEventListener('abort', onAbort, { once: true })
227
+ // The signal may have aborted during the initial `await snapshot()` above
228
+ // (the once-listener would have missed it) — tear down now if so.
229
+ if (options.signal?.aborted) void stop()
230
+
231
+ return { stop }
232
+ }
233
+
234
+ /** Recursively collect file paths under `root`, honoring `ignore`. */
235
+ async function collectPaths(
236
+ handle: SandboxHandle,
237
+ root: string,
238
+ ignore: Array<string>,
239
+ ): Promise<Set<string>> {
240
+ const files = new Set<string>()
241
+ const walk = async (dir: string): Promise<void> => {
242
+ let entries: Awaited<ReturnType<SandboxHandle['fs']['list']>>
243
+ try {
244
+ entries = await handle.fs.list(dir)
245
+ } catch {
246
+ return
247
+ }
248
+ for (const entry of entries) {
249
+ if (ignore.includes(entry.name)) continue
250
+ if (entry.type === 'dir') await walk(entry.path)
251
+ else files.add(entry.path)
252
+ }
253
+ }
254
+ await walk(root)
255
+ return files
256
+ }
@@ -0,0 +1,151 @@
1
+ import type { SetupInput } from './setup-plan'
2
+ import type { BearerRef, SecretRef, Secrets } from './secrets'
3
+
4
+ /**
5
+ * Workspace definition — the portable description of what the agent sees
6
+ * inside the sandbox. Each harness adapter PROJECTS this into its own native
7
+ * format via `projectWorkspace()` (e.g. Claude Code → CLAUDE.md + .claude/skills
8
+ * + --mcp-config). The definition itself is provider- and harness-agnostic.
9
+ */
10
+
11
+ /** Where the working tree comes from. */
12
+ export type WorkspaceSource =
13
+ | {
14
+ type: 'git'
15
+ url: string
16
+ ref?: string
17
+ auth?: { username?: string; token: string }
18
+ /**
19
+ * Clone depth. Defaults to `1` (shallow). Pass a number for a specific
20
+ * depth, or `'full'` to fetch the entire history.
21
+ */
22
+ depth?: number | 'full'
23
+ }
24
+ | { type: 'local'; path: string }
25
+ | { type: 'none' }
26
+
27
+ /** Clone a git repo into the workspace. `githubRepo` is a convenience wrapper. */
28
+ export function gitSource(input: {
29
+ url: string
30
+ ref?: string
31
+ auth?: { username?: string; token: string }
32
+ depth?: number | 'full'
33
+ }): WorkspaceSource {
34
+ return { type: 'git', ...input }
35
+ }
36
+
37
+ export function githubRepo(input: {
38
+ repo: string
39
+ ref?: string
40
+ auth?: { username?: string; token: string }
41
+ depth?: number | 'full'
42
+ }): WorkspaceSource {
43
+ const url = input.repo.startsWith('http')
44
+ ? input.repo
45
+ : `https://github.com/${input.repo}.git`
46
+ return {
47
+ type: 'git',
48
+ url,
49
+ ref: input.ref,
50
+ auth: input.auth,
51
+ depth: input.depth,
52
+ }
53
+ }
54
+
55
+ export function localSource(path: string): WorkspaceSource {
56
+ return { type: 'local', path }
57
+ }
58
+
59
+ /**
60
+ * An MCP server config where header names/values may be plain strings or
61
+ * unresolved SecretRef values. Secrets are resolved by each harness projector
62
+ * at projection time — never at definition time.
63
+ */
64
+ export type McpConfig = {
65
+ headers?: Record<string, string | SecretRef | BearerRef>
66
+ [key: string]: unknown
67
+ }
68
+
69
+ /** A unit of agent guidance/config projected into the harness's native format. */
70
+ export type WorkspaceSkill =
71
+ | { kind: 'file'; path: string; content: string }
72
+ | { kind: 'agent-skill'; name: string }
73
+ | { kind: 'mcp'; name: string; config: McpConfig }
74
+ | {
75
+ kind: 'git'
76
+ /** Short `owner/repo` or a full HTTPS URL. */
77
+ repo: string
78
+ /** Optional SecretRef for private-repo authentication. */
79
+ secret?: SecretRef
80
+ /** Absolute path inside the sandbox to clone into. Defaults to a `.tanstack-skills/<repo>` dir under the workspace root. */
81
+ into?: string
82
+ }
83
+
84
+ /** Write a file (e.g. CLAUDE.md) into the workspace / harness config. */
85
+ export function fileSkill(input: {
86
+ path: string
87
+ content: string
88
+ }): WorkspaceSkill {
89
+ return { kind: 'file', ...input }
90
+ }
91
+
92
+ /** Reference a named agent skill the harness should load. */
93
+ export function agentSkill(name: string): WorkspaceSkill {
94
+ return { kind: 'agent-skill', name }
95
+ }
96
+
97
+ /** Project an MCP server into the harness. Header values may be SecretRefs. */
98
+ export function mcpSkill(name: string, config: McpConfig): WorkspaceSkill {
99
+ return { kind: 'mcp', name, config }
100
+ }
101
+
102
+ /**
103
+ * Clone a git repository as a workspace skill (e.g. a private skill repo).
104
+ * The clone is performed during bootstrap; `secret` is resolved from the
105
+ * workspace `secrets` registry at that time.
106
+ */
107
+ export function gitSkill(input: {
108
+ repo: string
109
+ secret?: SecretRef
110
+ into?: string
111
+ }): WorkspaceSkill {
112
+ return { kind: 'git', ...input }
113
+ }
114
+
115
+ export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' | 'auto'
116
+
117
+ export interface WorkspaceDefinition {
118
+ source: WorkspaceSource
119
+ /** Defaults to `'auto'` — detect from the lockfile after the source lands. */
120
+ packageManager?: PackageManager
121
+ /** Commands run once during bootstrap. Accepts a string array (serial) or a builder function for serial/parallel groups. */
122
+ setup?: SetupInput
123
+ /** Named commands the agent/user can invoke (e.g. { test: 'pnpm test' }). */
124
+ scripts?: Record<string, string>
125
+ /** Guidance/config projected into the harness. */
126
+ skills?: Array<WorkspaceSkill>
127
+ /**
128
+ * Natural-language instructions written to AGENTS.md (and symlinked as
129
+ * CLAUDE.md, GEMINI.md, etc.) inside the sandbox during bootstrap.
130
+ */
131
+ instructions?: string
132
+ /**
133
+ * Harness plugin identifiers installed idempotently by each harness
134
+ * projector (e.g. `['@anthropic/plugin-foo']` for Claude Code).
135
+ */
136
+ plugins?: Array<string>
137
+ /**
138
+ * Typed secret references. The underlying values are injected into the
139
+ * sandbox env at create/resume — NEVER written to snapshots, the
140
+ * SandboxStore, or the event log.
141
+ */
142
+ secrets?: Secrets
143
+ /** Workspace root inside the sandbox. Defaults to `/workspace`. */
144
+ root?: string
145
+ }
146
+
147
+ export function defineWorkspace(
148
+ definition: WorkspaceDefinition,
149
+ ): WorkspaceDefinition {
150
+ return definition
151
+ }