@larkup/sandbox 0.1.5 → 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.5 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,18 +1,27 @@
1
1
 
2
- > @larkup/sandbox@0.1.5 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
-  RUN  v4.1.11 /Users/abdelrahmanabounida/Desktop/my-ai-startup/larkup/larkup/packages/sandbox
6
+ RUN v4.1.11 /Users/abdelrahmanabounida/Desktop/my-ai-startup/larkup/larkup/packages/sandbox
7
+
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
12
+ ✓ src/providers/browserbase.test.ts (5 tests) 4ms
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
22
+
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)
7
27
 
8
- ✓ src/remote-exec.test.ts (14 tests) 116ms
9
- ✓ src/providers/flyio.test.ts (6 tests) 9ms
10
- ✓ src/providers/modal.test.ts (4 tests) 10ms
11
- ✓ src/providers/daytona.test.ts (5 tests) 4ms
12
- ✓ src/providers/vercel.test.ts (6 tests) 36ms
13
- ✓ src/providers/e2b.test.ts (9 tests) 45ms
14
- ✓ src/providers/northflank.test.ts (7 tests) 39ms
15
- ✓ src/local-runner.test.ts (2 tests) 505ms
16
- ✓ runs JavaScript without Docker and returns output artifacts  359ms
17
- ✓ src/providers/cloudflare.test.ts (6 tests) 14ms
18
-  ELIFECYCLE  Test failed. See above for more details.
@@ -1,4 +1,4 @@
1
1
 
2
- > @larkup/sandbox@0.1.5 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,11 @@
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
+
3
9
  ## 0.1.5
4
10
 
5
11
  ### 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,15 +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
+ let managedPythonCommand;
8
9
  // Keep this list in sync with the capabilities advertised by executeAnalysis
