@yolk-sdk/sandbox 0.1.0-canary.18

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/dist/agent.d.mts +48 -0
  4. package/dist/agent.d.mts.map +1 -0
  5. package/dist/agent.mjs +173 -0
  6. package/dist/agent.mjs.map +1 -0
  7. package/dist/errors.d.mts +44 -0
  8. package/dist/errors.d.mts.map +1 -0
  9. package/dist/errors.mjs +40 -0
  10. package/dist/errors.mjs.map +1 -0
  11. package/dist/index.d.mts +7 -0
  12. package/dist/index.mjs +7 -0
  13. package/dist/lifecycle.d.mts +52 -0
  14. package/dist/lifecycle.d.mts.map +1 -0
  15. package/dist/lifecycle.mjs +118 -0
  16. package/dist/lifecycle.mjs.map +1 -0
  17. package/dist/model.d.mts +84 -0
  18. package/dist/model.d.mts.map +1 -0
  19. package/dist/model.mjs +72 -0
  20. package/dist/model.mjs.map +1 -0
  21. package/dist/name.d.mts +5 -0
  22. package/dist/name.d.mts.map +1 -0
  23. package/dist/name.mjs +19 -0
  24. package/dist/name.mjs.map +1 -0
  25. package/dist/service.d.mts +15 -0
  26. package/dist/service.d.mts.map +1 -0
  27. package/dist/service.mjs +7 -0
  28. package/dist/service.mjs.map +1 -0
  29. package/dist/state-store.d.mts +18 -0
  30. package/dist/state-store.d.mts.map +1 -0
  31. package/dist/state-store.mjs +7 -0
  32. package/dist/state-store.mjs.map +1 -0
  33. package/dist/testing/index.d.mts +21 -0
  34. package/dist/testing/index.d.mts.map +1 -0
  35. package/dist/testing/index.mjs +25 -0
  36. package/dist/testing/index.mjs.map +1 -0
  37. package/dist/vercel/client.d.mts +70 -0
  38. package/dist/vercel/client.d.mts.map +1 -0
  39. package/dist/vercel/client.mjs +113 -0
  40. package/dist/vercel/client.mjs.map +1 -0
  41. package/dist/vercel/index.d.mts +3 -0
  42. package/dist/vercel/index.mjs +3 -0
  43. package/dist/vercel/layer.d.mts +23 -0
  44. package/dist/vercel/layer.d.mts.map +1 -0
  45. package/dist/vercel/layer.mjs +250 -0
  46. package/dist/vercel/layer.mjs.map +1 -0
  47. package/package.json +72 -0
  48. package/src/agent.ts +304 -0
  49. package/src/errors.ts +71 -0
  50. package/src/index.ts +52 -0
  51. package/src/lifecycle.ts +186 -0
  52. package/src/model.ts +122 -0
  53. package/src/name.ts +23 -0
  54. package/src/service.ts +19 -0
  55. package/src/state-store.ts +17 -0
  56. package/src/testing/index.ts +63 -0
  57. package/src/vercel/client.ts +223 -0
  58. package/src/vercel/index.ts +24 -0
  59. package/src/vercel/layer.ts +396 -0
