@larkup/sandbox 0.1.4 → 0.1.6

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.
@@ -1,4 +1,4 @@
1
1
 
2
- > @larkup/sandbox@0.1.4 build /Users/abdelrahmanabounida/Desktop/my-ai-startup/larkup/larkup/packages/sandbox
2
+ > @larkup/sandbox@0.1.6 build /Users/abdelrahmanabounida/Desktop/my-ai-startup/larkup/larkup/packages/sandbox
3
3
  > tsc -p tsconfig.build.json --outDir dist
4
4
 
@@ -1,26 +1,27 @@
1
1
 
2
- > @larkup/sandbox@0.1.4 test /Users/abdelrahmanabounida/Desktop/my-ai-startup/larkup/larkup/packages/sandbox
2
+ > @larkup/sandbox@0.1.6 test /Users/abdelrahmanabounida/Desktop/my-ai-startup/larkup/larkup/packages/sandbox
3
3
  > vitest run
4
4
 
5
5
 
6
6
  RUN v4.1.11 /Users/abdelrahmanabounida/Desktop/my-ai-startup/larkup/larkup/packages/sandbox
7
7
 
8
- ✓ src/registry.test.ts (9 tests) 3ms
9
- ✓ src/providers/modal.test.ts (4 tests) 10ms
10
- ✓ src/providers/e2b.test.ts (9 tests) 5ms
8
+ ✓ src/providers/vercel.test.ts (6 tests) 20ms
9
+ ✓ src/sandbox-manager.test.ts (3 tests) 6ms
10
+ ✓ src/remote-exec.test.ts (14 tests) 8ms
11
+ ✓ src/providers/e2b.test.ts (9 tests) 18ms
11
12
  ✓ src/providers/browserbase.test.ts (5 tests) 4ms
12
- ✓ src/remote-exec.test.ts (14 tests) 12ms
13
- ✓ src/providers/northflank.test.ts (7 tests) 26ms
14
- ✓ src/providers/flyio.test.ts (6 tests) 4ms
15
- ✓ src/providers/daytona.test.ts (5 tests) 9ms
16
- ✓ src/local-runner.test.ts (1 test) 191ms
17
- ✓ src/providers/webcontainers.test.ts (3 tests) 5ms
18
- ✓ src/providers/vercel.test.ts (6 tests) 35ms
19
- ✓ src/sandbox-manager.test.ts (2 tests) 5ms
20
- ✓ src/providers/cloudflare.test.ts (6 tests) 9ms
13
+ ✓ src/local-runner-provisioning.test.ts (1 test) 15ms
14
+ ✓ src/providers/daytona.test.ts (5 tests) 6ms
15
+ ✓ src/local-runner.test.ts (2 tests) 292ms
16
+ ✓ src/providers/northflank.test.ts (7 tests) 9ms
17
+ ✓ src/providers/flyio.test.ts (6 tests) 8ms
18
+ ✓ src/registry.test.ts (9 tests) 10ms
19
+ ✓ src/providers/cloudflare.test.ts (6 tests) 40ms
20
+ ✓ src/providers/modal.test.ts (4 tests) 9ms
21
+ ✓ src/providers/webcontainers.test.ts (3 tests) 36ms
21
22
 
22
- Test Files 13 passed (13)
23
- Tests 77 passed (77)
24
- Start at 02:57:15
25
- Duration 1.60s (transform 2.39s, setup 0ms, import 4.20s, tests 320ms, environment 2ms)
23
+ Test Files 14 passed (14)
24
+ Tests 80 passed (80)
25
+ Start at 11:36:11
26
+ Duration 2.02s (transform 1.97s, setup 0ms, import 4.11s, tests 481ms, environment 3ms)
26
27
 
@@ -1,4 +1,4 @@
1
1
 
2
- > @larkup/sandbox@0.1.4 type-check /Users/abdelrahmanabounida/Desktop/my-ai-startup/larkup/larkup/packages/sandbox
2
+ > @larkup/sandbox@0.1.6 type-check /Users/abdelrahmanabounida/Desktop/my-ai-startup/larkup/larkup/packages/sandbox
3
3
  > tsc --noEmit
4
4
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @larkup/sandbox
2
2
 
3
+ ## 0.1.6
4
+
5
+ ### Patch Changes
6
+
7
+ - ceb12a5: Prepare an isolated local Python environment automatically for analysis tools, without exposing automatic sandbox readiness or dependency status in Settings.
8
+
9
+ ## 0.1.5
10
+
11
+ ### Patch Changes
12
+
13
+ - 848cc4c: Route spreadsheet questions through structured table queries before optional code analysis, validate local Python analysis dependencies before exposing code tools, and index every non-empty Excel worksheet as a separate dataset.
14
+
3
15
  ## 0.1.4
4
16
 
5
17
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -5,6 +5,6 @@
5
5
  */
6
6
  export { SandboxManager, verifySandboxProvider } from './sandbox-manager.js';
7
7
  export { checkDockerHealth, buildSandboxImage, ensureImage, executeInDocker, } from './docker-runner.js';
8
- export { checkLocalRuntime, executeLocally } from './local-runner.js';
8
+ export { checkLocalRuntime, executeLocally, setupLocalRuntime } from './local-runner.js';
9
9
  export { SANDBOX_PROVIDERS, SANDBOX_PROVIDER_LIST, getSandboxProvider, validateSandboxCredentials, } from './registry.js';
