@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +18 -17
- package/.turbo/turbo-type-check.log +1 -1
- package/CHANGELOG.md +12 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/local-runner.d.ts +3 -1
- package/dist/local-runner.js +147 -16
- package/dist/sandbox-manager.js +4 -1
- package/package.json +3 -3
- package/src/index.ts +1 -1
- package/src/local-runner-provisioning.test.ts +96 -0
- package/src/local-runner.test.ts +11 -1
- package/src/local-runner.ts +238 -25
- package/src/sandbox-manager.test.ts +12 -0
- package/src/sandbox-manager.ts +4 -1
package/.turbo/turbo-build.log
CHANGED
package/.turbo/turbo-test.log
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
|
|
2
|
-
> @larkup/sandbox@0.1.
|
|
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/
|
|
9
|
-
✓ src/
|
|
10
|
-
✓ src/
|
|
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/
|
|
13
|
-
✓ src/providers/
|
|
14
|
-
✓ src/
|
|
15
|
-
✓ src/providers/
|
|
16
|
-
✓ src/
|
|
17
|
-
✓ src/
|
|
18
|
-
✓ src/providers/
|
|
19
|
-
✓ src/
|
|
20
|
-
✓ src/providers/
|
|
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
|
|
23
|
-
Tests
|
|
24
|
-
Start at
|
|
25
|
-
Duration
|
|
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
|
|
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';
|
package/dist/local-runner.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ExecutionRequest, ExecutionResult, SandboxHealthCheck } from './types.js';
|
|
2
|
-
/**
|
|
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>;
|
package/dist/local-runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
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);
|
package/dist/sandbox-manager.js
CHANGED
|
@@ -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.
|
|
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.
|
|
44
|
-
"@daytona/sdk": "^0.
|
|
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
|
+
});
|
package/src/local-runner.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/local-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 (
|
|
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
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
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(
|
|
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
|
});
|
package/src/sandbox-manager.ts
CHANGED
|
@@ -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);
|