@@ -0,0 +1,250 @@
1
+ import { SandboxProviderError, unknownToMessage } from "../errors.mjs";
2
+ import { SandboxCommandResult, SandboxPreviewUrl, SandboxResources } from "../model.mjs";
3
+ import { absoluteSandboxCwd, backgroundSandboxProbeMs, defaultSandboxPorts, initialSandboxState, normalizeWorkspaceCwd, sandboxCommandTimeoutMs, sandboxLifecycleOrDefault, sandboxSourceOrDefault, sandboxStateDecision, sandboxTimeoutExtendDeltaMs, touchSandboxState, validateSandboxCommand } from "../lifecycle.mjs";
4
+ import { Sandbox } from "../service.mjs";
5
+ import { makeVercelSandboxName } from "../name.mjs";
6
+ import { SandboxStateStore } from "../state-store.mjs";
7
+ import { VercelSandboxClient, VercelSandboxClientLive } from "./client.mjs";
8
+ import { Array, Clock, Duration, Effect, Layer, Option, Ref, Semaphore } from "effect";
9
+ //#region src/vercel/layer.ts
10
+ const defaultVercelResources = SandboxResources.make({ vcpus: 2 });
11
+ const defaultVercelRuntime = "node24";
12
+ const providerError = (operation, error) => new SandboxProviderError({
13
+ provider: "vercel",
14
+ operation,
15
+ message: `Vercel Sandbox ${operation} failed: ${unknownToMessage(error)}`,
16
+ underlying: error
17
+ });
18
+ const tryProvider = (operation, body) => Effect.tryPromise({
19
+ try: body,
20
+ catch: (error) => providerError(operation, error)
21
+ });
22
+ const lifecycleTimeoutMs = (lifecycle) => {
23
+ switch (lifecycle._tag) {
24
+ case "Disposable": return Math.min(lifecycle.idleTtlMs, lifecycle.maxLifetimeMs);
25
+ case "Persistent": return lifecycle.idleTtlMs;
26
+ }
27
+ };
28
+ const persistentFlag = (lifecycle) => lifecycle._tag === "Persistent";
29
+ const snapshotExpirationMs = (lifecycle) => lifecycle._tag === "Persistent" ? lifecycle.snapshotExpirationMs : void 0;
30
+ const keepLastSnapshots = (lifecycle) => lifecycle._tag === "Persistent" ? lifecycle.keepLastSnapshots : void 0;
31
+ const previewUrls = (handle, ports) => Effect.forEach(ports, (port) => Effect.try({
32
+ try: () => handle.domain(port),
33
+ catch: (error) => error
34
+ }).pipe(Effect.match({
35
+ onFailure: () => Option.none(),
36
+ onSuccess: (url) => Option.some(SandboxPreviewUrl.make({
37
+ port,
38
+ url
39
+ }))
40
+ }))).pipe(Effect.map(Array.getSomes));
41
+ const commandOutputOrEmpty = (command) => tryProvider("command.output", command.output).pipe(Effect.catchTag("SandboxProviderError", (error) => Effect.succeed({
42
+ stdout: "",
43
+ stderr: error.message
44
+ })));
45
+ const finishedCommandOutputOrEmpty = (command) => tryProvider("command.output", command.output).pipe(Effect.catchTag("SandboxProviderError", (error) => Effect.succeed({
46
+ stdout: "",
47
+ stderr: error.message
48
+ })));
49
+ const waitForegroundCommand = (command, timeoutMs) => tryProvider("command.wait", command.wait).pipe(Effect.flatMap((finished) => finishedCommandOutputOrEmpty(finished).pipe(Effect.map((output) => ({
50
+ exitCode: finished.exitCode,
51
+ stdout: output.stdout,
52
+ stderr: output.stderr,
53
+ timedOut: false
54
+ })))), Effect.timeoutOrElse({
55
+ duration: Duration.millis(timeoutMs),
56
+ orElse: () => Effect.gen(function* () {
57
+ yield* tryProvider("command.kill", () => command.kill("SIGKILL")).pipe(Effect.catchTag("SandboxProviderError", () => Effect.void));
58
+ const output = yield* commandOutputOrEmpty(command);
59
+ return {
60
+ exitCode: null,
61
+ stdout: output.stdout,
62
+ stderr: output.stderr,
63
+ timedOut: true
64
+ };
65
+ })
66
+ }));
67
+ const probeBackgroundCommand = (handle, command) => Effect.sleep(Duration.millis(backgroundSandboxProbeMs)).pipe(Effect.flatMap(() => tryProvider("command.get", () => handle.getCommand(command.id))), Effect.flatMap((current) => {
68
+ if (current.exitCode === null) return Effect.succeed({
69
+ exitCode: null,
70
+ stdout: "",
71
+ stderr: "",
72
+ timedOut: false,
73
+ backgroundId: command.id
74
+ });
75
+ return commandOutputOrEmpty(current).pipe(Effect.map((output) => ({
76
+ exitCode: current.exitCode,
77
+ stdout: output.stdout,
78
+ stderr: output.stderr,
79
+ timedOut: false
80
+ })));
81
+ }));
82
+ const createCommandFiles = (input) => {
83
+ const commandDir = `${input.workspaceRoot}/.yolk/commands/${input.commandId}`;
84
+ const commandPath = `${commandDir}/command.sh`;
85
+ const stdinPath = `${commandDir}/stdin.txt`;
86
+ const wrapperPath = `${commandDir}/wrapper.sh`;
87
+ return {
88
+ wrapperPath,
89
+ files: [
90
+ {
91
+ path: commandPath,
92
+ content: `#!/usr/bin/env bash\n${input.command}\n`,
93
+ mode: 493
94
+ },
95
+ {
96
+ path: stdinPath,
97
+ content: input.stdin ?? ""
98
+ },
99
+ {
100
+ path: wrapperPath,
101
+ content: `#!/usr/bin/env bash\nbash ${JSON.stringify(commandPath)} < ${JSON.stringify(stdinPath)}\n`,
102
+ mode: 493
103
+ }
104
+ ]
105
+ };
106
+ };
107
+ const makeVercelSandboxLayerWithClient = (config) => Layer.effect(Sandbox, Effect.gen(function* () {
108
+ const store = yield* SandboxStateStore;
109
+ const client = yield* VercelSandboxClient;
110
+ const semaphore = yield* Semaphore.make(1);
111
+ const commandCounter = yield* Ref.make(0);
112
+ const lifecycle = sandboxLifecycleOrDefault(config.lifecycle);
113
+ const source = sandboxSourceOrDefault(config.source);
114
+ const ports = config.ports ?? defaultSandboxPorts;
115
+ const resources = config.resources ?? defaultVercelResources;
116
+ const runtime = config.runtime ?? defaultVercelRuntime;
117
+ const workspaceRoot = config.workspaceRoot ?? "/vercel/sandbox";
118
+ const name = makeVercelSandboxName(config.sandboxSessionId);
119
+ const deleteNamedSandbox = (sandboxName) => tryProvider("get", () => client.get({ name: sandboxName })).pipe(Effect.flatMap((handle) => handle === null ? Effect.void : tryProvider("delete", () => handle.delete())));
120
+ const createSandbox = (input) => Effect.gen(function* () {
121
+ const handle = yield* tryProvider("create", () => client.create({
122
+ name,
123
+ source,
124
+ ports,
125
+ timeoutMs: lifecycleTimeoutMs(lifecycle),
126
+ resources,
127
+ runtime,
128
+ env: config.env,
129
+ persistent: persistentFlag(lifecycle),
130
+ snapshotExpirationMs: snapshotExpirationMs(lifecycle),
131
+ keepLastSnapshots: keepLastSnapshots(lifecycle),
132
+ networkPolicy: config.networkPolicy
133
+ }));
134
+ const state = initialSandboxState({
135
+ name,
136
+ nowMs: input.nowMs,
137
+ lifecycle
138
+ });
139
+ yield* store.save({
140
+ sandboxSessionId: config.sandboxSessionId,
141
+ state
142
+ });
143
+ return {
144
+ handle,
145
+ state,
146
+ workspaceReset: input.workspaceReset
147
+ };
148
+ });
149
+ const touchExistingSandbox = (input) => Effect.gen(function* () {
150
+ const touched = touchSandboxState({
151
+ state: input.state,
152
+ nowMs: input.nowMs,
153
+ lifecycle
154
+ });
155
+ const deltaMs = sandboxTimeoutExtendDeltaMs({
156
+ before: input.state,
157
+ after: touched
158
+ });
159
+ if (deltaMs > 0) yield* tryProvider("extendTimeout", () => input.handle.extendTimeout(deltaMs));
160
+ yield* store.save({
161
+ sandboxSessionId: config.sandboxSessionId,
162
+ state: touched
163
+ });
164
+ return {
165
+ handle: input.handle,
166
+ state: touched,
167
+ workspaceReset: input.workspaceReset
168
+ };
169
+ });
170
+ const ensureSandbox = (nowMs) => Effect.gen(function* () {
171
+ const loaded = yield* store.load(config.sandboxSessionId);
172
+ const decision = sandboxStateDecision({
173
+ state: loaded,
174
+ name,
175
+ nowMs
176
+ });
177
+ if (decision._tag === "Create") {
178
+ if (Option.isSome(loaded) && decision.workspaceReset) {
179
+ yield* deleteNamedSandbox(loaded.value.name);
180
+ yield* store.clear(config.sandboxSessionId);
181
+ }
182
+ return yield* createSandbox({
183
+ nowMs,
184
+ workspaceReset: decision.workspaceReset
185
+ });
186
+ }
187
+ const handle = yield* tryProvider("get", () => client.get({ name }));
188
+ if (handle === null) {
189
+ yield* store.clear(config.sandboxSessionId);
190
+ return yield* createSandbox({
191
+ nowMs,
192
+ workspaceReset: true
193
+ });
194
+ }
195
+ return yield* touchExistingSandbox({
196
+ handle,
197
+ state: decision.state,
198
+ nowMs,
199
+ workspaceReset: false
200
+ });
201
+ });
202
+ const nextCommandId = Ref.updateAndGet(commandCounter, (value) => value + 1).pipe(Effect.map((value) => `command-${value}`));
203
+ const runUnlocked = (input) => Effect.gen(function* () {
204
+ yield* validateSandboxCommand(input.command);
205
+ const normalizedCwd = yield* normalizeWorkspaceCwd(input.cwd);
206
+ const timeoutMs = sandboxCommandTimeoutMs(input.timeoutMs);
207
+ const active = yield* ensureSandbox(yield* Clock.currentTimeMillis);
208
+ const commandFiles = createCommandFiles({
209
+ commandId: yield* nextCommandId,
210
+ workspaceRoot,
211
+ command: input.command,
212
+ stdin: input.stdin
213
+ });
214
+ yield* tryProvider("writeFiles", () => active.handle.writeFiles(commandFiles.files));
215
+ const startedAtMs = yield* Clock.currentTimeMillis;
216
+ const command = yield* tryProvider("runCommand", () => active.handle.runDetachedCommand({
217
+ cmd: "bash",
218
+ args: [commandFiles.wrapperPath],
219
+ cwd: absoluteSandboxCwd(workspaceRoot, normalizedCwd)
220
+ }));
221
+ const output = yield* input.background === true ? probeBackgroundCommand(active.handle, command) : waitForegroundCommand(command, timeoutMs);
222
+ const endedAtMs = yield* Clock.currentTimeMillis;
223
+ const urls = yield* previewUrls(active.handle, ports);
224
+ return SandboxCommandResult.make({
225
+ exitCode: output.exitCode,
226
+ stdout: output.stdout,
227
+ stderr: output.stderr,
228
+ durationMs: Math.max(0, endedAtMs - startedAtMs),
229
+ timedOut: output.timedOut,
230
+ workspaceReset: active.workspaceReset,
231
+ ...output.backgroundId === void 0 ? {} : { backgroundId: output.backgroundId },
232
+ previewUrls: urls,
233
+ state: active.state
234
+ });
235
+ });
236
+ return Sandbox.of({
237
+ run: (input) => semaphore.withPermits(1)(runUnlocked(input)),
238
+ currentState: store.load(config.sandboxSessionId),
239
+ delete: Effect.gen(function* () {
240
+ const loaded = yield* store.load(config.sandboxSessionId);
241
+ if (Option.isSome(loaded)) yield* deleteNamedSandbox(loaded.value.name);
242
+ yield* store.clear(config.sandboxSessionId);
243
+ })
244
+ });
245
+ }));
246
+ const makeVercelSandboxLayer = (config) => makeVercelSandboxLayerWithClient(config).pipe(Layer.provide(VercelSandboxClientLive));
247
+ //#endregion
248
+ export { makeVercelSandboxLayer, makeVercelSandboxLayerWithClient };
249
+
250
+ //# sourceMappingURL=layer.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layer.mjs","names":["Arr"],"sources":["../../src/vercel/layer.ts"],"sourcesContent":["import { Array as Arr, Clock, Duration, Effect, Layer, Option, Ref, Semaphore } from 'effect'\nimport {\n absoluteSandboxCwd,\n backgroundSandboxProbeMs,\n defaultSandboxPorts,\n defaultSandboxWorkspaceRoot,\n initialSandboxState,\n normalizeWorkspaceCwd,\n sandboxCommandTimeoutMs,\n sandboxLifecycleOrDefault,\n sandboxSourceOrDefault,\n sandboxStateDecision,\n sandboxTimeoutExtendDeltaMs,\n touchSandboxState,\n validateSandboxCommand\n} from '../lifecycle.ts'\nimport { makeVercelSandboxName } from '../name.ts'\nimport {\n SandboxCommandResult,\n type SandboxCommandInput,\n SandboxPreviewUrl,\n SandboxResources,\n type SandboxInitialSource,\n type SandboxLifecycle,\n type SandboxState\n} from '../model.ts'\nimport { Sandbox } from '../service.ts'\nimport { SandboxStateStore } from '../state-store.ts'\nimport { SandboxProviderError, unknownToMessage } from '../errors.ts'\nimport type { SandboxError } from '../errors.ts'\nimport {\n VercelSandboxClient,\n VercelSandboxClientLive,\n type VercelDetachedCommand,\n type VercelNetworkPolicy,\n type VercelSandboxHandle,\n type VercelSandboxRuntime\n} from './client.ts'\n\nexport type VercelSandboxLayerConfig = {\n readonly sandboxSessionId: string\n readonly lifecycle?: SandboxLifecycle\n readonly source?: SandboxInitialSource\n readonly env?: Readonly<Record<string, string>>\n readonly ports?: ReadonlyArray<number>\n readonly resources?: SandboxResources\n readonly runtime?: VercelSandboxRuntime\n readonly networkPolicy?: VercelNetworkPolicy\n readonly workspaceRoot?: string\n}\n\ntype ActiveSandbox = {\n readonly handle: VercelSandboxHandle\n readonly state: SandboxState\n readonly workspaceReset: boolean\n}\n\ntype CommandRunOutput = {\n readonly exitCode: number | null\n readonly stdout: string\n readonly stderr: string\n readonly timedOut: boolean\n readonly backgroundId?: string\n}\n\nconst defaultVercelResources = SandboxResources.make({ vcpus: 2 })\nconst defaultVercelRuntime: VercelSandboxRuntime = 'node24'\n\nconst providerError = (operation: string, error: unknown) =>\n new SandboxProviderError({\n provider: 'vercel',\n operation,\n message: `Vercel Sandbox ${operation} failed: ${unknownToMessage(error)}`,\n underlying: error\n })\n\nconst tryProvider = <A>(operation: string, body: () => Promise<A>) =>\n Effect.tryPromise({\n try: body,\n catch: error => providerError(operation, error)\n })\n\nconst lifecycleTimeoutMs = (lifecycle: SandboxLifecycle) => {\n switch (lifecycle._tag) {\n case 'Disposable':\n return Math.min(lifecycle.idleTtlMs, lifecycle.maxLifetimeMs)\n case 'Persistent':\n return lifecycle.idleTtlMs\n }\n}\n\nconst persistentFlag = (lifecycle: SandboxLifecycle) => lifecycle._tag === 'Persistent'\n\nconst snapshotExpirationMs = (lifecycle: SandboxLifecycle) =>\n lifecycle._tag === 'Persistent' ? lifecycle.snapshotExpirationMs : undefined\n\nconst keepLastSnapshots = (lifecycle: SandboxLifecycle) =>\n lifecycle._tag === 'Persistent' ? lifecycle.keepLastSnapshots : undefined\n\nconst previewUrls = (handle: VercelSandboxHandle, ports: ReadonlyArray<number>) =>\n Effect.forEach(ports, port =>\n Effect.try({\n try: () => handle.domain(port),\n catch: error => error\n }).pipe(\n Effect.match({\n onFailure: () => Option.none<SandboxPreviewUrl>(),\n onSuccess: url => Option.some(SandboxPreviewUrl.make({ port, url }))\n })\n )\n ).pipe(Effect.map(Arr.getSomes))\n\nconst commandOutputOrEmpty = (command: VercelDetachedCommand) =>\n tryProvider('command.output', command.output).pipe(\n Effect.catchTag('SandboxProviderError', error =>\n Effect.succeed({\n stdout: '',\n stderr: error.message\n })\n )\n )\n\nconst finishedCommandOutputOrEmpty = (command: {\n readonly output: () => Promise<{ readonly stdout: string; readonly stderr: string }>\n}) =>\n tryProvider('command.output', command.output).pipe(\n Effect.catchTag('SandboxProviderError', error =>\n Effect.succeed({\n stdout: '',\n stderr: error.message\n })\n )\n )\n\nconst waitForegroundCommand = (\n command: VercelDetachedCommand,\n timeoutMs: number\n): Effect.Effect<CommandRunOutput, SandboxProviderError> =>\n tryProvider('command.wait', command.wait).pipe(\n Effect.flatMap(finished =>\n finishedCommandOutputOrEmpty(finished).pipe(\n Effect.map(output => ({\n exitCode: finished.exitCode,\n stdout: output.stdout,\n stderr: output.stderr,\n timedOut: false\n }))\n )\n ),\n Effect.timeoutOrElse({\n duration: Duration.millis(timeoutMs),\n orElse: () =>\n Effect.gen(function* () {\n yield* tryProvider('command.kill', () => command.kill('SIGKILL')).pipe(\n Effect.catchTag('SandboxProviderError', () => Effect.void)\n )\n const output = yield* commandOutputOrEmpty(command)\n\n return {\n exitCode: null,\n stdout: output.stdout,\n stderr: output.stderr,\n timedOut: true\n }\n })\n })\n )\n\nconst probeBackgroundCommand = (\n handle: VercelSandboxHandle,\n command: VercelDetachedCommand\n): Effect.Effect<CommandRunOutput, SandboxProviderError> =>\n Effect.sleep(Duration.millis(backgroundSandboxProbeMs)).pipe(\n Effect.flatMap(() => tryProvider('command.get', () => handle.getCommand(command.id))),\n Effect.flatMap(current => {\n if (current.exitCode === null) {\n return Effect.succeed({\n exitCode: null,\n stdout: '',\n stderr: '',\n timedOut: false,\n backgroundId: command.id\n })\n }\n\n return commandOutputOrEmpty(current).pipe(\n Effect.map(output => ({\n exitCode: current.exitCode,\n stdout: output.stdout,\n stderr: output.stderr,\n timedOut: false\n }))\n )\n })\n )\n\nconst createCommandFiles = (input: {\n readonly commandId: string\n readonly workspaceRoot: string\n readonly command: string\n readonly stdin?: string\n}) => {\n const commandDir = `${input.workspaceRoot}/.yolk/commands/${input.commandId}`\n const commandPath = `${commandDir}/command.sh`\n const stdinPath = `${commandDir}/stdin.txt`\n const wrapperPath = `${commandDir}/wrapper.sh`\n\n return {\n wrapperPath,\n files: [\n {\n path: commandPath,\n content: `#!/usr/bin/env bash\\n${input.command}\\n`,\n mode: 0o755\n },\n {\n path: stdinPath,\n content: input.stdin ?? ''\n },\n {\n path: wrapperPath,\n content: `#!/usr/bin/env bash\\nbash ${JSON.stringify(commandPath)} < ${JSON.stringify(stdinPath)}\\n`,\n mode: 0o755\n }\n ]\n }\n}\n\nexport const makeVercelSandboxLayerWithClient = (config: VercelSandboxLayerConfig) =>\n Layer.effect(\n Sandbox,\n Effect.gen(function* () {\n const store = yield* SandboxStateStore\n const client = yield* VercelSandboxClient\n const semaphore = yield* Semaphore.make(1)\n const commandCounter = yield* Ref.make(0)\n const lifecycle = sandboxLifecycleOrDefault(config.lifecycle)\n const source = sandboxSourceOrDefault(config.source)\n const ports = config.ports ?? defaultSandboxPorts\n const resources = config.resources ?? defaultVercelResources\n const runtime = config.runtime ?? defaultVercelRuntime\n const workspaceRoot = config.workspaceRoot ?? defaultSandboxWorkspaceRoot\n const name = makeVercelSandboxName(config.sandboxSessionId)\n\n const deleteNamedSandbox = (sandboxName: string) =>\n tryProvider('get', () => client.get({ name: sandboxName })).pipe(\n Effect.flatMap(handle =>\n handle === null\n ? Effect.void\n : tryProvider('delete', () => handle.delete())\n )\n )\n\n const createSandbox = (input: { readonly nowMs: number; readonly workspaceReset: boolean }) =>\n Effect.gen(function* () {\n const handle = yield* tryProvider('create', () =>\n client.create({\n name,\n source,\n ports,\n timeoutMs: lifecycleTimeoutMs(lifecycle),\n resources,\n runtime,\n env: config.env,\n persistent: persistentFlag(lifecycle),\n snapshotExpirationMs: snapshotExpirationMs(lifecycle),\n keepLastSnapshots: keepLastSnapshots(lifecycle),\n networkPolicy: config.networkPolicy\n })\n )\n const state = initialSandboxState({ name, nowMs: input.nowMs, lifecycle })\n yield* store.save({ sandboxSessionId: config.sandboxSessionId, state })\n\n return { handle, state, workspaceReset: input.workspaceReset }\n })\n\n const touchExistingSandbox = (input: {\n readonly handle: VercelSandboxHandle\n readonly state: SandboxState\n readonly nowMs: number\n readonly workspaceReset: boolean\n }) =>\n Effect.gen(function* () {\n const touched = touchSandboxState({\n state: input.state,\n nowMs: input.nowMs,\n lifecycle\n })\n const deltaMs = sandboxTimeoutExtendDeltaMs({ before: input.state, after: touched })\n\n if (deltaMs > 0) {\n yield* tryProvider('extendTimeout', () => input.handle.extendTimeout(deltaMs))\n }\n\n yield* store.save({ sandboxSessionId: config.sandboxSessionId, state: touched })\n\n return {\n handle: input.handle,\n state: touched,\n workspaceReset: input.workspaceReset\n }\n })\n\n const ensureSandbox = (nowMs: number): Effect.Effect<ActiveSandbox, SandboxError> =>\n Effect.gen(function* () {\n const loaded = yield* store.load(config.sandboxSessionId)\n const decision = sandboxStateDecision({ state: loaded, name, nowMs })\n\n if (decision._tag === 'Create') {\n if (Option.isSome(loaded) && decision.workspaceReset) {\n yield* deleteNamedSandbox(loaded.value.name)\n yield* store.clear(config.sandboxSessionId)\n }\n\n return yield* createSandbox({ nowMs, workspaceReset: decision.workspaceReset })\n }\n\n const handle = yield* tryProvider('get', () => client.get({ name }))\n\n if (handle === null) {\n yield* store.clear(config.sandboxSessionId)\n return yield* createSandbox({ nowMs, workspaceReset: true })\n }\n\n return yield* touchExistingSandbox({\n handle,\n state: decision.state,\n nowMs,\n workspaceReset: false\n })\n })\n\n const nextCommandId = Ref.updateAndGet(commandCounter, value => value + 1).pipe(\n Effect.map(value => `command-${value}`)\n )\n\n const runUnlocked = (input: SandboxCommandInput) =>\n Effect.gen(function* () {\n yield* validateSandboxCommand(input.command)\n const normalizedCwd = yield* normalizeWorkspaceCwd(input.cwd)\n const timeoutMs = sandboxCommandTimeoutMs(input.timeoutMs)\n const nowMs = yield* Clock.currentTimeMillis\n const active = yield* ensureSandbox(nowMs)\n const id = yield* nextCommandId\n const commandFiles = createCommandFiles({\n commandId: id,\n workspaceRoot,\n command: input.command,\n stdin: input.stdin\n })\n\n yield* tryProvider('writeFiles', () => active.handle.writeFiles(commandFiles.files))\n\n const startedAtMs = yield* Clock.currentTimeMillis\n const command = yield* tryProvider('runCommand', () =>\n active.handle.runDetachedCommand({\n cmd: 'bash',\n args: [commandFiles.wrapperPath],\n cwd: absoluteSandboxCwd(workspaceRoot, normalizedCwd)\n })\n )\n const output = yield* (input.background === true\n ? probeBackgroundCommand(active.handle, command)\n : waitForegroundCommand(command, timeoutMs))\n const endedAtMs = yield* Clock.currentTimeMillis\n const urls = yield* previewUrls(active.handle, ports)\n\n return SandboxCommandResult.make({\n exitCode: output.exitCode,\n stdout: output.stdout,\n stderr: output.stderr,\n durationMs: Math.max(0, endedAtMs - startedAtMs),\n timedOut: output.timedOut,\n workspaceReset: active.workspaceReset,\n ...(output.backgroundId === undefined ? {} : { backgroundId: output.backgroundId }),\n previewUrls: urls,\n state: active.state\n })\n })\n\n return Sandbox.of({\n run: input => semaphore.withPermits(1)(runUnlocked(input)),\n currentState: store.load(config.sandboxSessionId),\n delete: Effect.gen(function* () {\n const loaded = yield* store.load(config.sandboxSessionId)\n if (Option.isSome(loaded)) {\n yield* deleteNamedSandbox(loaded.value.name)\n }\n yield* store.clear(config.sandboxSessionId)\n })\n })\n })\n )\n\nexport const makeVercelSandboxLayer = (config: VercelSandboxLayerConfig) =>\n makeVercelSandboxLayerWithClient(config).pipe(Layer.provide(VercelSandboxClientLive))\n"],"mappings":";;;;;;;;;AAiEA,MAAM,yBAAyB,iBAAiB,KAAK,EAAE,OAAO,EAAE,CAAC;AACjE,MAAM,uBAA6C;AAEnD,MAAM,iBAAiB,WAAmB,UACxC,IAAI,qBAAqB;CACvB,UAAU;CACV;CACA,SAAS,kBAAkB,UAAU,WAAW,iBAAiB,KAAK;CACtE,YAAY;AACd,CAAC;AAEH,MAAM,eAAkB,WAAmB,SACzC,OAAO,WAAW;CAChB,KAAK;CACL,QAAO,UAAS,cAAc,WAAW,KAAK;AAChD,CAAC;AAEH,MAAM,sBAAsB,cAAgC;CAC1D,QAAQ,UAAU,MAAlB;EACE,KAAK,cACH,OAAO,KAAK,IAAI,UAAU,WAAW,UAAU,aAAa;EAC9D,KAAK,cACH,OAAO,UAAU;CACrB;AACF;AAEA,MAAM,kBAAkB,cAAgC,UAAU,SAAS;AAE3E,MAAM,wBAAwB,cAC5B,UAAU,SAAS,eAAe,UAAU,uBAAuB,KAAA;AAErE,MAAM,qBAAqB,cACzB,UAAU,SAAS,eAAe,UAAU,oBAAoB,KAAA;AAElE,MAAM,eAAe,QAA6B,UAChD,OAAO,QAAQ,QAAO,SACpB,OAAO,IAAI;CACT,WAAW,OAAO,OAAO,IAAI;CAC7B,QAAO,UAAS;AAClB,CAAC,EAAE,KACD,OAAO,MAAM;CACX,iBAAiB,OAAO,KAAwB;CAChD,YAAW,QAAO,OAAO,KAAK,kBAAkB,KAAK;EAAE;EAAM;CAAI,CAAC,CAAC;AACrE,CAAC,CACH,CACF,EAAE,KAAK,OAAO,IAAIA,MAAI,QAAQ,CAAC;AAEjC,MAAM,wBAAwB,YAC5B,YAAY,kBAAkB,QAAQ,MAAM,EAAE,KAC5C,OAAO,SAAS,yBAAwB,UACtC,OAAO,QAAQ;CACb,QAAQ;CACR,QAAQ,MAAM;AAChB,CAAC,CACH,CACF;AAEF,MAAM,gCAAgC,YAGpC,YAAY,kBAAkB,QAAQ,MAAM,EAAE,KAC5C,OAAO,SAAS,yBAAwB,UACtC,OAAO,QAAQ;CACb,QAAQ;CACR,QAAQ,MAAM;AAChB,CAAC,CACH,CACF;AAEF,MAAM,yBACJ,SACA,cAEA,YAAY,gBAAgB,QAAQ,IAAI,EAAE,KACxC,OAAO,SAAQ,aACb,6BAA6B,QAAQ,EAAE,KACrC,OAAO,KAAI,YAAW;CACpB,UAAU,SAAS;CACnB,QAAQ,OAAO;CACf,QAAQ,OAAO;CACf,UAAU;AACZ,EAAE,CACJ,CACF,GACA,OAAO,cAAc;CACnB,UAAU,SAAS,OAAO,SAAS;CACnC,cACE,OAAO,IAAI,aAAa;EACtB,OAAO,YAAY,sBAAsB,QAAQ,KAAK,SAAS,CAAC,EAAE,KAChE,OAAO,SAAS,8BAA8B,OAAO,IAAI,CAC3D;EACA,MAAM,SAAS,OAAO,qBAAqB,OAAO;EAElD,OAAO;GACL,UAAU;GACV,QAAQ,OAAO;GACf,QAAQ,OAAO;GACf,UAAU;EACZ;CACF,CAAC;AACL,CAAC,CACH;AAEF,MAAM,0BACJ,QACA,YAEA,OAAO,MAAM,SAAS,OAAO,wBAAwB,CAAC,EAAE,KACtD,OAAO,cAAc,YAAY,qBAAqB,OAAO,WAAW,QAAQ,EAAE,CAAC,CAAC,GACpF,OAAO,SAAQ,YAAW;CACxB,IAAI,QAAQ,aAAa,MACvB,OAAO,OAAO,QAAQ;EACpB,UAAU;EACV,QAAQ;EACR,QAAQ;EACR,UAAU;EACV,cAAc,QAAQ;CACxB,CAAC;CAGH,OAAO,qBAAqB,OAAO,EAAE,KACnC,OAAO,KAAI,YAAW;EACpB,UAAU,QAAQ;EAClB,QAAQ,OAAO;EACf,QAAQ,OAAO;EACf,UAAU;CACZ,EAAE,CACJ;AACF,CAAC,CACH;AAEF,MAAM,sBAAsB,UAKtB;CACJ,MAAM,aAAa,GAAG,MAAM,cAAc,kBAAkB,MAAM;CAClE,MAAM,cAAc,GAAG,WAAW;CAClC,MAAM,YAAY,GAAG,WAAW;CAChC,MAAM,cAAc,GAAG,WAAW;CAElC,OAAO;EACL;EACA,OAAO;GACL;IACE,MAAM;IACN,SAAS,wBAAwB,MAAM,QAAQ;IAC/C,MAAM;GACR;GACA;IACE,MAAM;IACN,SAAS,MAAM,SAAS;GAC1B;GACA;IACE,MAAM;IACN,SAAS,6BAA6B,KAAK,UAAU,WAAW,EAAE,KAAK,KAAK,UAAU,SAAS,EAAE;IACjG,MAAM;GACR;EACF;CACF;AACF;AAEA,MAAa,oCAAoC,WAC/C,MAAM,OACJ,SACA,OAAO,IAAI,aAAa;CACtB,MAAM,QAAQ,OAAO;CACrB,MAAM,SAAS,OAAO;CACtB,MAAM,YAAY,OAAO,UAAU,KAAK,CAAC;CACzC,MAAM,iBAAiB,OAAO,IAAI,KAAK,CAAC;CACxC,MAAM,YAAY,0BAA0B,OAAO,SAAS;CAC5D,MAAM,SAAS,uBAAuB,OAAO,MAAM;CACnD,MAAM,QAAQ,OAAO,SAAS;CAC9B,MAAM,YAAY,OAAO,aAAa;CACtC,MAAM,UAAU,OAAO,WAAW;CAClC,MAAM,gBAAgB,OAAO,iBAAA;CAC7B,MAAM,OAAO,sBAAsB,OAAO,gBAAgB;CAE1D,MAAM,sBAAsB,gBAC1B,YAAY,aAAa,OAAO,IAAI,EAAE,MAAM,YAAY,CAAC,CAAC,EAAE,KAC1D,OAAO,SAAQ,WACb,WAAW,OACP,OAAO,OACP,YAAY,gBAAgB,OAAO,OAAO,CAAC,CACjD,CACF;CAEF,MAAM,iBAAiB,UACrB,OAAO,IAAI,aAAa;EACtB,MAAM,SAAS,OAAO,YAAY,gBAChC,OAAO,OAAO;GACZ;GACA;GACA;GACA,WAAW,mBAAmB,SAAS;GACvC;GACA;GACA,KAAK,OAAO;GACZ,YAAY,eAAe,SAAS;GACpC,sBAAsB,qBAAqB,SAAS;GACpD,mBAAmB,kBAAkB,SAAS;GAC9C,eAAe,OAAO;EACxB,CAAC,CACH;EACA,MAAM,QAAQ,oBAAoB;GAAE;GAAM,OAAO,MAAM;GAAO;EAAU,CAAC;EACzE,OAAO,MAAM,KAAK;GAAE,kBAAkB,OAAO;GAAkB;EAAM,CAAC;EAEtE,OAAO;GAAE;GAAQ;GAAO,gBAAgB,MAAM;EAAe;CAC/D,CAAC;CAEH,MAAM,wBAAwB,UAM5B,OAAO,IAAI,aAAa;EACtB,MAAM,UAAU,kBAAkB;GAChC,OAAO,MAAM;GACb,OAAO,MAAM;GACb;EACF,CAAC;EACD,MAAM,UAAU,4BAA4B;GAAE,QAAQ,MAAM;GAAO,OAAO;EAAQ,CAAC;EAEnF,IAAI,UAAU,GACZ,OAAO,YAAY,uBAAuB,MAAM,OAAO,cAAc,OAAO,CAAC;EAG/E,OAAO,MAAM,KAAK;GAAE,kBAAkB,OAAO;GAAkB,OAAO;EAAQ,CAAC;EAE/E,OAAO;GACL,QAAQ,MAAM;GACd,OAAO;GACP,gBAAgB,MAAM;EACxB;CACF,CAAC;CAEH,MAAM,iBAAiB,UACrB,OAAO,IAAI,aAAa;EACtB,MAAM,SAAS,OAAO,MAAM,KAAK,OAAO,gBAAgB;EACxD,MAAM,WAAW,qBAAqB;GAAE,OAAO;GAAQ;GAAM;EAAM,CAAC;EAEpE,IAAI,SAAS,SAAS,UAAU;GAC9B,IAAI,OAAO,OAAO,MAAM,KAAK,SAAS,gBAAgB;IACpD,OAAO,mBAAmB,OAAO,MAAM,IAAI;IAC3C,OAAO,MAAM,MAAM,OAAO,gBAAgB;GAC5C;GAEA,OAAO,OAAO,cAAc;IAAE;IAAO,gBAAgB,SAAS;GAAe,CAAC;EAChF;EAEA,MAAM,SAAS,OAAO,YAAY,aAAa,OAAO,IAAI,EAAE,KAAK,CAAC,CAAC;EAEnE,IAAI,WAAW,MAAM;GACnB,OAAO,MAAM,MAAM,OAAO,gBAAgB;GAC1C,OAAO,OAAO,cAAc;IAAE;IAAO,gBAAgB;GAAK,CAAC;EAC7D;EAEA,OAAO,OAAO,qBAAqB;GACjC;GACA,OAAO,SAAS;GAChB;GACA,gBAAgB;EAClB,CAAC;CACH,CAAC;CAEH,MAAM,gBAAgB,IAAI,aAAa,iBAAgB,UAAS,QAAQ,CAAC,EAAE,KACzE,OAAO,KAAI,UAAS,WAAW,OAAO,CACxC;CAEA,MAAM,eAAe,UACnB,OAAO,IAAI,aAAa;EACtB,OAAO,uBAAuB,MAAM,OAAO;EAC3C,MAAM,gBAAgB,OAAO,sBAAsB,MAAM,GAAG;EAC5D,MAAM,YAAY,wBAAwB,MAAM,SAAS;EAEzD,MAAM,SAAS,OAAO,cAAc,OADf,MAAM,iBACc;EAEzC,MAAM,eAAe,mBAAmB;GACtC,WAAW,OAFK;GAGhB;GACA,SAAS,MAAM;GACf,OAAO,MAAM;EACf,CAAC;EAED,OAAO,YAAY,oBAAoB,OAAO,OAAO,WAAW,aAAa,KAAK,CAAC;EAEnF,MAAM,cAAc,OAAO,MAAM;EACjC,MAAM,UAAU,OAAO,YAAY,oBACjC,OAAO,OAAO,mBAAmB;GAC/B,KAAK;GACL,MAAM,CAAC,aAAa,WAAW;GAC/B,KAAK,mBAAmB,eAAe,aAAa;EACtD,CAAC,CACH;EACA,MAAM,SAAS,OAAQ,MAAM,eAAe,OACxC,uBAAuB,OAAO,QAAQ,OAAO,IAC7C,sBAAsB,SAAS,SAAS;EAC5C,MAAM,YAAY,OAAO,MAAM;EAC/B,MAAM,OAAO,OAAO,YAAY,OAAO,QAAQ,KAAK;EAEpD,OAAO,qBAAqB,KAAK;GAC/B,UAAU,OAAO;GACjB,QAAQ,OAAO;GACf,QAAQ,OAAO;GACf,YAAY,KAAK,IAAI,GAAG,YAAY,WAAW;GAC/C,UAAU,OAAO;GACjB,gBAAgB,OAAO;GACvB,GAAI,OAAO,iBAAiB,KAAA,IAAY,CAAC,IAAI,EAAE,cAAc,OAAO,aAAa;GACjF,aAAa;GACb,OAAO,OAAO;EAChB,CAAC;CACH,CAAC;CAEH,OAAO,QAAQ,GAAG;EAChB,MAAK,UAAS,UAAU,YAAY,CAAC,EAAE,YAAY,KAAK,CAAC;EACzD,cAAc,MAAM,KAAK,OAAO,gBAAgB;EAChD,QAAQ,OAAO,IAAI,aAAa;GAC9B,MAAM,SAAS,OAAO,MAAM,KAAK,OAAO,gBAAgB;GACxD,IAAI,OAAO,OAAO,MAAM,GACtB,OAAO,mBAAmB,OAAO,MAAM,IAAI;GAE7C,OAAO,MAAM,MAAM,OAAO,gBAAgB;EAC5C,CAAC;CACH,CAAC;AACH,CAAC,CACH;AAEF,MAAa,0BAA0B,WACrC,iCAAiC,MAAM,EAAE,KAAK,MAAM,QAAQ,uBAAuB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@yolk-sdk/sandbox",
3
+ "version": "0.1.0-canary.18",
4
+ "description": "Effect-native sandbox execution plane primitives and Vercel Sandbox adapter for Yolk agents.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/magoz/yolk-sdk.git",
11
+ "directory": "packages/sandbox"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/magoz/yolk-sdk/issues"
15
+ },
16
+ "homepage": "https://github.com/magoz/yolk-sdk#readme",
17
+ "keywords": [
18
+ "sandbox",
19
+ "agents",
20
+ "execution",
21
+ "vercel",
22
+ "effect"
23
+ ],
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "exports": {
28
+ "./package.json": "./package.json",
29
+ ".": {
30
+ "types": "./dist/index.d.mts",
31
+ "import": "./dist/index.mjs",
32
+ "default": "./dist/index.mjs"
33
+ },
34
+ "./agent": {
35
+ "types": "./dist/agent.d.mts",
36
+ "import": "./dist/agent.mjs",
37
+ "default": "./dist/agent.mjs"
38
+ },
39
+ "./testing": {
40
+ "types": "./dist/testing/index.d.mts",
41
+ "import": "./dist/testing/index.mjs",
42
+ "default": "./dist/testing/index.mjs"
43
+ },
44
+ "./vercel": {
45
+ "types": "./dist/vercel/index.d.mts",
46
+ "import": "./dist/vercel/index.mjs",
47
+ "default": "./dist/vercel/index.mjs"
48
+ }
49
+ },
50
+ "files": [
51
+ "src/**/*.ts",
52
+ "!src/**/*.test.ts",
53
+ "!src/**/*.test.tsx",
54
+ "dist/**/*",
55
+ "README.md"
56
+ ],
57
+ "publishConfig": {
58
+ "access": "public",
59
+ "provenance": true
60
+ },
61
+ "dependencies": {
62
+ "@vercel/sandbox": "2.2.1",
63
+ "effect": "4.0.0-beta.65",
64
+ "@yolk-sdk/agent": "^0.1.0-canary.18"
65
+ },
66
+ "scripts": {
67
+ "build": "tsdown",
68
+ "check": "tsc -p tsconfig.json --noEmit",
69
+ "test": "vitest run --passWithNoTests",
70
+ "test:run": "vitest run --passWithNoTests"
71
+ }
72
+ }
package/src/agent.ts ADDED
@@ -0,0 +1,304 @@
1
+ import { Effect } from 'effect'
2
+ import * as Schema from 'effect/Schema'
3
+ import { ToolError } from '@yolk-sdk/agent/loop'
4
+ import { ToolResult, type ToolCall } from '@yolk-sdk/agent/protocol'
5
+ import {
6
+ makeTool,
7
+ modelVisibleToolError,
8
+ type ModelVisibleToolError,
9
+ type ToolModule,
10
+ type ToolRegistryError
11
+ } from '@yolk-sdk/agent/tools'
12
+ import {
13
+ maxSandboxCommandTimeoutMs,
14
+ normalizeWorkspaceCwd,
15
+ sandboxCommandTimeoutMs,
16
+ sandboxToolOutputLimit,
17
+ validateSandboxCommand
18
+ } from './lifecycle.ts'
19
+ import type { SandboxError, SandboxExpiredError, SandboxInputError } from './errors.ts'
20
+ import type { SandboxApi } from './service.ts'
21
+ import { Sandbox } from './service.ts'
22
+ import type { SandboxCommandResult } from './model.ts'
23
+
24
+ export const sandboxToolName = 'sandbox'
25
+
26
+ const SandboxToolParams = Schema.Struct({
27
+ command: Schema.String.pipe(
28
+ Schema.annotate({ description: 'Shell command to run. Multiline commands are allowed.' })
29
+ ),
30
+ cwd: Schema.optional(
31
+ Schema.NullOr(
32
+ Schema.String.pipe(
33
+ Schema.annotate({ description: 'Workspace-relative working directory. Absolute paths are rejected.' })
34
+ )
35
+ )
36
+ ),
37
+ stdin: Schema.optional(
38
+ Schema.NullOr(
39
+ Schema.String.pipe(
40
+ Schema.annotate({ description: 'Optional stdin text passed to the command.' })
41
+ )
42
+ )
43
+ ),
44
+ timeoutSeconds: Schema.optional(
45
+ Schema.NullOr(
46
+ Schema.Number.pipe(
47
+ Schema.annotate({ description: 'Foreground timeout in seconds. Default 120, max 600.' })
48
+ )
49
+ )
50
+ ),
51
+ background: Schema.optional(
52
+ Schema.NullOr(
53
+ Schema.Boolean.pipe(
54
+ Schema.annotate({ description: 'Start command in background and return after a quick probe.' })
55
+ )
56
+ )
57
+ )
58
+ })
59
+
60
+ export type SandboxToolParams = typeof SandboxToolParams.Type
61
+
62
+ export type SandboxToolModuleOptions<Context> = {
63
+ readonly capabilities?: ReadonlyArray<string>
64
+ readonly workspaceDescription?: string
65
+ readonly lifecycleDescription?: string
66
+ readonly previewPorts?: ReadonlyArray<number>
67
+ readonly isEnabled?: (context: Context) => Effect.Effect<boolean, ToolRegistryError>
68
+ }
69
+
70
+ export type SandboxToolStructuredContent = {
71
+ readonly exitCode: number | null
72
+ readonly durationMs: number
73
+ readonly timedOut: boolean
74
+ readonly truncated: boolean
75
+ readonly workspaceReset: boolean
76
+ readonly backgroundId?: string
77
+ readonly previewUrls: SandboxCommandResult['previewUrls']
78
+ readonly state: SandboxCommandResult['state']
79
+ }
80
+
81
+ type OutputSlice = {
82
+ readonly stdout: string
83
+ readonly stderr: string
84
+ readonly truncated: boolean
85
+ }
86
+
87
+ const nullToUndefined = <A>(value: A | null | undefined) => value === null ? undefined : value
88
+
89
+ const toolError = (message: string, cause: ToolError['cause']) =>
90
+ new ToolError({
91
+ tool: sandboxToolName,
92
+ message,
93
+ cause
94
+ })
95
+
96
+ const modelVisibleSandboxError = (
97
+ error: SandboxInputError | SandboxExpiredError
98
+ ): ModelVisibleToolError => {
99
+ switch (error._tag) {
100
+ case 'SandboxInputError':
101
+ return modelVisibleToolError({
102
+ tool: sandboxToolName,
103
+ message: error.message,
104
+ reason: 'invalid_input'
105
+ })
106
+ case 'SandboxExpiredError':
107
+ return modelVisibleToolError({
108
+ tool: sandboxToolName,
109
+ message: error.message,
110
+ reason: 'unavailable',
111
+ details: { expiredAtMs: error.expiredAtMs }
112
+ })
113
+ }
114
+ }
115
+
116
+ const sandboxInfraToolError = (error: Exclude<SandboxError, SandboxInputError | SandboxExpiredError>) => {
117
+ switch (error._tag) {
118
+ case 'SandboxConfigError':
119
+ return toolError(error.message, 'invalid_input')
120
+ case 'SandboxProviderError':
121
+ return toolError(error.message, 'execution')
122
+ case 'SandboxStateError':
123
+ case 'SandboxStateStoreError':
124
+ return toolError(error.message, 'unavailable')
125
+ }
126
+ }
127
+
128
+ const normalizeTimeoutMs = (timeoutSeconds: number | null | undefined) => {
129
+ const value = nullToUndefined(timeoutSeconds)
130
+
131
+ if (value === undefined) {
132
+ return Effect.succeed(sandboxCommandTimeoutMs(undefined))
133
+ }
134
+
135
+ if (!Number.isFinite(value) || value <= 0) {
136
+ return Effect.fail(
137
+ modelVisibleToolError({
138
+ tool: sandboxToolName,
139
+ message: 'timeoutSeconds must be greater than 0',
140
+ reason: 'invalid_input'
141
+ })
142
+ )
143
+ }
144
+
145
+ return Effect.succeed(sandboxCommandTimeoutMs(Math.floor(value * 1_000)))
146
+ }
147
+
148
+ const normalizeToolParams = (params: SandboxToolParams) =>
149
+ Effect.gen(function* () {
150
+ const command = yield* validateSandboxCommand(params.command).pipe(
151
+ Effect.mapError(modelVisibleSandboxError)
152
+ )
153
+ const cwd = yield* normalizeWorkspaceCwd(params.cwd).pipe(
154
+ Effect.mapError(modelVisibleSandboxError)
155
+ )
156
+ const timeoutMs = yield* normalizeTimeoutMs(params.timeoutSeconds)
157
+
158
+ return {
159
+ command,
160
+ cwd,
161
+ stdin: nullToUndefined(params.stdin),
162
+ timeoutMs,
163
+ background: nullToUndefined(params.background)
164
+ }
165
+ })
166
+
167
+ const truncateOutputs = (stdout: string, stderr: string, limit: number): OutputSlice => {
168
+ if (stdout.length + stderr.length <= limit) {
169
+ return { stdout, stderr, truncated: false }
170
+ }
171
+
172
+ const initialStderrBudget = Math.min(stderr.length, Math.floor(limit / 2))
173
+ const initialStdoutBudget = Math.min(stdout.length, limit - initialStderrBudget)
174
+ const unused = limit - initialStdoutBudget - initialStderrBudget
175
+ const stdoutBudget = Math.min(stdout.length, initialStdoutBudget + unused)
176
+ const stderrBudget = Math.min(stderr.length, limit - stdoutBudget)
177
+
178
+ return {
179
+ stdout: stdout.slice(0, stdoutBudget),
180
+ stderr: stderr.slice(0, stderrBudget),
181
+ truncated: true
182
+ }
183
+ }
184
+
185
+ const formatSandboxToolContent = (result: SandboxCommandResult, output: OutputSlice) =>
186
+ [
187
+ `exit_code: ${result.exitCode === null ? 'null' : String(result.exitCode)}`,
188
+ `duration_ms: ${result.durationMs}`,
189
+ `truncated: ${output.truncated}`,
190
+ `timed_out: ${result.timedOut}`,
191
+ `workspace_reset: ${result.workspaceReset}`,
192
+ result.backgroundId === undefined ? undefined : `background_id: ${result.backgroundId}`,
193
+ '<stdout>',
194
+ output.stdout,
195
+ '</stdout>',
196
+ '<stderr>',
197
+ output.stderr,
198
+ '</stderr>'
199
+ ].filter(line => line !== undefined).join('\n')
200
+
201
+ const structuredContent = (
202
+ result: SandboxCommandResult,
203
+ truncated: boolean
204
+ ): SandboxToolStructuredContent => ({
205
+ exitCode: result.exitCode,
206
+ durationMs: result.durationMs,
207
+ timedOut: result.timedOut,
208
+ truncated,
209
+ workspaceReset: result.workspaceReset,
210
+ ...(result.backgroundId === undefined ? {} : { backgroundId: result.backgroundId }),
211
+ previewUrls: result.previewUrls,
212
+ state: result.state
213
+ })
214
+
215
+ export const makeSandboxToolResult = (input: {
216
+ readonly callId: string
217
+ readonly result: SandboxCommandResult
218
+ }) => {
219
+ const output = truncateOutputs(input.result.stdout, input.result.stderr, sandboxToolOutputLimit)
220
+
221
+ return ToolResult.make({
222
+ toolCallId: input.callId,
223
+ content: formatSandboxToolContent(input.result, output),
224
+ isError: input.result.timedOut || (input.result.exitCode !== null && input.result.exitCode !== 0),
225
+ structuredContent: structuredContent(input.result, output.truncated)
226
+ })
227
+ }
228
+
229
+ const sandboxToolDescription = <Context>(options: SandboxToolModuleOptions<Context>) => {
230
+ const capabilities = options.capabilities ?? [
231
+ 'run project commands such as typechecks, lint, tests, and builds',
232
+ 'edit files with shell tools such as apply_patch when available',
233
+ 'start dev servers and use preview ports',
234
+ 'run browser checks through agent-browser when available'
235
+ ]
236
+ const ports = options.previewPorts ?? []
237
+
238
+ return [
239
+ 'Run one non-interactive bash command inside a real sandbox workspace.',
240
+ options.workspaceDescription ?? 'The working directory is the sandbox workspace root.',
241
+ options.lifecycleDescription ?? 'The sandbox is disposable and may reset after idle or max lifetime expiry.',
242
+ `Foreground timeout defaults to 120s and is capped at ${Math.floor(maxSandboxCommandTimeoutMs / 1_000)}s.`,
243
+ 'Use workspace-relative cwd only; absolute paths and workspace escapes are rejected.',
244
+ 'Use stdin for large patches, scripts, or data.',
245
+ 'Set background=true for long-running servers; the tool returns after a quick probe.',
246
+ ports.length === 0 ? 'Preview URLs are returned when configured by the host.' : `Preview ports: ${ports.join(', ')}.`,
247
+ `Available capabilities:\n${capabilities.map(capability => `- ${capability}`).join('\n')}`
248
+ ].join('\n\n')
249
+ }
250
+
251
+ export const makeSandboxToolModuleFromApi = <Context>(
252
+ sandbox: SandboxApi,
253
+ options: SandboxToolModuleOptions<Context> = {}
254
+ ): ToolModule<Context> => ({
255
+ id: 'sandbox',
256
+ tools: [
257
+ makeTool({
258
+ name: sandboxToolName,
259
+ description: sandboxToolDescription(options),
260
+ parameters: SandboxToolParams,
261
+ access: 'destructive',
262
+ isEnabled: options.isEnabled,
263
+ invalidParamsMessage: error => `Invalid sandbox arguments: ${error instanceof Error ? error.message : String(error)}`,
264
+ execute: ({ call, params }) =>
265
+ Effect.gen(function* () {
266
+ if (call.name !== sandboxToolName) {
267
+ return yield* Effect.fail(toolError(`Tool is not configured: ${call.name}`, 'not_found'))
268
+ }
269
+
270
+ const normalized = yield* normalizeToolParams(params)
271
+ const result = yield* sandbox.run(normalized).pipe(
272
+ Effect.mapError(error => {
273
+ switch (error._tag) {
274
+ case 'SandboxInputError':
275
+ case 'SandboxExpiredError':
276
+ return modelVisibleSandboxError(error)
277
+ case 'SandboxConfigError':
278
+ case 'SandboxProviderError':
279
+ case 'SandboxStateError':
280
+ case 'SandboxStateStoreError':
281
+ return sandboxInfraToolError(error)
282
+ }
283
+ })
284
+ )
285
+
286
+ return makeSandboxToolResult({ callId: call.id, result })
287
+ })
288
+ })
289
+ ]
290
+ })
291
+
292
+ export const makeSandboxToolModule = <Context>(
293
+ options: SandboxToolModuleOptions<Context> = {}
294
+ ): Effect.Effect<ToolModule<Context>, never, Sandbox> =>
295
+ Effect.gen(function* () {
296
+ const sandbox = yield* Sandbox
297
+ return makeSandboxToolModuleFromApi(sandbox, options)
298
+ })
299
+
300
+ export type SandboxToolExecutionInput<Context> = {
301
+ readonly call: ToolCall
302
+ readonly context: Context
303
+ readonly params: SandboxToolParams
304
+ }