10
10
  export type { SandboxBackend, SandboxConfig, DockerConfig, SandboxLanguage, SandboxFile, ExecutionRequest, ExecutionResult, ExecutionArtifact, SandboxStatus, SandboxHealthCheck, SandboxCredentialField, SandboxProviderDescriptor, SandboxProviderAdapter, } from './types.js';
package/dist/index.js CHANGED
@@ -5,5 +5,5 @@
5
5
  */
6
6
  export { SandboxManager, verifySandboxProvider } from './sandbox-manager.js';
7
7
  export { checkDockerHealth, buildSandboxImage, ensureImage, executeInDocker, } from './docker-runner.js';
8
- export { checkLocalRuntime, executeLocally } from './local-runner.js';
8
+ export { checkLocalRuntime, executeLocally, setupLocalRuntime } from './local-runner.js';
9
9
  export { SANDBOX_PROVIDERS, SANDBOX_PROVIDER_LIST, getSandboxProvider, validateSandboxCredentials, } from './registry.js';
@@ -1,5 +1,7 @@
1
1
  import type { ExecutionRequest, ExecutionResult, SandboxHealthCheck } from './types.js';
2
- /** Checks whether the host can run Python analysis without Docker. */
2
+ /** Creates or repairs Larkup's isolated local Python analysis environment. */
3
+ export declare function setupLocalRuntime(): Promise<void>;
4
+ /** Checks whether the host can bootstrap Larkup's managed Python environment. */
3
5
  export declare function checkLocalRuntime(): Promise<SandboxHealthCheck>;
4
6
  /** Executes trusted code in a temporary host workspace and collects its output. */
5
7
  export declare function executeLocally(request: ExecutionRequest): Promise<ExecutionResult>;
@@ -5,11 +5,59 @@ import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { getMimeType } from './mime.js';
7
7
  let pythonCommand;
