@larkup/sandbox 0.1.1 → 0.1.3

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 (71) hide show
  1. package/.turbo/turbo-build.log +2 -4
  2. package/CHANGELOG.md +12 -0
  3. package/dist/docker-runner.js +35 -21
  4. package/dist/index.d.ts +4 -2
  5. package/dist/index.js +3 -1
  6. package/dist/local-runner.d.ts +5 -0
  7. package/dist/local-runner.js +176 -0
  8. package/dist/mime.d.ts +1 -0
  9. package/dist/mime.js +16 -0
  10. package/dist/providers/browserbase.d.ts +12 -0
  11. package/dist/providers/browserbase.js +61 -0
  12. package/dist/providers/cloudflare.d.ts +5 -0
  13. package/dist/providers/cloudflare.js +55 -0
  14. package/dist/providers/daytona.d.ts +12 -0
  15. package/dist/providers/daytona.js +84 -0
  16. package/dist/providers/e2b.d.ts +9 -0
  17. package/dist/providers/e2b.js +70 -0
  18. package/dist/providers/flyio.d.ts +9 -0
  19. package/dist/providers/flyio.js +98 -0
  20. package/dist/providers/index.d.ts +13 -0
  21. package/dist/providers/index.js +26 -0
  22. package/dist/providers/modal.d.ts +8 -0
  23. package/dist/providers/modal.js +96 -0
  24. package/dist/providers/northflank.d.ts +9 -0
  25. package/dist/providers/northflank.js +122 -0
  26. package/dist/providers/vercel.d.ts +9 -0
  27. package/dist/providers/vercel.js +79 -0
  28. package/dist/providers/webcontainers.d.ts +13 -0
  29. package/dist/providers/webcontainers.js +28 -0
  30. package/dist/registry.d.ts +20 -0
  31. package/dist/registry.js +281 -0
  32. package/dist/remote-exec.d.ts +42 -0
  33. package/dist/remote-exec.js +144 -0
  34. package/dist/sandbox-manager.d.ts +5 -2
  35. package/dist/sandbox-manager.js +67 -46
  36. package/dist/types.d.ts +58 -9
  37. package/dist/types.js +1 -1
  38. package/package.json +23 -6
  39. package/src/docker-runner.ts +39 -27
  40. package/src/index.ts +11 -2
  41. package/src/local-runner.test.ts +22 -0
  42. package/src/local-runner.ts +199 -0
  43. package/src/mime.ts +17 -0
  44. package/src/providers/browserbase.test.ts +55 -0
  45. package/src/providers/browserbase.ts +88 -0
  46. package/src/providers/cloudflare.test.ts +63 -0
  47. package/src/providers/cloudflare.ts +72 -0
  48. package/src/providers/daytona.test.ts +60 -0
  49. package/src/providers/daytona.ts +144 -0
  50. package/src/providers/e2b.test.ts +100 -0
  51. package/src/providers/e2b.ts +115 -0
  52. package/src/providers/flyio.test.ts +69 -0
  53. package/src/providers/flyio.ts +148 -0
  54. package/src/providers/index.ts +42 -0
  55. package/src/providers/modal.test.ts +107 -0
  56. package/src/providers/modal.ts +167 -0
  57. package/src/providers/northflank.test.ts +110 -0
  58. package/src/providers/northflank.ts +199 -0
  59. package/src/providers/vercel.test.ts +84 -0
  60. package/src/providers/vercel.ts +143 -0
  61. package/src/providers/webcontainers.test.ts +21 -0
  62. package/src/providers/webcontainers.ts +36 -0
  63. package/src/registry.test.ts +84 -0
  64. package/src/registry.ts +308 -0
  65. package/src/remote-exec.test.ts +155 -0
  66. package/src/remote-exec.ts +190 -0
  67. package/src/sandbox-manager.test.ts +74 -0
  68. package/src/sandbox-manager.ts +71 -51
  69. package/src/types.ts +81 -23
  70. package/tsconfig.build.json +4 -0
  71. package/vitest.config.ts +9 -0
