@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/sandbox.ts ADDED
@@ -0,0 +1,259 @@
1
+ /**
2
+ * `defineSandbox()` returns a LAZY controller — it never creates a sandbox at
3
+ * definition time. `withSandbox()` (and advanced users) call `ensure()` to
4
+ * resume-or-create, following: provider.resume → provider.restoreSnapshot →
5
+ * create + bootstrap. The controller folds provider/workspace/policy/lifecycle
6
+ * into a stable instance key and coordinates through the (optional) lock +
7
+ * sandbox stores.
8
+ */
9
+ import { bootstrapWorkspace } from './bootstrap'
10
+ import { resolveAllSecrets } from './secrets'
11
+ import { computeSandboxKey } from './key'
12
+ import { InMemoryLockStore, InMemorySandboxStore } from './store'
13
+ import type { SandboxFileEvent } from '@tanstack/ai'
14
+ import type { SandboxHandle, SandboxProvider } from './contracts'
15
+ import type { SandboxKeyInput } from './key'
16
+ import type { LockStore, SandboxStore } from './store'
17
+ import type { SandboxPolicy } from './policy'
18
+ import type { WorkspaceDefinition } from './workspace'
19
+
20
+ /**
21
+ * Sandbox-scoped hooks declared on `defineSandbox`. File hooks fire for every
22
+ * create/change/delete during a chat run; lifecycle hooks fire server-side.
23
+ */
24
+ export interface SandboxHooks {
25
+ onFile?: (e: SandboxFileEvent) => void | Promise<void>
26
+ onFileCreate?: (e: SandboxFileEvent) => void | Promise<void>
27
+ onFileChange?: (e: SandboxFileEvent) => void | Promise<void>
28
+ onFileDelete?: (e: SandboxFileEvent) => void | Promise<void>
29
+ onReady?: (handle: SandboxHandle) => void | Promise<void>
30
+ onError?: (err: unknown) => void | Promise<void>
31
+ onDestroy?: () => void | Promise<void>
32
+ }
33
+
34
+ export type ReuseStrategy = 'thread' | 'none'
35
+ export type SnapshotStrategy = 'after-setup' | 'after-run' | 'none'
36
+
37
+ export interface SandboxLifecycle {
38
+ /** `'thread'` resumes one sandbox per thread; `'none'` is fresh per run. */
39
+ reuse?: ReuseStrategy
40
+ /** When to snapshot (provider-permitting). */
41
+ snapshot?: SnapshotStrategy
42
+ /** Hint for how long a provider should keep the sandbox warm between runs. */
43
+ keepAlive?: string
44
+ /** Destroy the sandbox after the run completes. */
45
+ destroyOnComplete?: boolean
46
+ /**
47
+ * Maximum age of a sandbox record before it is discarded and re-created
48
+ * instead of resumed. Accepts `'<n>h'` (hours) or `'<n>m'` (minutes),
49
+ * e.g. `'2h'` or `'30m'`.
50
+ */
51
+ snapshotMaxAge?: string
52
+ }
53
+
54
+ export interface SandboxConfig {
55
+ id: string
56
+ provider: SandboxProvider
57
+ workspace?: WorkspaceDefinition
58
+ policy?: SandboxPolicy
59
+ lifecycle?: SandboxLifecycle
60
+ /** Sandbox-scoped file/lifecycle hooks. */
61
+ hooks?: SandboxHooks
62
+ /** Watch the workspace for file events (default true). Set false to disable. */
63
+ fileEvents?: boolean
64
+ }
65
+
66
+ /** Context passed to `ensure()` by `withSandbox` (or advanced callers). */
67
+ export interface SandboxEnsureContext {
68
+ threadId: string
69
+ runId: string
70
+ /** Persistence seam; falls back to an in-memory store when absent. */
71
+ store?: SandboxStore
72
+ /** Lock seam; falls back to an in-memory lock when absent. */
73
+ locks?: LockStore
74
+ tenant?: { userId?: string; orgId?: string }
75
+ signal?: AbortSignal
76
+ }
77
+
78
+ export interface SandboxDefinition {
79
+ readonly id: string
80
+ readonly provider: SandboxProvider
81
+ readonly workspace?: WorkspaceDefinition
82
+ readonly policy?: SandboxPolicy
83
+ readonly lifecycle?: SandboxLifecycle
84
+ /** Sandbox-scoped file/lifecycle hooks. */
85
+ readonly hooks?: SandboxHooks
86
+ /** Watch the workspace for file events (default true). Set false to disable. */
87
+ readonly fileEvents?: boolean
88
+ /** Compound instance key for a given run context. */
89
+ key: (ctx: SandboxEnsureContext) => string
90
+ /** Resume-or-create the sandbox for this thread/run. */
91
+ ensure: (ctx: SandboxEnsureContext) => Promise<SandboxHandle>
92
+ /** Tear down the sandbox recorded for this key. */
93
+ destroy: (ctx: SandboxEnsureContext) => Promise<void>
94
+ }
95
+
96
+ /**
97
+ * Parse a human-readable duration string into milliseconds.
98
+ * Supports `'<n>h'` (hours) and `'<n>m'` (minutes).
99
+ * Returns `undefined` when the input is undefined or the format is unrecognised.
100
+ */
101
+ function parseMaxAgeMs(value: string | undefined): number | undefined {
102
+ if (value === undefined) return undefined
103
+ const hourMatch = /^(\d+)h$/.exec(value)
104
+ if (hourMatch) return Number(hourMatch[1]) * 60 * 60 * 1000
105
+ const minuteMatch = /^(\d+)m$/.exec(value)
106
+ if (minuteMatch) return Number(minuteMatch[1]) * 60 * 1000
107
+ return undefined
108
+ }
109
+
110
+ // Process-lifetime fallbacks shared across all definitions so concurrent
111
+ // ensures for the same key serialize even without an injected store/lock.
112
+ const fallbackStore = new InMemorySandboxStore()
113
+ const fallbackLocks = new InMemoryLockStore()
114
+
115
+ export function defineSandbox(config: SandboxConfig): SandboxDefinition {
116
+ const keyInputFor = (ctx: SandboxEnsureContext): SandboxKeyInput => ({
117
+ threadId:
118
+ config.lifecycle?.reuse === 'none'
119
+ ? `${ctx.threadId}:${ctx.runId}`
120
+ : ctx.threadId,
121
+ sandboxId: config.id,
122
+ providerName: config.provider.name,
123
+ workspace: config.workspace,
124
+ tenant: ctx.tenant,
125
+ })
126
+
127
+ const ensure = async (ctx: SandboxEnsureContext): Promise<SandboxHandle> => {
128
+ const store = ctx.store ?? fallbackStore
129
+ const locks = ctx.locks ?? fallbackLocks
130
+ const key = computeSandboxKey(keyInputFor(ctx))
131
+ const caps = config.provider.capabilities()
132
+
133
+ return locks.withLock(`sandbox:${key}`, async () => {
134
+ const effectiveSnapshot: SnapshotStrategy =
135
+ config.lifecycle?.snapshot ?? (caps.snapshots ? 'after-setup' : 'none')
136
+ const maxAgeMs = parseMaxAgeMs(config.lifecycle?.snapshotMaxAge)
137
+
138
+ const existing = await store.get(key)
139
+ if (existing) {
140
+ // Check whether the record has exceeded snapshotMaxAge; if so,
141
+ // discard and fall through to a fresh create.
142
+ const tooOld =
143
+ maxAgeMs !== undefined && Date.now() - existing.updatedAt > maxAgeMs
144
+
145
+ if (!tooOld) {
146
+ // 1) Try to reconnect to the still-running sandbox.
147
+ const resumed = await config.provider.resume({
148
+ id: existing.providerSandboxId,
149
+ signal: ctx.signal,
150
+ })
151
+ if (resumed) {
152
+ await store.upsert({
153
+ ...existing,
154
+ latestRunId: ctx.runId,
155
+ updatedAt: Date.now(),
156
+ })
157
+ return resumed
158
+ }
159
+ // 2) Else restore from the latest snapshot, if supported.
160
+ if (
161
+ existing.latestSnapshotId &&
162
+ caps.snapshots &&
163
+ config.provider.restoreSnapshot
164
+ ) {
165
+ const restored = await config.provider.restoreSnapshot({
166
+ snapshotId: existing.latestSnapshotId,
167
+ workspace: config.workspace,
168
+ policy: config.policy,
169
+ env:
170
+ config.workspace?.secrets !== undefined
171
+ ? resolveAllSecrets(config.workspace.secrets)
172
+ : undefined,
173
+ signal: ctx.signal,
174
+ })
175
+ await store.upsert({
176
+ ...existing,
177
+ providerSandboxId: restored.id,
178
+ latestRunId: ctx.runId,
179
+ updatedAt: Date.now(),
180
+ })
181
+ return restored
182
+ }
183
+ }
184
+ // 3) Else fall through and re-create under the same identity
185
+ // (capability-aware degradation for ephemeral-disk providers, or
186
+ // snapshotMaxAge TTL exceeded).
187
+ }
188
+
189
+ const created = await config.provider.create({
190
+ workspace: config.workspace,
191
+ policy: config.policy,
192
+ env:
193
+ config.workspace?.secrets !== undefined
194
+ ? resolveAllSecrets(config.workspace.secrets)
195
+ : undefined,
196
+ signal: ctx.signal,
197
+ })
198
+
199
+ if (config.workspace) {
200
+ try {
201
+ await bootstrapWorkspace(created, config.workspace, {
202
+ signal: ctx.signal,
203
+ })
204
+ } catch (error) {
205
+ // Bootstrap failed after the sandbox was created but before it was
206
+ // recorded — destroy the orphan so a failed/retried run doesn't leak
207
+ // a (billed) sandbox, then surface the original error.
208
+ await created.destroy().catch(() => {})
209
+ throw error
210
+ }
211
+ }
212
+
213
+ let latestSnapshotId: string | undefined
214
+ if (
215
+ effectiveSnapshot === 'after-setup' &&
216
+ caps.snapshots &&
217
+ created.snapshot
218
+ ) {
219
+ latestSnapshotId = (await created.snapshot('after-setup')).id
220
+ }
221
+
222
+ await store.upsert({
223
+ key,
224
+ provider: config.provider.name,
225
+ providerSandboxId: created.id,
226
+ latestSnapshotId,
227
+ threadId: ctx.threadId,
228
+ latestRunId: ctx.runId,
229
+ updatedAt: Date.now(),
230
+ })
231
+ return created
232
+ })
233
+ }
234
+
235
+ const destroy = async (ctx: SandboxEnsureContext): Promise<void> => {
236
+ const store = ctx.store ?? fallbackStore
237
+ const key = computeSandboxKey(keyInputFor(ctx))
238
+ const existing = await store.get(key)
239
+ if (!existing) return
240
+ await config.provider.destroy({
241
+ id: existing.providerSandboxId,
242
+ signal: ctx.signal,
243
+ })
244
+ await store.delete(key)
245
+ }
246
+
247
+ return {
248
+ id: config.id,
249
+ provider: config.provider,
250
+ workspace: config.workspace,
251
+ policy: config.policy,
252
+ lifecycle: config.lifecycle,
253
+ hooks: config.hooks,
254
+ fileEvents: config.fileEvents,
255
+ key: (ctx) => computeSandboxKey(keyInputFor(ctx)),
256
+ ensure,
257
+ destroy,
258
+ }
259
+ }
package/src/secrets.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Type-safe secret references for sandbox workspace definitions.
3
+ *
4
+ * Values are stored in a Map under a non-enumerable symbol key on the returned
5
+ * object so that `Object.keys(secrets)` only yields the ref names, never the
6
+ * registry or the underlying plaintext values.
7
+ */
8
+
9
+ /** A reference to a named secret — carries only the name, never the value. */
10
+ export type SecretRef = { readonly __secretName: string }
11
+
12
+ /**
13
+ * A map of named SecretRef properties. The underlying value registry is stored
14
+ * under a non-enumerable symbol so iterating the object never exposes it.
15
+ */
16
+ export type Secrets<TKeys extends string = string> = {
17
+ readonly [P in TKeys]: SecretRef
18
+ }
19
+
20
+ /** Internal symbol used to store the value registry on a Secrets object. */
21
+ const REGISTRY = Symbol('secrets.registry')
22
+
23
+ /** Create a typed secrets object from a plain record of name→value pairs. */
24
+ export function createSecrets<T extends Record<string, string>>(
25
+ values: T,
26
+ ): Secrets<keyof T & string> {
27
+ const registry = new Map<string, string>(Object.entries(values))
28
+ const obj = {} as Record<string, SecretRef>
29
+
30
+ for (const name of Object.keys(values)) {
31
+ obj[name] = Object.freeze({ __secretName: name })
32
+ }
33
+
34
+ Object.defineProperty(obj, REGISTRY, {
35
+ value: registry,
36
+ enumerable: false,
37
+ configurable: false,
38
+ writable: false,
39
+ })
40
+
41
+ return obj as Secrets<keyof T & string>
42
+ }
43
+
44
+ /** Marker type for a bearer-token value derived from a SecretRef. */
45
+ export type BearerRef = { readonly __bearerRef: SecretRef }
46
+
47
+ /** Create a bearer-token marker that resolves to `Bearer <value>` at runtime. */
48
+ export function bearer(ref: SecretRef): BearerRef {
49
+ return Object.freeze({ __bearerRef: ref })
50
+ }
51
+
52
+ /** Return true when `x` is a SecretRef. */
53
+ export function isSecretRef(x: unknown): x is SecretRef {
54
+ return (
55
+ typeof x === 'object' &&
56
+ x !== null &&
57
+ typeof (x as Record<string, unknown>)['__secretName'] === 'string'
58
+ )
59
+ }
60
+
61
+ /** Resolve a SecretRef to its plaintext value using the secrets object. */
62
+ export function resolveSecret(secrets: Secrets, ref: SecretRef): string {
63
+ const registry = Reflect.get(secrets, REGISTRY) as
64
+ | Map<string, string>
65
+ | undefined
66
+ if (registry === undefined) {
67
+ throw new Error(
68
+ 'resolveSecret: secrets object was not created by createSecrets',
69
+ )
70
+ }
71
+ const value = registry.get(ref.__secretName)
72
+ if (value === undefined) {
73
+ throw new Error(`resolveSecret: unknown secret "${ref.__secretName}"`)
74
+ }
75
+ return value
76
+ }
77
+
78
+ /** Resolve a BearerRef to a `Bearer <value>` string. */
79
+ export function resolveBearer(secrets: Secrets, ref: BearerRef): string {
80
+ return `Bearer ${resolveSecret(secrets, ref.__bearerRef)}`
81
+ }
82
+
83
+ /**
84
+ * Resolve all secrets in a Secrets object to a plain `Record<string, string>`
85
+ * suitable for injecting into a process environment.
86
+ */
87
+ export function resolveAllSecrets(secrets: Secrets): Record<string, string> {
88
+ const registry = Reflect.get(secrets, REGISTRY) as
89
+ | Map<string, string>
90
+ | undefined
91
+ if (registry === undefined) {
92
+ throw new Error(
93
+ 'resolveAllSecrets: secrets object was not created by createSecrets',
94
+ )
95
+ }
96
+ const result: Record<string, string> = {}
97
+ for (const [key, value] of registry.entries()) {
98
+ result[key] = value
99
+ }
100
+ return result
101
+ }
@@ -0,0 +1,25 @@
1
+ export type SetupGroup =
2
+ | { kind: 'serial'; command: string }
3
+ | { kind: 'parallel'; commands: Array<string> }
4
+
5
+ export interface SetupBuilder {
6
+ serial: (command: string) => void
7
+ parallel: (commands: Array<string>) => void
8
+ }
9
+
10
+ export type SetupInput = Array<string> | ((builder: SetupBuilder) => void)
11
+
12
+ export function buildSetupPlan(
13
+ input: SetupInput | undefined,
14
+ ): Array<SetupGroup> {
15
+ if (input === undefined) return []
16
+ if (Array.isArray(input)) {
17
+ return input.map((command) => ({ kind: 'serial', command }))
18
+ }
19
+ const groups: Array<SetupGroup> = []
20
+ input({
21
+ serial: (command) => groups.push({ kind: 'serial', command }),
22
+ parallel: (commands) => groups.push({ kind: 'parallel', commands }),
23
+ })
24
+ return groups
25
+ }
package/src/shell.ts ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Internal persistent bootstrap shell.
3
+ *
4
+ * Spawns a single `sh` process via {@link SandboxHandle.process.spawn} and
5
+ * drives it over stdin/stdout with a sentinel-echo protocol. Commands run
6
+ * sequentially inside the same shell so `cd`, exported variables, etc. persist
7
+ * across calls — exactly the exec model the bootstrap setup plan needs.
8
+ *
9
+ * Providers WITHOUT a writable host→process stdin (`capabilities.writableStdin
10
+ * === false`, e.g. Cloudflare / Daytona / Vercel) can't be driven over stdin, so
11
+ * {@link createBootstrapShell} transparently falls back to an exec-backed shell
12
+ * ({@link createExecBootstrapShell}) that threads `cwd`/env across `exec` calls
13
+ * to reproduce the same persistent-shell semantics.
14
+ *
15
+ * This module is internal-only and must NOT be re-exported from
16
+ * `packages/ai-sandbox/src/index.ts`.
17
+ */
18
+ import type { SandboxHandle } from './contracts'
19
+
20
+ /**
21
+ * Parse the output of `export -p` (or `declare -x`) into a plain env map.
22
+ * Shared by the stdin shell's `forkState` and the exec-backed shell.
23
+ */
24
+ function parseExports(output: string): Record<string, string> {
25
+ const env: Record<string, string> = {}
26
+ for (const line of output.split('\n')) {
27
+ const trimmed = line.trim()
28
+ // Match `declare -x KEY=...` or `export KEY=...` forms.
29
+ const match =
30
+ /^(?:declare\s+-x\s+|export\s+)([A-Za-z_][A-Za-z0-9_]*)(?:="((?:[^"\\]|\\.)*)")?$/.exec(
31
+ trimmed,
32
+ )
33
+ if (match === null) continue
34
+ const key = match[1]
35
+ if (key === undefined) continue
36
+ // Value may be absent for exported-but-unset vars; skip those.
37
+ const raw = match[2]
38
+ if (raw === undefined) continue
39
+ // Unescape backslash-escaped chars inside double quotes.
40
+ env[key] = raw.replace(/\\(.)/g, '$1')
41
+ }
42
+ return env
43
+ }
44
+
45
+ /** The surface the bootstrap engine uses. */
46
+ export interface BootstrapShell {
47
+ /** Run a shell command and capture its stdout + exit code. */
48
+ run: (command: string) => Promise<{ exitCode: number; stdout: string }>
49
+ /**
50
+ * Snapshot the shell's current working directory and exported environment.
51
+ * Used to fork parallel exec calls that inherit the serial shell's state.
52
+ */
53
+ forkState: () => Promise<{ cwd: string; env: Record<string, string> }>
54
+ /** End the shell session (closes stdin, kills the process). */
55
+ dispose: () => Promise<void>
56
+ }
57
+
58
+ /** Options for {@link createBootstrapShell}. */
59
+ export interface BootstrapShellOptions {
60
+ /** Working directory to start the shell in (passed as ProcessOptions.cwd). */
61
+ cwd?: string
62
+ }
63
+
64
+ /**
65
+ * Spawn one `sh` process and return a {@link BootstrapShell} that drives it
66
+ * via the sentinel-echo protocol.
67
+ *
68
+ * Protocol: for each `run(cmd)` call, we write
69
+ * `<cmd>; printf "\n__BSSH_<N>__ $?\n"` to stdin, then read stdout lines
70
+ * until we see a line matching `__BSSH_<N>__ <exitCode>`. Everything before
71
+ * that line is the command's stdout; the trailing integer is the exit code.
72
+ * The counter `N` is a module-level monotonic integer — no Date.now / random.
73
+ */
74
+ export async function createBootstrapShell(
75
+ handle: SandboxHandle,
76
+ opts: BootstrapShellOptions = {},
77
+ ): Promise<BootstrapShell> {
78
+ // Providers without a writable host→process stdin can't run the sentinel-echo
79
+ // protocol below (it feeds commands over stdin), so use the exec-backed shell.
80
+ if (!handle.capabilities.writableStdin) {
81
+ return createExecBootstrapShell(handle, opts)
82
+ }
83
+ const proc = await handle.process.spawn('sh', { cwd: opts.cwd })
84
+
85
+ /*
86
+ * We need to read stdout lines across multiple run() calls while keeping
87
+ * the iterator open. Buffer chunks into lines manually.
88
+ */
89
+ const lineBuffer: Array<string> = []
90
+ let pending: Array<(line: string) => void> = []
91
+ let streamDone = false
92
+
93
+ /** Feed the stdout async-iterable into the shared line queue. */
94
+ async function drainStdout(): Promise<void> {
95
+ let partial = ''
96
+ for await (const chunk of proc.stdout) {
97
+ partial += chunk
98
+ const parts = partial.split('\n')
99
+ // All but the last element are complete lines.
100
+ for (let i = 0; i < parts.length - 1; i++) {
101
+ const line = parts[i] as string
102
+ const resolver = pending.shift()
103
+ if (resolver !== undefined) {
104
+ resolver(line)
105
+ } else {
106
+ lineBuffer.push(line)
107
+ }
108
+ }
109
+ partial = parts[parts.length - 1] as string
110
+ }
111
+ // Flush any trailing partial line.
112
+ if (partial.length > 0) {
113
+ const line = partial
114
+ const resolver = pending.shift()
115
+ if (resolver !== undefined) {
116
+ resolver(line)
117
+ } else {
118
+ lineBuffer.push(line)
119
+ }
120
+ }
121
+ streamDone = true
122
+ // Resolve any remaining waiters with an empty sentinel so they unblock.
123
+ for (const resolver of pending) {
124
+ resolver('')
125
+ }
126
+ pending = []
127
+ }
128
+
129
+ // Start draining immediately; do NOT await — runs concurrently.
130
+ const drainPromise = drainStdout()
131
+
132
+ /** Read the next line from the shared queue. */
133
+ function nextLine(): Promise<string> {
134
+ const buffered = lineBuffer.shift()
135
+ if (buffered !== undefined) {
136
+ return Promise.resolve(buffered)
137
+ }
138
+ if (streamDone) {
139
+ return Promise.resolve('')
140
+ }
141
+ return new Promise<string>((resolve) => {
142
+ pending.push(resolve)
143
+ })
144
+ }
145
+
146
+ let counter = 0
147
+
148
+ async function run(
149
+ command: string,
150
+ ): Promise<{ exitCode: number; stdout: string }> {
151
+ const id = counter
152
+ counter += 1
153
+ const sentinel = `__BSSH_${id}__`
154
+
155
+ // Write the command followed by a sentinel printf to stdin. Merge the
156
+ // command's stderr into stdout (`{ … ; } 2>&1`) so a failing setup step's
157
+ // error text is captured and can be surfaced — otherwise only the exit code
158
+ // is visible. `$?` after the group is still the command's own exit code.
159
+ await proc.stdin.write(
160
+ `{ ${command} ; } 2>&1; printf "\\n${sentinel} $?\\n"\n`,
161
+ )
162
+
163
+ const outputLines: Array<string> = []
164
+
165
+ // Read lines until we find the sentinel.
166
+ for (;;) {
167
+ const line = await nextLine()
168
+ if (line.startsWith(`${sentinel} `)) {
169
+ const codeStr = line.slice(sentinel.length + 1).trim()
170
+ const exitCode = parseInt(codeStr, 10)
171
+ return {
172
+ exitCode: Number.isFinite(exitCode) ? exitCode : 1,
173
+ stdout: outputLines.join('\n'),
174
+ }
175
+ }
176
+ outputLines.push(line)
177
+ }
178
+ }
179
+
180
+ async function forkState(): Promise<{
181
+ cwd: string
182
+ env: Record<string, string>
183
+ }> {
184
+ const pwdResult = await run('pwd')
185
+ const cwd = pwdResult.stdout.trim()
186
+
187
+ const exportResult = await run('export -p')
188
+ return { cwd, env: parseExports(exportResult.stdout) }
189
+ }
190
+
191
+ async function dispose(): Promise<void> {
192
+ await proc.stdin.end()
193
+ await proc.kill()
194
+ // Drain the stdout iterator to completion so there are no dangling promises.
195
+ await drainPromise
196
+ }
197
+
198
+ return { run, forkState, dispose }
199
+ }
200
+
201
+ /**
202
+ * Exec-backed {@link BootstrapShell} for providers WITHOUT a writable stdin.
203
+ *
204
+ * There is no persistent process to feed commands into, so persistence of `cd`
205
+ * and exported variables is reproduced by threading state across discrete
206
+ * {@link SandboxHandle.process.exec} calls: each `run()` executes the command in
207
+ * the tracked cwd+env, then captures the resulting `pwd` and `export -p` (via
208
+ * marker lines) so the NEXT command inherits any directory change or exports.
209
+ */
210
+ export function createExecBootstrapShell(
211
+ handle: SandboxHandle,
212
+ opts: BootstrapShellOptions = {},
213
+ ): BootstrapShell {
214
+ let cwd = opts.cwd ?? '/'
215
+ let env: Record<string, string> = {}
216
+ let counter = 0
217
+
218
+ async function run(
219
+ command: string,
220
+ ): Promise<{ exitCode: number; stdout: string }> {
221
+ const id = counter
222
+ counter += 1
223
+ const sentinel = `__BSSH_${id}__`
224
+
225
+ // Run the command, then emit its exit code, cwd and exported env behind
226
+ // marker lines so we can recover state even when the command itself fails
227
+ // (no `set -e`). Capturing `$?` immediately after the command keeps the
228
+ // reported exit code the command's own, not the trailing introspection's.
229
+ const script = [
230
+ command,
231
+ `__bssh_rc=$?`,
232
+ `printf '\\n%s %s\\n' '${sentinel}' "$__bssh_rc"`,
233
+ `printf '%s\\n' '${sentinel}_CWD'`,
234
+ `pwd`,
235
+ `printf '%s\\n' '${sentinel}_ENV'`,
236
+ `export -p`,
237
+ ].join('\n')
238
+
239
+ const res = await handle.process.exec(script, { cwd, env })
240
+
241
+ const cmdOut: Array<string> = []
242
+ const cwdLines: Array<string> = []
243
+ const envLines: Array<string> = []
244
+ let exitCode = res.exitCode
245
+ let phase: 'cmd' | 'await-cwd' | 'cwd' | 'env' = 'cmd'
246
+
247
+ for (const line of res.stdout.split('\n')) {
248
+ if (phase === 'cmd') {
249
+ if (line.startsWith(`${sentinel} `)) {
250
+ const parsed = parseInt(line.slice(sentinel.length + 1).trim(), 10)
251
+ exitCode = Number.isFinite(parsed) ? parsed : res.exitCode
252
+ phase = 'await-cwd'
253
+ continue
254
+ }
255
+ cmdOut.push(line)
256
+ } else if (phase === 'await-cwd') {
257
+ if (line === `${sentinel}_CWD`) phase = 'cwd'
258
+ } else if (phase === 'cwd') {
259
+ if (line === `${sentinel}_ENV`) phase = 'env'
260
+ else cwdLines.push(line)
261
+ } else {
262
+ envLines.push(line)
263
+ }
264
+ }
265
+
266
+ // `pwd` prints a single line; the last non-empty one is the new cwd.
267
+ const newCwd = cwdLines
268
+ .map((l) => l.trim())
269
+ .filter(Boolean)
270
+ .pop()
271
+ if (newCwd) cwd = newCwd
272
+ const newEnv = parseExports(envLines.join('\n'))
273
+ if (Object.keys(newEnv).length > 0) env = newEnv
274
+
275
+ return { exitCode, stdout: cmdOut.join('\n') }
276
+ }
277
+
278
+ function forkState(): Promise<{ cwd: string; env: Record<string, string> }> {
279
+ return Promise.resolve({ cwd, env: { ...env } })
280
+ }
281
+
282
+ function dispose(): Promise<void> {
283
+ // Nothing to tear down — there is no persistent process.
284
+ return Promise.resolve()
285
+ }
286
+
287
+ return { run, forkState, dispose }
288
+ }