9
- // and analyzeCorpusWithCode. A bare Python installation is not an analysis
10
- // runtime, so it must not make those tools appear available.
11
- const ANALYSIS_PYTHON_MODULES = ['numpy', 'pandas', 'matplotlib', 'scipy', 'sklearn', 'seaborn'];
12
- function run(command, args, cwd, timeoutMs = 10_000) {
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) {
13
57
  return new Promise((resolve, reject) => {
14
58
  const child = spawn(command.executable, [...command.args, ...args], {
15
59
  cwd,
16
- env: process.env,
60
+ env,
17
61
  shell: false,
18
62
  stdio: ['ignore', 'pipe', 'pipe'],
19
63
  });
@@ -63,7 +107,7 @@ async function resolvePython() {
63
107
  ];
64
108
  for (const candidate of candidates) {
65
109
  try {
66
- if ((await run(candidate, ['--version'])).exitCode === 0) {
110
+ if ((await run(candidate, ['--version'], undefined, 10_000, pythonEnvironment())).exitCode === 0) {
67
111
  pythonCommand = candidate;
68
112
  return candidate;
69
113
  }
@@ -75,6 +119,79 @@ async function resolvePython() {
75
119
  pythonCommand = null;
76
120
  return null;
77
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
+ }
78
195
  function assertSafeFileName(name) {
79
196
  if (!name || name === '.' || name === '..' || /[\\/\0]/.test(name)) {
80
197
  throw new Error('Sandbox file names must be plain file names.');
@@ -109,7 +226,7 @@ except Exception as error:
109
226
  sys.exit(1)
110
227
  `;
111
228
  }
112
- /** Checks whether the host can run Python analysis without Docker. */
229
+ /** Checks whether the host can bootstrap Larkup's managed Python environment. */
113
230
  export async function checkLocalRuntime() {
114
231
  const python = await resolvePython();
115
232
  if (!python) {
@@ -120,17 +237,12 @@ export async function checkLocalRuntime() {
120
237
  };
121
238
  }
122
239
  try {
123
- const dependencyCheck = await run(python, [
124
- '-c',
125
- `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))`,
126
- ]);
127
- if (dependencyCheck.exitCode !== 0) {
128
- const missing = dependencyCheck.stdout.trim() || 'required analysis packages';
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) {
129
242
  return {
130
243
  status: 'error',
131
244
  backend: 'local',
132
- error: `Local Python is missing ${missing}. Install the Larkup analysis dependencies ` +
133
- `(for example: pip install ${ANALYSIS_PYTHON_MODULES.join(' ')}) or choose Docker or a remote sandbox provider.`,
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.',
134
246
  };
135
247
  }
136
248
  }
@@ -138,7 +250,7 @@ export async function checkLocalRuntime() {
138
250
  return {
139
251
  status: 'error',
140
252
  backend: 'local',
141
- error: 'Larkup could not verify the Python analysis dependencies. Choose Docker or a remote sandbox provider.',
253
+ error: 'Larkup could not verify Python virtual-environment support. Choose Docker or a remote sandbox provider.',
142
254
  };
143
255
  }
144
256
  return { status: 'ready', backend: 'local' };
@@ -157,22 +269,15 @@ export async function executeLocally(request) {
157
269
  await fs.writeFile(path.join(inputDir, file.name), file.isBase64 ? Buffer.from(file.content, 'base64') : file.content);
158
270
  }
159
271
  const isPython = request.language === 'python';
160
- const command = isPython ? await resolvePython() : { executable: process.execPath, args: [] };
161
- if (!command) {
162
- return {
163
- stdout: '',
164
- stderr: 'Local code execution needs Python 3. Install Python or choose Docker or a remote sandbox provider.',
165
- exitCode: 1,
166
- artifacts: [],
167
- executionTimeMs: Date.now() - startTime,
168
- };
169
- }
272
+ const command = isPython
273
+ ? await getManagedPython()
274
+ : { executable: process.execPath, args: [] };
170
275
  const script = path.join(tempDir, isPython ? 'run.py' : 'run.js');
171
276
  const source = isPython
172
277
  ? pythonWrapper(request.code, outputDir)
173
278
  : `process.env.LARKUP_OUTPUT_DIR = ${JSON.stringify(outputDir)};\n${request.code}`;
174
279
  await fs.writeFile(script, source, 'utf8');
175
- 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);
176
281
  const artifacts = [];
177
282
  for (const name of await fs.readdir(outputDir).catch(() => [])) {
178
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.5",
3
+ "version": "0.1.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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
+ });
@@ -20,13 +20,13 @@ describe('executeLocally', () => {
20
20
  ]);
21
21
  });
22
22
 
23
- it('does not mark a bare Python installation as analysis-ready', async () => {
23
+ it('accepts a Python installation that can bootstrap the managed analysis environment', async () => {
24
24
  const health = await checkLocalRuntime();
25
25
  expect(health.backend).toBe('local');
26
26
  if (health.status === 'ready') {
27
27
  expect(health.error).toBeUndefined();
28
28
  } else {
29
- expect(health.error).toMatch(/Python|analysis dependenc/i);
29
+ expect(health.error).toMatch(/Python|virtual-environment/i);
30
30
  }
31
31
  });
32
32
  });
@@ -15,17 +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
20
  // Keep this list in sync with the capabilities advertised by executeAnalysis
20
- // and analyzeCorpusWithCode. A bare Python installation is not an analysis
21
- // runtime, so it must not make those tools appear available.
22
- const ANALYSIS_PYTHON_MODULES = ['numpy', 'pandas', 'matplotlib', 'scipy', 'sklearn', 'seaborn'];
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
+ ];
23
53
 
24
- function run(command: Command, args: string[], cwd?: string, timeoutMs = 10_000) {
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
+ ) {
25
76
  return new Promise<{ stdout: string; stderr: string; exitCode: number }>((resolve, reject) => {
26
77
  const child = spawn(command.executable, [...command.args, ...args], {
27
78
  cwd,
28
- env: process.env,
79
+ env,
29
80
  shell: false,
30
81
  stdio: ['ignore', 'pipe', 'pipe'],
31
82
  });
@@ -76,7 +127,9 @@ async function resolvePython(): Promise<Command | null> {
76
127
 
77
128
  for (const candidate of candidates) {
78
129
  try {
79
- if ((await run(candidate, ['--version'])).exitCode === 0) {
130
+ if (
131
+ (await run(candidate, ['--version'], undefined, 10_000, pythonEnvironment())).exitCode === 0
132
+ ) {
80
133
  pythonCommand = candidate;
81
134
  return candidate;
82
135
  }
@@ -88,6 +141,138 @@ async function resolvePython(): Promise<Command | null> {
88
141
  return null;
89
142
  }
90
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
+
91
276
  function assertSafeFileName(name: string) {
92
277
  if (!name || name === '.' || name === '..' || /[\\/\0]/.test(name)) {
93
278
  throw new Error('Sandbox file names must be plain file names.');
@@ -128,7 +313,7 @@ except Exception as error:
128
313
  `;
129
314
  }
130
315
 
131
- /** Checks whether the host can run Python analysis without Docker. */
316
+ /** Checks whether the host can bootstrap Larkup's managed Python environment. */
132
317
  export async function checkLocalRuntime(): Promise<SandboxHealthCheck> {
133
318
  const python = await resolvePython();
134
319
  if (!python) {
@@ -141,20 +326,19 @@ export async function checkLocalRuntime(): Promise<SandboxHealthCheck> {
141
326
  }
142
327
 
143
328
  try {
144
- const dependencyCheck = await run(python, [
145
- '-c',
146
- `import importlib.util\nmodules = ${JSON.stringify(
147
- ANALYSIS_PYTHON_MODULES,
148
- )}\nmissing = [name for name in modules if importlib.util.find_spec(name) is None]\nprint(','.join(missing))\nraise SystemExit(bool(missing))`,
149
- ]);
150
- if (dependencyCheck.exitCode !== 0) {
151
- const missing = dependencyCheck.stdout.trim() || 'required analysis packages';
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) {
152
337
  return {
153
338
  status: 'error',
154
339
  backend: 'local',
155
340
  error:
156
- `Local Python is missing ${missing}. Install the Larkup analysis dependencies ` +
157
- `(for example: pip install ${ANALYSIS_PYTHON_MODULES.join(' ')}) or choose Docker or a remote sandbox provider.`,
341
+ 'Local code execution needs Python 3.9 or later with virtual-environment support. Install Python or choose Docker or a remote sandbox provider.',
158
342
  };
159
343
  }
160
344
  } catch {
@@ -162,7 +346,7 @@ export async function checkLocalRuntime(): Promise<SandboxHealthCheck> {
162
346
  status: 'error',
163
347
  backend: 'local',
164
348
  error:
165
- 'Larkup could not verify the Python analysis dependencies. Choose Docker or a remote sandbox provider.',
349
+ 'Larkup could not verify Python virtual-environment support. Choose Docker or a remote sandbox provider.',
166
350
  };
167
351
  }
168
352
  return { status: 'ready', backend: 'local' };
@@ -186,24 +370,22 @@ export async function executeLocally(request: ExecutionRequest): Promise<Executi
186
370
  }
187
371
 
188
372
  const isPython = request.language === 'python';
189
- const command = isPython ? await resolvePython() : { executable: process.execPath, args: [] };
190
- if (!command) {
191
- return {
192
- stdout: '',
193
- stderr:
194
- 'Local code execution needs Python 3. Install Python or choose Docker or a remote sandbox provider.',
195
- exitCode: 1,
196
- artifacts: [],
197
- executionTimeMs: Date.now() - startTime,
198
- };
199
- }
373
+ const command = isPython
374
+ ? await getManagedPython()
375
+ : { executable: process.execPath, args: [] };
200
376
 
201
377
  const script = path.join(tempDir, isPython ? 'run.py' : 'run.js');
202
378
  const source = isPython
203
379
  ? pythonWrapper(request.code, outputDir)
204
380
  : `process.env.LARKUP_OUTPUT_DIR = ${JSON.stringify(outputDir)};\n${request.code}`;
205
381
  await fs.writeFile(script, source, 'utf8');
206
- 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
+ );
207
389
  const artifacts: ExecutionArtifact[] = [];
208
390
  for (const name of await fs.readdir(outputDir).catch(() => [])) {
209
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);