@@ -0,0 +1,308 @@
1
+ import type { SandboxBackend, SandboxProviderDescriptor } from './types.js';
2
+
3
+ /**
4
+ * Declarative registry of the remote sandbox providers @larkup/sandbox can
5
+ * execute code on, besides the always-available local Docker backend.
6
+ *
7
+ * This single object powers two consumers:
8
+ * 1. The Settings UI — renders `fields` dynamically so each provider asks
9
+ * for exactly the credentials it needs, the same way vector store and
10
+ * embedding provider fields are rendered from their own registries.
11
+ * 2. `SandboxManager` / the provider adapters — read `id` to route
12
+ * execution and `executionSupport` to fail fast with a clear message
13
+ * instead of pretending every provider can run arbitrary code.
14
+ *
15
+ * Adding a new provider = one entry here + one adapter in `./providers`.
16
+ */
17
+ export const SANDBOX_PROVIDERS: Record<
18
+ Exclude<SandboxBackend, 'local' | 'docker' | 'custom'>,
19
+ SandboxProviderDescriptor
20
+ > = {
21
+ e2b: {
22
+ id: 'e2b',
23
+ label: 'E2B',
24
+ description:
25
+ 'Firecracker-microVM sandboxes purpose-built for AI code execution, with fast cold starts.',
26
+ sdkPackage: 'e2b',
27
+ docsUrl: 'https://docs.e2b.dev/',
28
+ icon: '/icons/e2b.png',
29
+ executionSupport: 'full',
30
+ fields: [
31
+ {
32
+ key: 'apiKey',
33
+ label: 'API key',
34
+ type: 'password',
35
+ required: true,
36
+ secret: true,
37
+ placeholder: 'e2b_...',
38
+ help: 'From the E2B dashboard. Equivalent to the E2B_API_KEY environment variable.',
39
+ },
40
+ {
41
+ key: 'snapshotId',
42
+ label: 'Snapshot or template ID',
43
+ type: 'text',
44
+ required: false,
45
+ help: 'Optional prebuilt environment with your required packages. New executions start from it.',
46
+ },
47
+ ],
48
+ },
49
+
50
+ vercel: {
51
+ id: 'vercel',
52
+ label: 'Vercel Sandbox',
53
+ description:
54
+ 'Isolated Firecracker microVMs for running arbitrary or untrusted code, billed through your Vercel account.',
55
+ sdkPackage: '@vercel/sandbox',
56
+ docsUrl: 'https://vercel.com/docs/sandbox',
57
+ icon: '/icons/vercel.svg',
58
+ executionSupport: 'full',
59
+ fields: [
60
+ {
61
+ key: 'token',
62
+ label: 'Access token',
63
+ type: 'password',
64
+ required: true,
65
+ secret: true,
66
+ help: 'Vercel access token with Sandbox permissions. Equivalent to VERCEL_TOKEN.',
67
+ },
68
+ {
69
+ key: 'teamId',
70
+ label: 'Team ID',
71
+ type: 'text',
72
+ required: true,
73
+ placeholder: 'team_...',
74
+ help: 'Found in Vercel Team Settings. Equivalent to VERCEL_TEAM_ID.',
75
+ },
76
+ {
77
+ key: 'projectId',
78
+ label: 'Project ID',
79
+ type: 'text',
80
+ required: true,
81
+ placeholder: 'prj_...',
82
+ help: 'The Vercel project sandboxes run under. Equivalent to VERCEL_PROJECT_ID.',
83
+ },
84
+ {
85
+ key: 'snapshotId',
86
+ label: 'Snapshot ID',
87
+ type: 'text',
88
+ required: false,
89
+ help: 'Optional Vercel snapshot to restore for each execution.',
90
+ },
91
+ ],
92
+ },
93
+
94
+ modal: {
95
+ id: 'modal',
96
+ label: 'Modal',
97
+ description:
98
+ 'Sandboxes for executing untrusted user or agent code in secure containers on Modal’s compute platform.',
99
+ sdkPackage: 'modal',
100
+ docsUrl: 'https://modal.com/docs/guide/sandboxes',
101
+ icon: '/icons/modal.png',
102
+ executionSupport: 'full',
103
+ fields: [
104
+ {
105
+ key: 'tokenId',
106
+ label: 'Token ID',
107
+ type: 'text',
108
+ required: true,
109
+ placeholder: 'ak-...',
110
+ help: 'From `modal setup` or the Modal dashboard. Equivalent to MODAL_TOKEN_ID.',
111
+ },
112
+ {
113
+ key: 'tokenSecret',
114
+ label: 'Token secret',
115
+ type: 'password',
116
+ required: true,
117
+ secret: true,
118
+ placeholder: 'as-...',
119
+ help: 'Equivalent to MODAL_TOKEN_SECRET.',
120
+ },
121
+ ],
122
+ },
123
+
124
+ daytona: {
125
+ id: 'daytona',
126
+ label: 'Daytona',
127
+ description:
128
+ 'Elastic infrastructure for running AI-generated code in fast, isolated sandboxes.',
129
+ sdkPackage: '@daytonaio/sdk',
130
+ docsUrl: 'https://www.daytona.io/docs/en/sandboxes/',
131
+ icon: '/icons/daytonaio.png',
132
+ executionSupport: 'full',
133
+ fields: [
134
+ {
135
+ key: 'apiKey',
136
+ label: 'API key',
137
+ type: 'password',
138
+ required: true,
139
+ secret: true,
140
+ help: 'From the Daytona dashboard. Equivalent to DAYTONA_API_KEY.',
141
+ },
142
+ {
143
+ key: 'apiUrl',
144
+ label: 'API URL',
145
+ type: 'text',
146
+ required: false,
147
+ placeholder: 'https://app.daytona.io/api',
148
+ help: 'Only needed for a self-hosted Daytona instance.',
149
+ },
150
+ {
151
+ key: 'snapshot',
152
+ label: 'Snapshot',
153
+ type: 'text',
154
+ required: false,
155
+ help: 'Optional Daytona snapshot containing the dependencies your agent needs.',
156
+ },
157
+ ],
158
+ },
159
+
160
+ browserbase: {
161
+ id: 'browserbase',
162
+ label: 'Browserbase',
163
+ description: 'Headless-Chrome browser sessions for Playwright/Puppeteer automation.',
164
+ sdkPackage: '@browserbasehq/sdk',
165
+ docsUrl: 'https://docs.browserbase.com/welcome/introduction',
166
+ icon: '/icons/browserbase.png',
167
+ executionSupport: 'unsupported',
168
+ executionCaveat:
169
+ 'Browserbase provisions remote browser sessions reachable over CDP for Playwright/Puppeteer — it does not run arbitrary Python/JS and return stdout the way a code-execution sandbox does. Verify still checks your API key and project, but code-execution tools (data analysis, corpus analytics) need a different provider.',
170
+ fields: [
171
+ {
172
+ key: 'apiKey',
173
+ label: 'API key',
174
+ type: 'password',
175
+ required: true,
176
+ secret: true,
177
+ help: 'Equivalent to BROWSERBASE_API_KEY.',
178
+ },
179
+ {
180
+ key: 'projectId',
181
+ label: 'Project ID',
182
+ type: 'text',
183
+ required: true,
184
+ help: 'Equivalent to BROWSERBASE_PROJECT_ID.',
185
+ },
186
+ ],
187
+ },
188
+
189
+ flyio: {
190
+ id: 'flyio',
191
+ label: 'Fly.io Sprites',
192
+ description:
193
+ 'Persistent, fast-suspending Linux VM sandboxes for AI agents, built on Fly Machines.',
194
+ sdkPackage: '@fly/sprites',
195
+ docsUrl: 'https://fly.io/sprites/',
196
+ icon: '/icons/flyio.jpeg',
197
+ executionSupport: 'full',
198
+ fields: [
199
+ {
200
+ key: 'token',
201
+ label: 'Sprite token',
202
+ type: 'password',
203
+ required: true,
204
+ secret: true,
205
+ help: 'From `sprite org auth` or the Fly dashboard. Equivalent to SPRITE_TOKEN.',
206
+ },
207
+ ],
208
+ },
209
+
210
+ northflank: {
211
+ id: 'northflank',
212
+ label: 'Northflank',
213
+ description: 'On-demand sandbox containers running as Northflank services in your own project.',
214
+ sdkPackage: '@northflank/js-client',
215
+ docsUrl: 'https://northflank.com/docs/v1/application/sandboxes/sandboxes-on-northflank',
216
+ icon: '/icons/Northflank.png',
217
+ executionSupport: 'full',
218
+ fields: [
219
+ {
220
+ key: 'token',
221
+ label: 'API token',
222
+ type: 'password',
223
+ required: true,
224
+ secret: true,
225
+ help: 'A personal or team API token from Northflank → Account → API Tokens.',
226
+ },
227
+ {
228
+ key: 'projectId',
229
+ label: 'Project ID',
230
+ type: 'text',
231
+ required: true,
232
+ help: 'The Northflank project sandbox services are created in.',
233
+ },
234
+ ],
235
+ },
236
+
237
+ cloudflare: {
238
+ id: 'cloudflare',
239
+ label: 'Cloudflare Sandbox',
240
+ description:
241
+ 'Container sandboxes running inside a Cloudflare Worker via the Durable Object Sandbox SDK.',
242
+ sdkPackage: '@cloudflare/sandbox',
243
+ docsUrl: 'https://developers.cloudflare.com/sandbox/',
244
+ icon: '/icons/cloudflare.png',
245
+ executionSupport: 'unsupported',
246
+ executionCaveat:
247
+ '@cloudflare/sandbox only runs inside a Cloudflare Worker through a Durable Object binding declared in wrangler config. There is no external REST API to create or run commands in a sandbox from this server. Verify checks your account API token; actually running code here requires deploying a Worker with the Sandbox binding.',
248
+ fields: [
249
+ {
250
+ key: 'apiToken',
251
+ label: 'API token',
252
+ type: 'password',
253
+ required: true,
254
+ secret: true,
255
+ help: 'Used only to verify account access.',
256
+ },
257
+ {
258
+ key: 'accountId',
259
+ label: 'Account ID',
260
+ type: 'text',
261
+ required: true,
262
+ },
263
+ ],
264
+ },
265
+
266
+ webcontainers: {
267
+ id: 'webcontainers',
268
+ label: 'WebContainers',
269
+ description: 'In-browser WebAssembly Node.js runtime for running full-stack apps client-side.',
270
+ sdkPackage: '@webcontainer/api',
271
+ docsUrl: 'https://webcontainers.io/',
272
+ icon: '/icons/webcontainers.svg',
273
+ executionSupport: 'unsupported',
274
+ executionCaveat:
275
+ 'WebContainers boots entirely inside a cross-origin-isolated browser tab (it needs SharedArrayBuffer). There is no Node.js/server-side way to run it, so this backend cannot execute code from here. Use @webcontainer/api directly in your frontend for in-browser execution instead.',
276
+ fields: [
277
+ {
278
+ key: 'apiKey',
279
+ label: 'License key (optional)',
280
+ type: 'password',
281
+ required: false,
282
+ secret: true,
283
+ help: 'StackBlitz-issued commercial key for production embeds. Leave blank for local/prototype use.',
284
+ },
285
+ ],
286
+ },
287
+ };
288
+
289
+ export const SANDBOX_PROVIDER_LIST: SandboxProviderDescriptor[] = Object.values(SANDBOX_PROVIDERS);
290
+
291
+ export function getSandboxProvider(id: SandboxBackend): SandboxProviderDescriptor | undefined {
292
+ if (id === 'local' || id === 'docker' || id === 'custom') return undefined;
293
+ return SANDBOX_PROVIDERS[id];
294
+ }
295
+
296
+ /** Validates credentials against a provider's registered fields, mirroring validateStoreConfig. */
297
+ export function validateSandboxCredentials(
298
+ provider: SandboxProviderDescriptor,
299
+ values: Record<string, string>,
300
+ ): Record<string, string> {
301
+ const errors: Record<string, string> = {};
302
+ for (const field of provider.fields) {
303
+ if (field.required && !values[field.key]?.trim()) {
304
+ errors[field.key] = `${field.label} is required`;
305
+ }
306
+ }
307
+ return errors;
308
+ }
@@ -0,0 +1,155 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { buildExecutionScript, failedResult, runScriptedExecution, withTimeout } from './remote-exec.js';
3
+ import type { ExecutionRequest } from './types.js';
4
+
5
+ describe('buildExecutionScript', () => {
6
+ it('writes the code to run.py and invokes python3 for python requests', () => {
7
+ const request: ExecutionRequest = { code: 'print(1)', language: 'python' };
8
+ const script = buildExecutionScript(request);
9
+ expect(script).toContain("mkdir -p '/tmp/larkup-sandbox/output'");
10
+ expect(script).toContain("base64 -d > 'run.py'");
11
+ expect(script).toContain("python3 'run.py'");
12
+ expect(script).not.toContain("node 'run.js'");
13
+ });
14
+
15
+ it('writes the code to run.js and invokes node for non-python requests', () => {
16
+ const request: ExecutionRequest = { code: 'console.log(1)', language: 'javascript' };
17
+ const script = buildExecutionScript(request);
18
+ expect(script).toContain("base64 -d > 'run.js'");
19
+ expect(script).toContain("node 'run.js'");
20
+ });
21
+
22
+ it('base64-encodes and stages every input file before running the code', () => {
23
+ const request: ExecutionRequest = {
24
+ code: 'x=1',
25
+ language: 'python',
26
+ files: [{ name: 'data.csv', content: 'a,b\n1,2' }],
27
+ };
28
+ const script = buildExecutionScript(request);
29
+ const expectedBase64 = Buffer.from('a,b\n1,2', 'utf8').toString('base64');
30
+ expect(script).toContain("base64 -d > 'data.csv'");
31
+ expect(script).toContain(expectedBase64);
32
+ // File staging must happen before the code executes.
33
+ expect(script.indexOf("'data.csv'")).toBeLessThan(script.indexOf("'run.py'"));
34
+ });
35
+
36
+ it('passes already-base64 file content through unmodified', () => {
37
+ const b64 = Buffer.from('binary-ish').toString('base64');
38
+ const request: ExecutionRequest = {
39
+ code: 'x=1',
40
+ language: 'python',
41
+ files: [{ name: 'blob.bin', content: b64, isBase64: true }],
42
+ };
43
+ const script = buildExecutionScript(request);
44
+ expect(script).toContain(b64);
45
+ });
46
+
47
+ it('single-quotes filenames so shell metacharacters cannot break out', () => {
48
+ const request: ExecutionRequest = {
49
+ code: 'x=1',
50
+ language: 'python',
51
+ files: [{ name: "weird'name.csv", content: 'a' }],
52
+ };
53
+ const script = buildExecutionScript(request);
54
+ expect(script).toContain(String.raw`'weird'\''name.csv'`);
55
+ });
56
+
57
+ it('rejects path-like input file names', () => {
58
+ expect(() =>
59
+ buildExecutionScript({
60
+ code: 'x=1',
61
+ language: 'python',
62
+ files: [{ name: '../escape.py', content: 'x' }],
63
+ }),
64
+ ).toThrow('Sandbox file names must be plain file names.');
65
+ });
66
+
67
+ it('emits an artifact marker line that lists the output directory', () => {
68
+ const script = buildExecutionScript({ code: 'x=1', language: 'python' });
69
+ expect(script).toContain('__LARKUP_ARTIFACTS__:');
70
+ expect(script).toContain("ls -1 '/tmp/larkup-sandbox/output'");
71
+ });
72
+ });
73
+
74
+ describe('runScriptedExecution', () => {
75
+ it('passes stdout/stderr/exitCode straight through when there are no artifacts', async () => {
76
+ const execFn = vi.fn().mockResolvedValue({ stdout: 'hello\n', stderr: '', exitCode: 0 });
77
+ const result = await runScriptedExecution(execFn, { code: 'print(1)', language: 'python' });
78
+
79
+ expect(execFn).toHaveBeenCalledTimes(1);
80
+ expect(result.stdout).toBe('hello\n');
81
+ expect(result.stderr).toBe('');
82
+ expect(result.exitCode).toBe(0);
83
+ expect(result.artifacts).toEqual([]);
84
+ expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
85
+ });
86
+
87
+ it('strips the artifact marker from stdout and fetches each named artifact', async () => {
88
+ const chartBase64 = Buffer.from('fake-png-bytes').toString('base64');
89
+ const execFn = vi
90
+ .fn()
91
+ .mockResolvedValueOnce({
92
+ stdout: 'analysis done\n__LARKUP_ARTIFACTS__:chart_0.png,chart_1.png\n',
93
+ stderr: '',
94
+ exitCode: 0,
95
+ })
96
+ .mockResolvedValueOnce({ stdout: chartBase64, stderr: '', exitCode: 0 })
97
+ .mockResolvedValueOnce({ stdout: chartBase64, stderr: '', exitCode: 0 });
98
+
99
+ const result = await runScriptedExecution(execFn, { code: 'x=1', language: 'python' });
100
+
101
+ expect(result.stdout).toBe('analysis done');
102
+ expect(execFn).toHaveBeenCalledTimes(3);
103
+ expect(result.artifacts).toHaveLength(2);
104
+ expect(result.artifacts[0]).toEqual({ name: 'chart_0.png', mimeType: 'image/png', data: chartBase64 });
105
+ expect(result.artifacts[1].name).toBe('chart_1.png');
106
+ });
107
+
108
+ it('drops an artifact whose follow-up fetch fails instead of failing the whole execution', async () => {
109
+ const execFn = vi
110
+ .fn()
111
+ .mockResolvedValueOnce({ stdout: '__LARKUP_ARTIFACTS__:missing.png\n', stderr: '', exitCode: 0 })
112
+ .mockRejectedValueOnce(new Error('file gone'));
113
+
114
+ const result = await runScriptedExecution(execFn, { code: 'x=1', language: 'python' });
115
+ expect(result.exitCode).toBe(0);
116
+ expect(result.artifacts).toEqual([]);
117
+ });
118
+
119
+ it('leaves a non-zero exit code and stderr message untouched', async () => {
120
+ const execFn = vi.fn().mockResolvedValue({ stdout: '', stderr: 'Traceback...', exitCode: 1 });
121
+ const result = await runScriptedExecution(execFn, { code: 'raise ValueError()', language: 'python' });
122
+ expect(result.exitCode).toBe(1);
123
+ expect(result.stderr).toBe('Traceback...');
124
+ });
125
+ });
126
+
127
+ describe('withTimeout', () => {
128
+ it('resolves with the underlying value when it settles first', async () => {
129
+ await expect(withTimeout(Promise.resolve('ok'), 1000, 'test')).resolves.toBe('ok');
130
+ });
131
+
132
+ it('rejects with a labeled error once the timeout elapses', async () => {
133
+ vi.useFakeTimers();
134
+ try {
135
+ const never = new Promise(() => {});
136
+ const pending = withTimeout(never, 50, 'slow op');
137
+ const assertion = expect(pending).rejects.toThrow('slow op timed out after 50ms');
138
+ await vi.advanceTimersByTimeAsync(60);
139
+ await assertion;
140
+ } finally {
141
+ vi.useRealTimers();
142
+ }
143
+ });
144
+ });
145
+
146
+ describe('failedResult', () => {
147
+ it('produces a normalized failure shape', () => {
148
+ const result = failedResult('boom', Date.now() - 5);
149
+ expect(result.exitCode).toBe(1);
150
+ expect(result.stdout).toBe('');
151
+ expect(result.stderr).toBe('boom');
152
+ expect(result.artifacts).toEqual([]);
153
+ expect(result.executionTimeMs).toBeGreaterThanOrEqual(0);
154
+ });
155
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Shared execution plumbing for every cloud sandbox adapter (E2B, Vercel
3
+ * Sandbox, Modal, Daytona, Fly Sprites, Northflank).
4
+ *
5
+ * Each of those providers exposes the same primitive — "run this shell
6
+ * command inside my sandbox and give me stdout/stderr/exitCode back" — but
7
+ * with a different SDK shape (commands.run, runCommand, exec, ...). Rather
8
+ * than re-implement file staging + artifact capture per provider, adapters
9
+ * plug their SDK's exec call in as a `RemoteExecFn` and hand it to
10
+ * `runScriptedExecution`, which builds one POSIX shell script that writes
11
+ * any input files, runs the code, and reports back any files written to an
12
+ * output directory (mirroring what the Docker adapter gets for free via a
13
+ * bind mount).
14
+ */
15
+
16
+ import type { ExecutionArtifact, ExecutionRequest, ExecutionResult } from './types.js';
17
+ import { getMimeType } from './mime.js';
18
+
19
+ /** Default per-execution timeout for remote adapters when a request doesn't specify one. */
20
+ export const DEFAULT_TIMEOUT_MS = 30_000;
21
+
22
+ const WORKDIR = '/tmp/larkup-sandbox';
23
+ const OUTPUT_DIR = `${WORKDIR}/output`;
24
+ const ARTIFACT_MARKER = '__LARKUP_ARTIFACTS__:';
25
+ /** Not part of the base64 alphabet, so it can never collide with an encoded file body. */
26
+ const HEREDOC_DELIMITER = 'LARKUP__EOF__';
27
+
28
+ export interface RemoteCommandResult {
29
+ stdout: string;
30
+ stderr: string;
31
+ exitCode: number;
32
+ }
33
+
34
+ /** Runs a single shell command/script inside a provider's sandbox instance. */
35
+ export type RemoteExecFn = (script: string) => Promise<RemoteCommandResult>;
36
+
37
+ function assertSafeFileName(name: string): void {
38
+ if (
39
+ !name ||
40
+ name === '.' ||
41
+ name === '..' ||
42
+ name.includes('/') ||
43
+ name.includes('\\') ||
44
+ name.includes('\0')
45
+ ) {
46
+ throw new Error('Sandbox file names must be plain file names.');
47
+ }
48
+ }
49
+
50
+ function shellSingleQuote(value: string): string {
51
+ return `'${value.replace(/'/g, `'\\''`)}'`;
52
+ }
53
+
54
+ function writeBase64FileScript(name: string, base64: string): string {
55
+ return `base64 -d > ${shellSingleQuote(
56
+ name,
57
+ )} <<'${HEREDOC_DELIMITER}'\n${base64}\n${HEREDOC_DELIMITER}`;
58
+ }
59
+
60
+ /** Builds the POSIX shell script a remote adapter hands to its provider's exec call. */
61
+ export function buildExecutionScript(request: ExecutionRequest): string {
62
+ const lines: string[] = [
63
+ 'set -e',
64
+ `mkdir -p ${shellSingleQuote(OUTPUT_DIR)}`,
65
+ `cd ${shellSingleQuote(WORKDIR)}`,
66
+ ];
67
+
68
+ for (const file of request.files ?? []) {
69
+ assertSafeFileName(file.name);
70
+ const base64 = file.isBase64
71
+ ? file.content
72
+ : Buffer.from(file.content, 'utf8').toString('base64');
73
+ lines.push(writeBase64FileScript(file.name, base64));
74
+ }
75
+
76
+ const scriptName = request.language === 'python' ? 'run.py' : 'run.js';
77
+ const runner = request.language === 'python' ? 'python3' : 'node';
78
+ const codeBase64 = Buffer.from(request.code, 'utf8').toString('base64');
79
+ lines.push(writeBase64FileScript(scriptName, codeBase64));
80
+ lines.push(`${runner} ${shellSingleQuote(scriptName)}`);
81
+ lines.push(
82
+ `if [ -d ${shellSingleQuote(
83
+ OUTPUT_DIR,
84
+ )} ]; then echo "${ARTIFACT_MARKER}$(ls -1 ${shellSingleQuote(
85
+ OUTPUT_DIR,
86
+ )} 2>/dev/null | tr '\\n' ',')"; fi`,
87
+ );
88
+
89
+ return lines.join('\n');
90
+ }
91
+
92
+ function extractArtifactMarker(stdout: string): { stdout: string; artifactNames: string[] } {
93
+ const markerIndex = stdout.lastIndexOf(ARTIFACT_MARKER);
94
+ if (markerIndex === -1) return { stdout, artifactNames: [] };
95
+
96
+ const before = stdout.slice(0, markerIndex);
97
+ const after = stdout.slice(markerIndex + ARTIFACT_MARKER.length);
98
+ const newlineIndex = after.indexOf('\n');
99
+ const namesPart = newlineIndex === -1 ? after : after.slice(0, newlineIndex);
100
+ const rest = newlineIndex === -1 ? '' : after.slice(newlineIndex + 1);
101
+
102
+ const artifactNames = namesPart
103
+ .split(',')
104
+ .map((name) => name.trim())
105
+ .filter(Boolean);
106
+
107
+ return { stdout: (before + rest).replace(/\n$/, ''), artifactNames };
108
+ }
109
+
110
+ /**
111
+ * Runs `request` via `execFn` and reassembles an `ExecutionResult`,
112
+ * including a best-effort fetch of any files the code wrote to the output
113
+ * directory (one follow-up `base64 <file>` call per artifact).
114
+ */
115
+ export async function runScriptedExecution(
116
+ execFn: RemoteExecFn,
117
+ request: ExecutionRequest,
118
+ ): Promise<ExecutionResult> {
119
+ const startTime = Date.now();
120
+ const script = buildExecutionScript(request);
121
+ const main = await execFn(script);
122
+
123
+ const { stdout, artifactNames } = extractArtifactMarker(main.stdout);
124
+ const artifacts: ExecutionArtifact[] = [];
125
+ for (const name of artifactNames) {
126
+ if (!name || name.includes('/') || name.includes('\\') || name === '.' || name === '..')
127
+ continue;
128
+ try {
129
+ const cat = await execFn(`base64 ${shellSingleQuote(`${OUTPUT_DIR}/${name}`)}`);
130
+ const data = cat.stdout.replace(/\s+/g, '');
131
+ if (cat.exitCode === 0 && data) {
132
+ artifacts.push({ name, mimeType: getMimeType(name), data });
133
+ }
134
+ } catch {
135
+ /* one missing/unreadable artifact shouldn't fail the whole execution */
136
+ }
137
+ }
138
+
139
+ return {
140
+ stdout,
141
+ stderr: main.stderr,
142
+ exitCode: main.exitCode,
143
+ artifacts,
144
+ executionTimeMs: Date.now() - startTime,
145
+ };
146
+ }
147
+
148
+ /** Uniform safety net for providers whose SDK doesn't take its own per-call timeout. */
149
+ export async function withTimeout<T>(
150
+ promise: Promise<T>,
151
+ timeoutMs: number,
152
+ label: string,
153
+ ): Promise<T> {
154
+ let timer: ReturnType<typeof setTimeout>;
155
+ const timeout = new Promise<never>((_, reject) => {
156
+ timer = setTimeout(
157
+ () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
158
+ timeoutMs,
159
+ );
160
+ });
161
+ try {
162
+ return await Promise.race([promise, timeout]);
163
+ } finally {
164
+ clearTimeout(timer!);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Runs a teardown call (kill/delete/terminate/stop) and swallows whatever it
170
+ * throws or rejects with — cleanup failures shouldn't mask the actual
171
+ * execution result. Wrapping in try/catch (rather than `.catch()`) also
172
+ * tolerates a teardown method that doesn't return a real promise.
173
+ */
174
+ export async function safeCleanup(fn: () => Promise<unknown>): Promise<void> {
175
+ try {
176
+ await fn();
177
+ } catch {
178
+ /* best effort */
179
+ }
180
+ }
181
+
182
+ export function failedResult(message: string, startTime: number): ExecutionResult {
183
+ return {
184
+ stdout: '',
185
+ stderr: message,
186
+ exitCode: 1,
187
+ artifacts: [],
188
+ executionTimeMs: Date.now() - startTime,
189
+ };
190
+ }