@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.
- package/README.md +182 -0
- package/dist/esm/agents-file.d.ts +36 -0
- package/dist/esm/agents-file.js +44 -0
- package/dist/esm/agents-file.js.map +1 -0
- package/dist/esm/approvals.d.ts +38 -0
- package/dist/esm/approvals.js +36 -0
- package/dist/esm/approvals.js.map +1 -0
- package/dist/esm/bootstrap.d.ts +17 -0
- package/dist/esm/bootstrap.js +124 -0
- package/dist/esm/bootstrap.js.map +1 -0
- package/dist/esm/bridge-events.d.ts +21 -0
- package/dist/esm/bridge-events.js +76 -0
- package/dist/esm/bridge-events.js.map +1 -0
- package/dist/esm/capabilities.d.ts +26 -0
- package/dist/esm/capabilities.js +29 -0
- package/dist/esm/capabilities.js.map +1 -0
- package/dist/esm/contracts.d.ts +211 -0
- package/dist/esm/errors.d.ts +16 -0
- package/dist/esm/errors.js +25 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/git-exec.d.ts +2 -0
- package/dist/esm/git-exec.js +68 -0
- package/dist/esm/git-exec.js.map +1 -0
- package/dist/esm/harness-cwd.d.ts +2 -0
- package/dist/esm/harness-cwd.js +24 -0
- package/dist/esm/harness-cwd.js.map +1 -0
- package/dist/esm/index.d.ts +39 -0
- package/dist/esm/index.js +103 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/key.d.ts +20 -0
- package/dist/esm/key.js +41 -0
- package/dist/esm/key.js.map +1 -0
- package/dist/esm/middleware.d.ts +5 -0
- package/dist/esm/middleware.js +140 -0
- package/dist/esm/middleware.js.map +1 -0
- package/dist/esm/ngrok.d.ts +16 -0
- package/dist/esm/ngrok.js +54 -0
- package/dist/esm/ngrok.js.map +1 -0
- package/dist/esm/policy.d.ts +47 -0
- package/dist/esm/policy.js +44 -0
- package/dist/esm/policy.js.map +1 -0
- package/dist/esm/projection.d.ts +31 -0
- package/dist/esm/projection.js +9 -0
- package/dist/esm/projection.js.map +1 -0
- package/dist/esm/remote-tools.d.ts +48 -0
- package/dist/esm/remote-tools.js +76 -0
- package/dist/esm/remote-tools.js.map +1 -0
- package/dist/esm/run-log.d.ts +81 -0
- package/dist/esm/run-log.js +107 -0
- package/dist/esm/run-log.js.map +1 -0
- package/dist/esm/run.d.ts +58 -0
- package/dist/esm/run.js +89 -0
- package/dist/esm/run.js.map +1 -0
- package/dist/esm/runner.d.ts +21 -0
- package/dist/esm/runner.js +54 -0
- package/dist/esm/runner.js.map +1 -0
- package/dist/esm/sandbox.d.ts +79 -0
- package/dist/esm/sandbox.js +125 -0
- package/dist/esm/sandbox.js.map +1 -0
- package/dist/esm/secrets.d.ts +37 -0
- package/dist/esm/secrets.js +59 -0
- package/dist/esm/secrets.js.map +1 -0
- package/dist/esm/setup-plan.d.ts +13 -0
- package/dist/esm/setup-plan.js +16 -0
- package/dist/esm/setup-plan.js.map +1 -0
- package/dist/esm/shell.d.ts +45 -0
- package/dist/esm/shell.js +164 -0
- package/dist/esm/shell.js.map +1 -0
- package/dist/esm/store.d.ts +53 -0
- package/dist/esm/store.js +34 -0
- package/dist/esm/store.js.map +1 -0
- package/dist/esm/tool-bridge.d.ts +130 -0
- package/dist/esm/tool-bridge.js +197 -0
- package/dist/esm/tool-bridge.js.map +1 -0
- package/dist/esm/watch.d.ts +36 -0
- package/dist/esm/watch.js +144 -0
- package/dist/esm/watch.js.map +1 -0
- package/dist/esm/workspace.d.ts +128 -0
- package/dist/esm/workspace.js +42 -0
- package/dist/esm/workspace.js.map +1 -0
- package/package.json +72 -0
- package/skills/ai-sandbox/SKILL.md +366 -0
- package/src/agents-file.ts +101 -0
- package/src/approvals.ts +96 -0
- package/src/bootstrap.ts +196 -0
- package/src/bridge-events.ts +112 -0
- package/src/capabilities.ts +47 -0
- package/src/contracts.ts +236 -0
- package/src/errors.ts +31 -0
- package/src/git-exec.ts +114 -0
- package/src/harness-cwd.ts +38 -0
- package/src/index.ts +222 -0
- package/src/key.ts +70 -0
- package/src/middleware.ts +233 -0
- package/src/ngrok.ts +85 -0
- package/src/policy.ts +111 -0
- package/src/projection.ts +46 -0
- package/src/remote-tools.ts +180 -0
- package/src/run-log.ts +224 -0
- package/src/run.ts +167 -0
- package/src/runner.ts +99 -0
- package/src/sandbox.ts +259 -0
- package/src/secrets.ts +101 -0
- package/src/setup-plan.ts +25 -0
- package/src/shell.ts +288 -0
- package/src/store.ts +83 -0
- package/src/tool-bridge.ts +399 -0
- package/src/watch.ts +256 -0
- 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
|
+
}
|