8
- function run(command, args, cwd, timeoutMs = 10_000) {
8
+ let managedPythonCommand;
9
+ // Keep this list in sync with the capabilities advertised by executeAnalysis
10
+ // and analyzeCorpusWithCode. These packages are installed in Larkup's managed
11
+ // virtual environment, never into the Python installation on the host.
12
+ const ANALYSIS_PYTHON_MODULES = [
13
+ 'numpy',
14
+ 'pandas',
15
+ 'matplotlib',
16
+ 'scipy',
17
+ 'sklearn',
18
+ 'seaborn',
19
+ 'openpyxl',
20
+ 'xlrd',
21
+ 'pypdf',
22
+ 'docx',
23
+ 'pptx',
24
+ 'PIL',
25
+ ];
26
+ const ANALYSIS_PYTHON_PACKAGES = [
27
+ 'pandas==2.2.3',
28
+ // Python 3.9 ships with several supported desktop environments. Keep the
29
+ // local pins compatible with it rather than requiring a user-managed upgrade.
30
+ 'numpy==1.26.4',
31
+ 'matplotlib==3.9.4',
32
+ 'openpyxl==3.1.5',
33
+ 'scipy==1.13.1',
34
+ 'scikit-learn==1.6.1',
35
+ 'seaborn==0.13.2',
36
+ 'xlrd==2.0.1',
37
+ 'pypdf==5.1.0',
38
+ 'python-docx==1.1.2',
39
+ 'python-pptx==1.0.2',
40
+ 'Pillow==11.1.0',
41
+ ];
42
+ function pythonEnvironment(virtualEnv) {
43
+ const env = { ...process.env };
44
+ // Let neither a user's active virtual environment nor PYTHONPATH leak into
45
+ // the managed runtime. This preserves the repeatable dependency set.
46
+ delete env.PYTHONHOME;
47
+ delete env.PYTHONPATH;
48
+ delete env.VIRTUAL_ENV;
49
+ if (virtualEnv) {
50
+ env.VIRTUAL_ENV = virtualEnv;
51
+ env.PYTHONNOUSERSITE = '1';
52
+ env.PATH = `${path.dirname(virtualEnvPython(virtualEnv))}${path.delimiter}${env.PATH ?? ''}`;
53
+ }
54
+ return env;
55
+ }
56
+ function run(command, args, cwd, timeoutMs = 10_000, env = process.env) {
9
57
  return new Promise((resolve, reject) => {
10
58
  const child = spawn(command.executable, [...command.args, ...args], {
11
59
  cwd,
12
- env: process.env,
60
+ env,
13
61
  shell: false,
14
62
  stdio: ['ignore', 'pipe', 'pipe'],
15
63
  });
@@ -38,7 +86,7 @@ function run(command, args, cwd, timeoutMs = 10_000) {
38
86
  resolve({
39
87
  stdout,
40
88
  stderr: timedOut ? `${stderr}\nExecution timed out.`.trim() : stderr,
41
- exitCode: timedOut ? 124 : code ?? 1,
89
+ exitCode: timedOut ? 124 : (code ?? 1),
42
90
  });
43
91
  });
44
92
  });
@@ -59,7 +107,7 @@ async function resolvePython() {
59
107
  ];
60
108
  for (const candidate of candidates) {
61
109
  try {
62
- if ((await run(candidate, ['--version'])).exitCode === 0) {
110
+ if ((await run(candidate, ['--version'], undefined, 10_000, pythonEnvironment())).exitCode === 0) {
63
111
  pythonCommand = candidate;
64
112
  return candidate;
65
113
  }
@@ -71,6 +119,79 @@ async function resolvePython() {
71
119
  pythonCommand = null;
72
120
  return null;
73
121
  }
122
+ function localRuntimeDirectory() {
123
+ const configured = process.env.LARKUP_SANDBOX_RUNTIME_DIR?.trim();
124
+ if (configured)
125
+ return path.resolve(configured);
126
+ const dataRoot = process.env.LARKUP_DATA_DIR?.trim();
127
+ return path.join(dataRoot ? path.resolve(dataRoot) : path.join(process.cwd(), '.larkup'), 'sandbox', 'python');
128
+ }
129
+ function virtualEnvPython(runtimeDirectory) {
130
+ return process.platform === 'win32'
131
+ ? path.join(runtimeDirectory, 'Scripts', 'python.exe')
132
+ : path.join(runtimeDirectory, 'bin', 'python');
133
+ }
134
+ function dependencyCheckCode() {
135
+ return `import importlib.util\nmodules = ${JSON.stringify(ANALYSIS_PYTHON_MODULES)}\nmissing = [name for name in modules if importlib.util.find_spec(name) is None]\nprint(','.join(missing))\nraise SystemExit(bool(missing))`;
136
+ }
137
+ async function hasAnalysisDependencies(command, runtimeDirectory) {
138
+ try {
139
+ return ((await run(command, ['-c', dependencyCheckCode()], undefined, 30_000, pythonEnvironment(runtimeDirectory))).exitCode === 0);
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ }
145
+ async function prepareManagedPythonRuntime() {
146
+ const bootstrapPython = await resolvePython();
147
+ if (!bootstrapPython) {
148
+ throw new Error('Local code execution needs Python 3.9 or later, or choose Docker or a remote sandbox provider.');
149
+ }
150
+ const runtimeDirectory = localRuntimeDirectory();
151
+ const managedPython = { executable: virtualEnvPython(runtimeDirectory), args: [] };
152
+ const bootstrapCheck = await run(bootstrapPython, ['-c', 'import sys, venv; raise SystemExit(sys.version_info < (3, 9))'], undefined, 10_000, pythonEnvironment());
153
+ if (bootstrapCheck.exitCode !== 0) {
154
+ throw new Error('Local code execution needs Python 3.9 or later with virtual-environment support. Install Python or choose Docker or a remote sandbox provider.');
155
+ }
156
+ const runtimeExists = await fs.access(managedPython.executable).then(() => true, () => false);
157
+ if (!runtimeExists) {
158
+ await fs.mkdir(path.dirname(runtimeDirectory), { recursive: true });
159
+ const created = await run(bootstrapPython, ['-m', 'venv', runtimeDirectory], undefined, 60_000, pythonEnvironment());
160
+ if (created.exitCode !== 0) {
161
+ throw new Error('Larkup could not create its local Python environment. Check that Python virtual environments are enabled, or choose Docker or a remote sandbox provider.');
162
+ }
163
+ }
164
+ if (await hasAnalysisDependencies(managedPython, runtimeDirectory))
165
+ return managedPython;
166
+ const installed = await run(managedPython, [
167
+ '-m',
168
+ 'pip',
169
+ 'install',
170
+ '--disable-pip-version-check',
171
+ '--no-input',
172
+ ...ANALYSIS_PYTHON_PACKAGES,
173
+ ], undefined, 10 * 60_000, pythonEnvironment(runtimeDirectory));
174
+ if (installed.exitCode !== 0 ||
175
+ !(await hasAnalysisDependencies(managedPython, runtimeDirectory))) {
176
+ throw new Error('Larkup could not prepare its local analysis environment. Check the internet connection and retry, or choose Docker or a remote sandbox provider.');
177
+ }
178
+ return managedPython;
179
+ }
180
+ /** Creates or repairs Larkup's isolated local Python analysis environment. */
181
+ export async function setupLocalRuntime() {
182
+ if (!managedPythonCommand) {
183
+ managedPythonCommand = prepareManagedPythonRuntime().catch((error) => {
184
+ managedPythonCommand = undefined;
185
+ throw error;
186
+ });
187
+ }
188
+ await managedPythonCommand;
189
+ }
190
+ async function getManagedPython() {
191
+ await setupLocalRuntime();
192
+ // setupLocalRuntime only resolves after prepareManagedPythonRuntime resolves.
193
+ return managedPythonCommand;
194
+ }
74
195
  function assertSafeFileName(name) {
75
196
  if (!name || name === '.' || name === '..' || /[\\/\0]/.test(name)) {
76
197
  throw new Error('Sandbox file names must be plain file names.');
@@ -105,7 +226,7 @@ except Exception as error:
105
226
  sys.exit(1)
106
227
  `;
107
228
  }
108
- /** Checks whether the host can run Python analysis without Docker. */
229
+ /** Checks whether the host can bootstrap Larkup's managed Python environment. */
109
230
  export async function checkLocalRuntime() {
110
231
  const python = await resolvePython();
111
232
  if (!python) {
@@ -115,6 +236,23 @@ export async function checkLocalRuntime() {
115
236
  error: 'Local code execution needs Python 3. Install Python or choose Docker or a remote sandbox provider.',
116
237
  };
117
238
  }
239
+ try {
240
+ const bootstrapCheck = await run(python, ['-c', 'import sys, venv; raise SystemExit(sys.version_info < (3, 9))'], undefined, 10_000, pythonEnvironment());
241
+ if (bootstrapCheck.exitCode !== 0) {
242
+ return {
243
+ status: 'error',
244
+ backend: 'local',
245
+ error: 'Local code execution needs Python 3.9 or later with virtual-environment support. Install Python or choose Docker or a remote sandbox provider.',
246
+ };
247
+ }
248
+ }
249
+ catch {
250
+ return {
251
+ status: 'error',
252
+ backend: 'local',
253
+ error: 'Larkup could not verify Python virtual-environment support. Choose Docker or a remote sandbox provider.',
254
+ };
255
+ }
118
256
  return { status: 'ready', backend: 'local' };
119
257
  }
120
258
  /** Executes trusted code in a temporary host workspace and collects its output. */
@@ -131,22 +269,15 @@ export async function executeLocally(request) {
131
269
  await fs.writeFile(path.join(inputDir, file.name), file.isBase64 ? Buffer.from(file.content, 'base64') : file.content);
132
270
  }
133
271
  const isPython = request.language === 'python';
134
- const command = isPython ? await resolvePython() : { executable: process.execPath, args: [] };
135
- if (!command) {
136
- return {
137
- stdout: '',
138
- stderr: 'Local code execution needs Python 3. Install Python or choose Docker or a remote sandbox provider.',
139
- exitCode: 1,
140
- artifacts: [],
141
- executionTimeMs: Date.now() - startTime,
142
- };
143
- }
272
+ const command = isPython
273
+ ? await getManagedPython()
274
+ : { executable: process.execPath, args: [] };
144
275
  const script = path.join(tempDir, isPython ? 'run.py' : 'run.js');
145
276
  const source = isPython
146
277
  ? pythonWrapper(request.code, outputDir)
147
278
  : `process.env.LARKUP_OUTPUT_DIR = ${JSON.stringify(outputDir)};\n${request.code}`;
148
279
  await fs.writeFile(script, source, 'utf8');
149
- const result = await run(command, [script], inputDir, request.timeout ?? 30_000);
280
+ const result = await run(command, [script], inputDir, request.timeout ?? 30_000, isPython ? pythonEnvironment(localRuntimeDirectory()) : process.env);
150
281
  const artifacts = [];
151
282
  for (const name of await fs.readdir(outputDir).catch(() => [])) {
152
283
  const filePath = path.join(outputDir, name);
@@ -7,7 +7,7 @@
7
7
  * lifecycle.
8
8
  */
9
9
  import { checkDockerHealth, buildSandboxImage, ensureImage, executeInDocker, } from './docker-runner.js';
10
- import { checkLocalRuntime, executeLocally } from './local-runner.js';
10
+ import { checkLocalRuntime, executeLocally, setupLocalRuntime } from './local-runner.js';
11
11
  import { getSandboxProviderAdapter } from './providers/index.js';
12
12
  const defaultDockerConfig = {
13
13
  imageName: 'larkup-sandbox:latest',
@@ -45,6 +45,9 @@ export class SandboxManager {
45
45
  async setup(onProgress) {
46
46
  switch (this.config.backend) {
47
47
  case 'local':
48
+ onProgress?.('Preparing Larkup local analysis environment…');
49
+ await setupLocalRuntime();
50
+ onProgress?.('Local analysis environment is ready ✓');
48
51
  return;
49
52
  case 'docker':
50
53
  await buildSandboxImage(onProgress);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@larkup/sandbox",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -40,8 +40,8 @@
40
40
  "optionalDependencies": {
41
41
  "e2b": "^2.39.0",
42
42
  "@vercel/sandbox": "^3.0.0",
43
- "modal": "^0.9.0",
44
- "@daytona/sdk": "^0.204.1",
43
+ "modal": "^0.10.0",
44
+ "@daytona/sdk": "^0.207.0",
45
45
  "@browserbasehq/sdk": "^2.17.0",
46
46
  "playwright-core": "^1.62.1",
47
47
  "@fly/sprites": "^0.2.0",
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ export {
11
11
  ensureImage,
12
12
  executeInDocker,
13
13
  } from './docker-runner.js';
14
- export { checkLocalRuntime, executeLocally } from './local-runner.js';
14
+ export { checkLocalRuntime, executeLocally, setupLocalRuntime } from './local-runner.js';
15
15
  export {
16
16
  SANDBOX_PROVIDERS,
17
17
  SANDBOX_PROVIDER_LIST,
@@ -0,0 +1,96 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ const childProcess = vi.hoisted(() => ({ spawn: vi.fn() }));
8
+
9
+ vi.mock('node:child_process', () => childProcess);
10
+
11
+ import { executeLocally, setupLocalRuntime } from './local-runner.js';
12
+
13
+ type SpawnCall = {
14
+ executable: string;
15
+ args: string[];
16
+ options?: { env?: NodeJS.ProcessEnv };
17
+ };
18
+
19
+ const originalRuntimeDirectory = process.env.LARKUP_SANDBOX_RUNTIME_DIR;
20
+ let runtimeDirectory: string | undefined;
21
+
22
+ function successfulChild(stdout = '', stderr = '', exitCode = 0) {
23
+ const child = new EventEmitter() as EventEmitter & {
24
+ stdout: EventEmitter;
25
+ stderr: EventEmitter;
26
+ kill: () => void;
27
+ };
28
+ child.stdout = new EventEmitter();
29
+ child.stderr = new EventEmitter();
30
+ child.kill = () => {};
31
+ queueMicrotask(() => {
32
+ if (stdout) child.stdout.emit('data', Buffer.from(stdout));
33
+ if (stderr) child.stderr.emit('data', Buffer.from(stderr));
34
+ child.emit('close', exitCode);
35
+ });
36
+ return child;
37
+ }
38
+
39
+ afterEach(async () => {
40
+ childProcess.spawn.mockReset();
41
+ if (runtimeDirectory) await rm(runtimeDirectory, { recursive: true, force: true });
42
+ runtimeDirectory = undefined;
43
+ if (originalRuntimeDirectory === undefined) delete process.env.LARKUP_SANDBOX_RUNTIME_DIR;
44
+ else process.env.LARKUP_SANDBOX_RUNTIME_DIR = originalRuntimeDirectory;
45
+ });
46
+
47
+ describe('managed local Python runtime', () => {
48
+ it('installs analysis packages in Larkup’s virtual environment before running Python', async () => {
49
+ runtimeDirectory = await mkdtemp(path.join(os.tmpdir(), 'larkup-sandbox-runtime-'));
50
+ process.env.LARKUP_SANDBOX_RUNTIME_DIR = runtimeDirectory;
51
+ const calls: SpawnCall[] = [];
52
+ let dependencyChecks = 0;
53
+
54
+ childProcess.spawn.mockImplementation(
55
+ (executable: string, args: string[], options?: SpawnCall['options']) => {
56
+ calls.push({ executable, args, options });
57
+ if (args[0] === '-c' && args[1]?.includes('import importlib.util')) {
58
+ dependencyChecks += 1;
59
+ return successfulChild(
60
+ dependencyChecks === 1 ? 'seaborn\n' : '',
61
+ '',
62
+ dependencyChecks === 1 ? 1 : 0,
63
+ );
64
+ }
65
+ if (args[0] === '-m' && args[1] === 'pip') return successfulChild();
66
+ if (args.length === 1 && args[0]?.endsWith('run.py'))
67
+ return successfulChild('analysis complete\n');
68
+ return successfulChild('Python 3.12.0\n');
69
+ },
70
+ );
71
+
72
+ await setupLocalRuntime();
73
+ const result = await executeLocally({ language: 'python', code: "print('analysis complete')" });
74
+
75
+ const pipCall = calls.find((call) => call.args[0] === '-m' && call.args[1] === 'pip');
76
+ expect(pipCall?.executable).toBe(
77
+ path.join(
78
+ runtimeDirectory,
79
+ process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python',
80
+ ),
81
+ );
82
+ expect(pipCall?.args).toEqual(
83
+ expect.arrayContaining([
84
+ 'numpy==1.26.4',
85
+ 'pandas==2.2.3',
86
+ 'openpyxl==3.1.5',
87
+ 'pypdf==5.1.0',
88
+ 'seaborn==0.13.2',
89
+ ]),
90
+ );
91
+ const executionCall = calls.find((call) => call.args[0]?.endsWith('run.py'));
92
+ expect(executionCall?.executable).toBe(pipCall?.executable);
93
+ expect(executionCall?.options?.env?.PYTHONNOUSERSITE).toBe('1');
94
+ expect(result).toMatchObject({ exitCode: 0, stdout: 'analysis complete\n' });
95
+ });
96
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { executeLocally } from './local-runner.js';
2
+ import { checkLocalRuntime, executeLocally } from './local-runner.js';
3
3
 
4
4
  describe('executeLocally', () => {
5
5
  it('runs JavaScript without Docker and returns output artifacts', async () => {
@@ -19,4 +19,14 @@ describe('executeLocally', () => {
19
19
  expect.objectContaining({ name: 'result.txt', mimeType: 'text/plain' }),
20
20
  ]);
21
21
  });
22
+
23
+ it('accepts a Python installation that can bootstrap the managed analysis environment', async () => {
24
+ const health = await checkLocalRuntime();
25
+ expect(health.backend).toBe('local');
26
+ if (health.status === 'ready') {
27
+ expect(health.error).toBeUndefined();
28
+ } else {
29
+ expect(health.error).toMatch(/Python|virtual-environment/i);
30
+ }
31
+ });
22
32
  });
@@ -15,12 +15,68 @@ import { getMimeType } from './mime.js';
15
15
  type Command = { executable: string; args: string[] };
16
16
 
17
17
  let pythonCommand: Command | null | undefined;
18
+ let managedPythonCommand: Promise<Command> | undefined;
18
19
 
19
- function run(command: Command, args: string[], cwd?: string, timeoutMs = 10_000) {
20
+ // Keep this list in sync with the capabilities advertised by executeAnalysis
21
+ // and analyzeCorpusWithCode. These packages are installed in Larkup's managed
22
+ // virtual environment, never into the Python installation on the host.
23
+ const ANALYSIS_PYTHON_MODULES = [
24
+ 'numpy',
25
+ 'pandas',
26
+ 'matplotlib',
27
+ 'scipy',
28
+ 'sklearn',
29
+ 'seaborn',
30
+ 'openpyxl',
31
+ 'xlrd',
32
+ 'pypdf',
33
+ 'docx',
34
+ 'pptx',
35
+ 'PIL',
36
+ ];
37
+ const ANALYSIS_PYTHON_PACKAGES = [
38
+ 'pandas==2.2.3',
39
+ // Python 3.9 ships with several supported desktop environments. Keep the
40
+ // local pins compatible with it rather than requiring a user-managed upgrade.
41
+ 'numpy==1.26.4',
42
+ 'matplotlib==3.9.4',
43
+ 'openpyxl==3.1.5',
44
+ 'scipy==1.13.1',
45
+ 'scikit-learn==1.6.1',
46
+ 'seaborn==0.13.2',
47
+ 'xlrd==2.0.1',
48
+ 'pypdf==5.1.0',
49
+ 'python-docx==1.1.2',
50
+ 'python-pptx==1.0.2',
51
+ 'Pillow==11.1.0',
52
+ ];
53
+
54
+ function pythonEnvironment(virtualEnv?: string): NodeJS.ProcessEnv {
55
+ const env = { ...process.env };
56
+ // Let neither a user's active virtual environment nor PYTHONPATH leak into
57
+ // the managed runtime. This preserves the repeatable dependency set.
58
+ delete env.PYTHONHOME;
59
+ delete env.PYTHONPATH;
60
+ delete env.VIRTUAL_ENV;
61
+ if (virtualEnv) {
62
+ env.VIRTUAL_ENV = virtualEnv;
63
+ env.PYTHONNOUSERSITE = '1';
64
+ env.PATH = `${path.dirname(virtualEnvPython(virtualEnv))}${path.delimiter}${env.PATH ?? ''}`;
65
+ }
66
+ return env;
67
+ }
68
+
69
+ function run(
70
+ command: Command,
71
+ args: string[],
72
+ cwd?: string,
73
+ timeoutMs = 10_000,
74
+ env: NodeJS.ProcessEnv = process.env,
75
+ ) {
20
76
  return new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => {
21
77
  const child = spawn(command.executable, [...command.args, ...args], {
22
78
  cwd,
23
- env: process.env,
79
+ env,
24
80
  shell: false,
25
81
  stdio: ['ignore', 'pipe', 'pipe'],
26
82
  });
@@ -49,7 +105,7 @@ function run(command: Command, args: string[], cwd?: string, timeoutMs = 10_000)
49
105
  resolve({
50
106
  stdout,
51
107
  stderr: timedOut ? `${stderr}\nExecution timed out.`.trim() : stderr,
52
- exitCode: timedOut ? 124 : code ?? 1,
108
+ exitCode: timedOut ? 124 : (code ?? 1),
53
109
  });
54
110
  });
55
111
  });
@@ -60,18 +116,20 @@ async function resolvePython(): Promise<Command | null> {
60
116
  const candidates: Command[] = process.env.LARKUP_PYTHON
61
117
  ? [{ executable: process.env.LARKUP_PYTHON, args: [] }]
62
118
  : process.platform === 'win32'
63
- ? [
64
- { executable: 'py', args: ['-3'] },
65
- { executable: 'python', args: [] },
66
- ]
67
- : [
68
- { executable: 'python3', args: [] },
69
- { executable: 'python', args: [] },
70
- ];
119
+ ? [
120
+ { executable: 'py', args: ['-3'] },
121
+ { executable: 'python', args: [] },
122
+ ]
123
+ : [
124
+ { executable: 'python3', args: [] },
125
+ { executable: 'python', args: [] },
126
+ ];
71
127
 
72
128
  for (const candidate of candidates) {
73
129
  try {
74
- if ((await run(candidate, ['--version'])).exitCode === 0) {
130
+ if (
131
+ (await run(candidate, ['--version'], undefined, 10_000, pythonEnvironment())).exitCode === 0
132
+ ) {
75
133
  pythonCommand = candidate;
76
134
  return candidate;
77
135
  }
@@ -83,6 +141,138 @@ async function resolvePython(): Promise<Command | null> {
83
141
  return null;
84
142
  }
85
143
 
144
+ function localRuntimeDirectory(): string {
145
+ const configured = process.env.LARKUP_SANDBOX_RUNTIME_DIR?.trim();
146
+ if (configured) return path.resolve(configured);
147
+
148
+ const dataRoot = process.env.LARKUP_DATA_DIR?.trim();
149
+ return path.join(
150
+ dataRoot ? path.resolve(dataRoot) : path.join(process.cwd(), '.larkup'),
151
+ 'sandbox',
152
+ 'python',
153
+ );
154
+ }
155
+
156
+ function virtualEnvPython(runtimeDirectory: string): string {
157
+ return process.platform === 'win32'
158
+ ? path.join(runtimeDirectory, 'Scripts', 'python.exe')
159
+ : path.join(runtimeDirectory, 'bin', 'python');
160
+ }
161
+
162
+ function dependencyCheckCode(): string {
163
+ return `import importlib.util\nmodules = ${JSON.stringify(
164
+ ANALYSIS_PYTHON_MODULES,
165
+ )}\nmissing = [name for name in modules if importlib.util.find_spec(name) is None]\nprint(','.join(missing))\nraise SystemExit(bool(missing))`;
166
+ }
167
+
168
+ async function hasAnalysisDependencies(
169
+ command: Command,
170
+ runtimeDirectory: string,
171
+ ): Promise<boolean> {
172
+ try {
173
+ return (
174
+ (
175
+ await run(
176
+ command,
177
+ ['-c', dependencyCheckCode()],
178
+ undefined,
179
+ 30_000,
180
+ pythonEnvironment(runtimeDirectory),
181
+ )
182
+ ).exitCode === 0
183
+ );
184
+ } catch {
185
+ return false;
186
+ }
187
+ }
188
+
189
+ async function prepareManagedPythonRuntime(): Promise<Command> {
190
+ const bootstrapPython = await resolvePython();
191
+ if (!bootstrapPython) {
192
+ throw new Error(
193
+ 'Local code execution needs Python 3.9 or later, or choose Docker or a remote sandbox provider.',
194
+ );
195
+ }
196
+
197
+ const runtimeDirectory = localRuntimeDirectory();
198
+ const managedPython: Command = { executable: virtualEnvPython(runtimeDirectory), args: [] };
199
+ const bootstrapCheck = await run(
200
+ bootstrapPython,
201
+ ['-c', 'import sys, venv; raise SystemExit(sys.version_info < (3, 9))'],
202
+ undefined,
203
+ 10_000,
204
+ pythonEnvironment(),
205
+ );
206
+ if (bootstrapCheck.exitCode !== 0) {
207
+ throw new Error(
208
+ 'Local code execution needs Python 3.9 or later with virtual-environment support. Install Python or choose Docker or a remote sandbox provider.',
209
+ );
210
+ }
211
+
212
+ const runtimeExists = await fs.access(managedPython.executable).then(
213
+ () => true,
214
+ () => false,
215
+ );
216
+ if (!runtimeExists) {
217
+ await fs.mkdir(path.dirname(runtimeDirectory), { recursive: true });
218
+ const created = await run(
219
+ bootstrapPython,
220
+ ['-m', 'venv', runtimeDirectory],
221
+ undefined,
222
+ 60_000,
223
+ pythonEnvironment(),
224
+ );
225
+ if (created.exitCode !== 0) {
226
+ throw new Error(
227
+ 'Larkup could not create its local Python environment. Check that Python virtual environments are enabled, or choose Docker or a remote sandbox provider.',
228
+ );
229
+ }
230
+ }
231
+
232
+ if (await hasAnalysisDependencies(managedPython, runtimeDirectory)) return managedPython;
233
+
234
+ const installed = await run(
235
+ managedPython,
236
+ [
237
+ '-m',
238
+ 'pip',
239
+ 'install',
240
+ '--disable-pip-version-check',
241
+ '--no-input',
242
+ ...ANALYSIS_PYTHON_PACKAGES,
243
+ ],
244
+ undefined,
245
+ 10 * 60_000,
246
+ pythonEnvironment(runtimeDirectory),
247
+ );
248
+ if (
249
+ installed.exitCode !== 0 ||
250
+ !(await hasAnalysisDependencies(managedPython, runtimeDirectory))
251
+ ) {
252
+ throw new Error(
253
+ 'Larkup could not prepare its local analysis environment. Check the internet connection and retry, or choose Docker or a remote sandbox provider.',
254
+ );
255
+ }
256
+ return managedPython;
257
+ }
258
+
259
+ /** Creates or repairs Larkup's isolated local Python analysis environment. */
260
+ export async function setupLocalRuntime(): Promise<void> {
261
+ if (!managedPythonCommand) {
262
+ managedPythonCommand = prepareManagedPythonRuntime().catch((error) => {
263
+ managedPythonCommand = undefined;
264
+ throw error;
265
+ });
266
+ }
267
+ await managedPythonCommand;
268
+ }
269
+
270
+ async function getManagedPython(): Promise<Command> {
271
+ await setupLocalRuntime();
272
+ // setupLocalRuntime only resolves after prepareManagedPythonRuntime resolves.
273
+ return managedPythonCommand!;
274
+ }
275
+
86
276
  function assertSafeFileName(name: string) {
87
277
  if (!name || name === '.' || name === '..' || /[\\/\0]/.test(name)) {
88
278
  throw new Error('Sandbox file names must be plain file names.');
@@ -123,7 +313,7 @@ except Exception as error:
123
313
  `;
124
314
  }
125
315
 
126
- /** Checks whether the host can run Python analysis without Docker. */
316
+ /** Checks whether the host can bootstrap Larkup's managed Python environment. */
127
317
  export async function checkLocalRuntime(): Promise<SandboxHealthCheck> {
128
318
  const python = await resolvePython();
129
319
  if (!python) {
@@ -134,6 +324,31 @@ export async function checkLocalRuntime(): Promise<SandboxHealthCheck> {
134
324
  'Local code execution needs Python 3. Install Python or choose Docker or a remote sandbox provider.',
135
325
  };
136
326
  }
327
+
328
+ try {
329
+ const bootstrapCheck = await run(
330
+ python,
331
+ ['-c', 'import sys, venv; raise SystemExit(sys.version_info < (3, 9))'],
332
+ undefined,
333
+ 10_000,
334
+ pythonEnvironment(),
335
+ );
336
+ if (bootstrapCheck.exitCode !== 0) {
337
+ return {
338
+ status: 'error',
339
+ backend: 'local',
340
+ error:
341
+ 'Local code execution needs Python 3.9 or later with virtual-environment support. Install Python or choose Docker or a remote sandbox provider.',
342
+ };
343
+ }
344
+ } catch {
345
+ return {
346
+ status: 'error',
347
+ backend: 'local',
348
+ error:
349
+ 'Larkup could not verify Python virtual-environment support. Choose Docker or a remote sandbox provider.',
350
+ };
351
+ }
137
352
  return { status: 'ready', backend: 'local' };
138
353
  }
139
354
 
@@ -155,24 +370,22 @@ export async function executeLocally(request: ExecutionRequest): Promise<Executi
155
370
  }
156
371
 
157
372
  const isPython = request.language === 'python';
158
- const command = isPython ? await resolvePython() : { executable: process.execPath, args: [] };
159
- if (!command) {
160
- return {
161
- stdout: '',
162
- stderr:
163
- 'Local code execution needs Python 3. Install Python or choose Docker or a remote sandbox provider.',
164
- exitCode: 1,
165
- artifacts: [],
166
- executionTimeMs: Date.now() - startTime,
167
- };
168
- }
373
+ const command = isPython
374
+ ? await getManagedPython()
375
+ : { executable: process.execPath, args: [] };
169
376
 
170
377
  const script = path.join(tempDir, isPython ? 'run.py' : 'run.js');
171
378
  const source = isPython
172
379
  ? pythonWrapper(request.code, outputDir)
173
380
  : `process.env.LARKUP_OUTPUT_DIR = ${JSON.stringify(outputDir)};\n${request.code}`;
174
381
  await fs.writeFile(script, source, 'utf8');
175
- const result = await run(command, [script], inputDir, request.timeout ?? 30_000);
382
+ const result = await run(
383
+ command,
384
+ [script],
385
+ inputDir,
386
+ request.timeout ?? 30_000,
387
+ isPython ? pythonEnvironment(localRuntimeDirectory()) : process.env,
388
+ );
176
389
  const artifacts: ExecutionArtifact[] = [];
177
390
  for (const name of await fs.readdir(outputDir).catch(() => [])) {
178
391
  const filePath = path.join(outputDir, name);
@@ -8,6 +8,7 @@ const dockerRunner = vi.hoisted(() => ({
8
8
  const localRunner = vi.hoisted(() => ({
9
9
  checkLocalRuntime: vi.fn(),
10
10
  executeLocally: vi.fn(),
11
+ setupLocalRuntime: vi.fn(),
11
12
  }));
12
13
 
13
14
  vi.mock('./docker-runner.js', () => ({
@@ -23,6 +24,7 @@ vi.mock('./providers/index.js', () => ({
23
24
  vi.mock('./local-runner.js', () => ({
24
25
  checkLocalRuntime: localRunner.checkLocalRuntime,
25
26
  executeLocally: localRunner.executeLocally,
27
+ setupLocalRuntime: localRunner.setupLocalRuntime,
26
28
  }));
27
29
 
28
30
  import { SandboxManager } from './sandbox-manager.js';
@@ -71,4 +73,14 @@ describe('SandboxManager', () => {
71
73
  expect(localRunner.executeLocally).toHaveBeenCalledOnce();
72
74
  expect(dockerRunner.checkDockerHealth).not.toHaveBeenCalled();
73
75
  });
76
+
77
+ it('provisions the managed local runtime when setup is requested', async () => {
78
+ const progress = vi.fn();
79
+
80
+ await new SandboxManager({ backend: 'local' }).setup(progress);
81
+
82
+ expect(localRunner.setupLocalRuntime).toHaveBeenCalledOnce();
83
+ expect(progress).toHaveBeenNthCalledWith(1, 'Preparing Larkup local analysis environment…');
84
+ expect(progress).toHaveBeenLastCalledWith('Local analysis environment is ready ✓');
85
+ });
74
86
  });
@@ -20,7 +20,7 @@ import {
20
20
  ensureImage,
21
21
  executeInDocker,
22
22
  } from './docker-runner.js';
23
- import { checkLocalRuntime, executeLocally } from './local-runner.js';
23
+ import { checkLocalRuntime, executeLocally, setupLocalRuntime } from './local-runner.js';
24
24
  import { getSandboxProviderAdapter } from './providers/index.js';
25
25
 
26
26
  const defaultDockerConfig: DockerConfig = {
@@ -62,6 +62,9 @@ export class SandboxManager {
62
62
  async setup(onProgress?: (msg: string) => void): Promise<void> {
63
63
  switch (this.config.backend) {
64
64
  case 'local':
65
+ onProgress?.('Preparing Larkup local analysis environment…');
66
+ await setupLocalRuntime();
67
+ onProgress?.('Local analysis environment is ready ✓');
65
68
  return;
66
69
  case 'docker':
67
70
  await buildSandboxImage(onProgress);