labgate 0.5.40 → 0.5.42
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/README.md +132 -265
- package/dist/cli.js +9 -33
- package/dist/cli.js.map +1 -1
- package/dist/lib/config.d.ts +18 -3
- package/dist/lib/config.js +151 -80
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/container.d.ts +11 -9
- package/dist/lib/container.js +753 -302
- package/dist/lib/container.js.map +1 -1
- package/dist/lib/dataset-mcp.js +2 -9
- package/dist/lib/dataset-mcp.js.map +1 -1
- package/dist/lib/display-mcp.d.ts +2 -2
- package/dist/lib/display-mcp.js +17 -38
- package/dist/lib/display-mcp.js.map +1 -1
- package/dist/lib/doctor.js +8 -0
- package/dist/lib/doctor.js.map +1 -1
- package/dist/lib/explorer-claude.js +36 -1
- package/dist/lib/explorer-claude.js.map +1 -1
- package/dist/lib/explorer-eval.js +3 -2
- package/dist/lib/explorer-eval.js.map +1 -1
- package/dist/lib/init.js +14 -18
- package/dist/lib/init.js.map +1 -1
- package/dist/lib/slurm-cli-passthrough.d.ts +12 -2
- package/dist/lib/slurm-cli-passthrough.js +401 -143
- package/dist/lib/slurm-cli-passthrough.js.map +1 -1
- package/dist/lib/startup-stage-lock.d.ts +21 -0
- package/dist/lib/startup-stage-lock.js +196 -0
- package/dist/lib/startup-stage-lock.js.map +1 -0
- package/dist/lib/ui.d.ts +40 -0
- package/dist/lib/ui.html +4953 -3366
- package/dist/lib/ui.js +1749 -295
- package/dist/lib/ui.js.map +1 -1
- package/dist/lib/web-terminal-startup-readiness.d.ts +8 -0
- package/dist/lib/web-terminal-startup-readiness.js +29 -0
- package/dist/lib/web-terminal-startup-readiness.js.map +1 -0
- package/dist/lib/web-terminal.d.ts +51 -0
- package/dist/lib/web-terminal.js +171 -1
- package/dist/lib/web-terminal.js.map +1 -1
- package/dist/mcp-bundles/dataset-mcp.bundle.mjs +125 -74
- package/dist/mcp-bundles/display-mcp.bundle.mjs +22 -30
- package/dist/mcp-bundles/explorer-mcp.bundle.mjs +211 -106
- package/dist/mcp-bundles/results-mcp.bundle.mjs +22 -24
- package/dist/mcp-bundles/slurm-mcp.bundle.mjs +6 -8
- package/package.json +1 -1
package/dist/lib/container.js
CHANGED
|
@@ -39,6 +39,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
39
39
|
exports.computeMountFingerprint = computeMountFingerprint;
|
|
40
40
|
exports.prepareMcpServers = prepareMcpServers;
|
|
41
41
|
exports.imageToSifName = imageToSifName;
|
|
42
|
+
exports.isUsableApptainerSif = isUsableApptainerSif;
|
|
43
|
+
exports.ensureSifImage = ensureSifImage;
|
|
44
|
+
exports.resolveSlurmProxyPathToHost = resolveSlurmProxyPathToHost;
|
|
42
45
|
exports.buildCodexOauthPublishSpec = buildCodexOauthPublishSpec;
|
|
43
46
|
exports.buildEntrypoint = buildEntrypoint;
|
|
44
47
|
exports.setupBrowserHook = setupBrowserHook;
|
|
@@ -61,6 +64,7 @@ const audit_js_1 = require("./audit.js");
|
|
|
61
64
|
const slurm_db_js_1 = require("./slurm-db.js");
|
|
62
65
|
const slurm_poller_js_1 = require("./slurm-poller.js");
|
|
63
66
|
const slurm_cli_passthrough_js_1 = require("./slurm-cli-passthrough.js");
|
|
67
|
+
const startup_stage_lock_js_1 = require("./startup-stage-lock.js");
|
|
64
68
|
const log = __importStar(require("./log.js"));
|
|
65
69
|
/**
|
|
66
70
|
* Compute a deterministic fingerprint of mount-affecting config fields.
|
|
@@ -206,6 +210,8 @@ function cleanupLabgateInstructionFile(workdir, filename, deleteWhenEmpty) {
|
|
|
206
210
|
function buildLabgateInstructionBlock(session) {
|
|
207
211
|
const agentLower = session.agent.toLowerCase();
|
|
208
212
|
const hasMcp = agentLower === 'claude' || agentLower === 'codex';
|
|
213
|
+
const datasetsPluginEnabled = session.config.plugins?.datasets !== false;
|
|
214
|
+
const slurmPluginEnabled = session.config.plugins?.slurm !== false;
|
|
209
215
|
const lines = [
|
|
210
216
|
LABGATE_INSTRUCTION_START,
|
|
211
217
|
'## LabGate Sandbox Context (Auto-Managed)',
|
|
@@ -238,41 +244,20 @@ function buildLabgateInstructionBlock(session) {
|
|
|
238
244
|
}
|
|
239
245
|
if (hasMcp) {
|
|
240
246
|
lines.push('');
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
lines.push('- Available tools: `list_results`, `register_result`, `get_result`, `update_result`, `delete_result`');
|
|
249
|
-
lines.push('- Use `register_result` sparingly. Do not auto-register routine progress updates or intermediate notes.');
|
|
250
|
-
lines.push('- Register a result only when the user explicitly asks to save it, or when it is manuscript-grade evidence likely needed later (for example p-values, effect sizes, confidence intervals, benchmark deltas, or a key figure/table output path).');
|
|
251
|
-
lines.push('- If uncertain whether to register, ask the user first.');
|
|
252
|
-
lines.push('');
|
|
253
|
-
lines.push('### Display Widgets');
|
|
254
|
-
lines.push('- Use the `labgate-display` MCP tool `display_widget` to render rich interactive content in the LabGate web UI.');
|
|
255
|
-
lines.push('- Supported widgets: `markdown`, `table`, `plot`, `image`, `pdf`, `molecule`, `sequence`, `alignment`, `file_preview`, `tree`, `heatmap`, `tracks`, `domains`, `network`');
|
|
256
|
-
lines.push('- Use `molecule` with `pdb_id` to show 3D protein structures (e.g. `{ pdb_id: "2HIU" }`).');
|
|
257
|
-
lines.push('- Use `sequence` to display colored DNA/RNA/protein sequences.');
|
|
258
|
-
lines.push('- Use `plot` with a Plotly.js spec (`{ plotly: { data: [...], layout: {...} } }`) for interactive charts.');
|
|
259
|
-
lines.push('- Use `table` for structured data (`{ columns: [...], rows: [...] }`).');
|
|
260
|
-
lines.push('- Use `image` with `path` to display images from the filesystem.');
|
|
261
|
-
lines.push('- Use `pdf` with `path` to display PDF documents inline (`{ path: "/work/paper.pdf" }`).');
|
|
262
|
-
lines.push('- Use `alignment` for multiple sequence alignments with conservation highlighting.');
|
|
263
|
-
lines.push('- Use `tree` with Newick format string for phylogenetic trees (e.g. `{ newick: "((A,B),(C,D));" }`).');
|
|
264
|
-
lines.push('- Use `heatmap` for clustered heatmaps with optional dendrograms (`{ matrix: [[...]], row_labels: [...], cluster_rows: true }`).');
|
|
265
|
-
lines.push('- Use `tracks` for genome feature viewing via igv.js (`{ tracks: [{ path: "/work/file.gff3", format: "gff3" }] }`).');
|
|
266
|
-
lines.push('- Use `domains` for protein domain architecture (`{ length: 300, features: [{ type: "domain", name: "Kinase", start: 10, end: 120 }] }`).');
|
|
267
|
-
lines.push('- Use `network` for interactive graph visualization (`{ nodes: [{id:"A"}], edges: [{source:"A", target:"B"}] }`).');
|
|
268
|
-
lines.push('- Prefer `display_widget` over plain text when showing structured data, sequences, or molecular information.');
|
|
247
|
+
if (datasetsPluginEnabled) {
|
|
248
|
+
lines.push('### Dataset Management');
|
|
249
|
+
lines.push('- Use the `labgate-datasets` MCP tools to manage and explore datasets.');
|
|
250
|
+
lines.push('- Available tools: `list_datasets`, `inspect_dataset`, `search_dataset`, `get_dataset_summary`, `read_dataset_file`, `validate_dataset`, `register_dataset`, `update_dataset`, `unregister_dataset`');
|
|
251
|
+
lines.push('- Use `register_dataset` to add new host directories as named datasets.');
|
|
252
|
+
lines.push('');
|
|
253
|
+
}
|
|
269
254
|
}
|
|
270
255
|
if (session.config.slurm.enabled) {
|
|
271
256
|
lines.push('');
|
|
272
257
|
lines.push('### SLURM Integration');
|
|
273
258
|
lines.push('- SLURM commands (srun, sbatch, squeue, scancel) are available.');
|
|
274
259
|
lines.push('- LabGate tracks your SLURM jobs automatically via squeue polling.');
|
|
275
|
-
if (session.config.slurm.mcp_server && hasMcp) {
|
|
260
|
+
if (slurmPluginEnabled && session.config.slurm.mcp_server && hasMcp) {
|
|
276
261
|
lines.push('- Use the `labgate-cluster` MCP tools to inspect partitions, queue pressure, and available modules.');
|
|
277
262
|
lines.push('- Available tools: `get_cluster_partitions`, `get_partition_limits`, `get_queue_pressure`, `find_module`');
|
|
278
263
|
lines.push('- Use the `labgate-slurm` MCP tools to check job status and read output files.');
|
|
@@ -336,172 +321,320 @@ function serializeCodexMcpToml(servers) {
|
|
|
336
321
|
}
|
|
337
322
|
return lines.join('\n');
|
|
338
323
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
324
|
+
const MCP_STAGE_STAMP_FILE = '.labgate-stage-stamp';
|
|
325
|
+
const STARTUP_STAGE_LOCK_DIR = 'startup-stage.lock';
|
|
326
|
+
function getStartupStageLockPath(sandboxHome) {
|
|
327
|
+
return (0, path_1.join)(sandboxHome, '.labgate', 'locks', STARTUP_STAGE_LOCK_DIR);
|
|
328
|
+
}
|
|
329
|
+
function syncMcpBundleFile(sourcePath, targetPath) {
|
|
330
|
+
if (!(0, fs_1.existsSync)(sourcePath))
|
|
331
|
+
return false;
|
|
332
|
+
const source = (0, fs_1.readFileSync)(sourcePath);
|
|
343
333
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
334
|
+
if ((0, fs_1.existsSync)(targetPath)) {
|
|
335
|
+
const target = (0, fs_1.readFileSync)(targetPath);
|
|
336
|
+
if (source.equals(target))
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// Fall through to overwrite.
|
|
342
|
+
}
|
|
343
|
+
(0, startup_stage_lock_js_1.writeTextFileAtomic)(targetPath, source, { mode: config_js_1.PRIVATE_FILE_MODE });
|
|
344
|
+
(0, config_js_1.ensurePrivateFile)(targetPath);
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
function writePrivateFileIfChanged(path, content) {
|
|
348
|
+
try {
|
|
349
|
+
if ((0, fs_1.existsSync)(path) && (0, fs_1.readFileSync)(path, 'utf-8') === content)
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// Fall through to rewrite.
|
|
354
|
+
}
|
|
355
|
+
(0, startup_stage_lock_js_1.writeTextFileAtomic)(path, content, { mode: config_js_1.PRIVATE_FILE_MODE });
|
|
356
|
+
(0, config_js_1.ensurePrivateFile)(path);
|
|
357
|
+
}
|
|
358
|
+
function updateMcpPackageStampFromDir(hash, rootDir, relativeDir = '') {
|
|
359
|
+
const dirPath = relativeDir ? (0, path_1.join)(rootDir, relativeDir) : rootDir;
|
|
360
|
+
const entries = (0, fs_1.readdirSync)(dirPath).sort((a, b) => a.localeCompare(b));
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
const relativePath = relativeDir ? (0, path_1.join)(relativeDir, entry) : entry;
|
|
363
|
+
const normalizedRelativePath = relativePath.replace(/\\/g, '/');
|
|
364
|
+
const absPath = (0, path_1.join)(rootDir, relativePath);
|
|
365
|
+
const st = (0, fs_1.lstatSync)(absPath);
|
|
366
|
+
if (st.isDirectory()) {
|
|
367
|
+
hash.update(`dir:${normalizedRelativePath}\n`);
|
|
368
|
+
updateMcpPackageStampFromDir(hash, rootDir, relativePath);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (st.isSymbolicLink()) {
|
|
372
|
+
hash.update(`link:${normalizedRelativePath}\n`);
|
|
373
|
+
hash.update((0, fs_1.readlinkSync)(absPath));
|
|
374
|
+
hash.update('\n');
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (!st.isFile())
|
|
378
|
+
continue;
|
|
379
|
+
hash.update(`file:${normalizedRelativePath}\n`);
|
|
380
|
+
hash.update((0, fs_1.readFileSync)(absPath));
|
|
381
|
+
hash.update('\n');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function computeMcpPackageStamp(sourceDir) {
|
|
385
|
+
if (!(0, fs_1.existsSync)(sourceDir))
|
|
386
|
+
return null;
|
|
387
|
+
try {
|
|
388
|
+
const hash = (0, crypto_1.createHash)('sha256');
|
|
389
|
+
updateMcpPackageStampFromDir(hash, sourceDir);
|
|
390
|
+
return hash.digest('hex');
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function syncMcpPackageDir(sourceDir, targetDir) {
|
|
397
|
+
const stamp = computeMcpPackageStamp(sourceDir);
|
|
398
|
+
if (!stamp)
|
|
399
|
+
return false;
|
|
400
|
+
const stampPath = (0, path_1.join)(targetDir, MCP_STAGE_STAMP_FILE);
|
|
401
|
+
let targetIsSymlink = false;
|
|
402
|
+
try {
|
|
403
|
+
targetIsSymlink = (0, fs_1.lstatSync)(targetDir).isSymbolicLink();
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
targetIsSymlink = false;
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
if ((0, fs_1.existsSync)(targetDir) && (0, fs_1.existsSync)(stampPath)) {
|
|
410
|
+
const currentStamp = (0, fs_1.readFileSync)(stampPath, 'utf-8').trim();
|
|
411
|
+
if (currentStamp === stamp && targetIsSymlink)
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
// Fall through to recopy.
|
|
417
|
+
}
|
|
418
|
+
const parentDir = (0, path_1.dirname)(targetDir);
|
|
419
|
+
const packageName = (0, path_1.basename)(targetDir);
|
|
420
|
+
const storeRoot = (0, path_1.join)(parentDir, '.labgate-store');
|
|
421
|
+
const storeDir = (0, path_1.join)(storeRoot, packageName);
|
|
422
|
+
const stagedDir = (0, path_1.join)(storeDir, stamp);
|
|
423
|
+
const tempStageDir = (0, path_1.join)(storeDir, `.tmp-${process.pid}-${(0, crypto_1.randomBytes)(6).toString('hex')}`);
|
|
424
|
+
const tempLinkPath = (0, path_1.join)(parentDir, `.${packageName}.tmp-link-${(0, crypto_1.randomBytes)(6).toString('hex')}`);
|
|
425
|
+
try {
|
|
426
|
+
(0, config_js_1.ensurePrivateDir)(storeRoot);
|
|
427
|
+
(0, config_js_1.ensurePrivateDir)(storeDir);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
if (!(0, fs_1.existsSync)(stagedDir)) {
|
|
351
433
|
try {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
434
|
+
(0, fs_1.cpSync)(sourceDir, tempStageDir, { recursive: true, force: true });
|
|
435
|
+
const stagedStampPath = (0, path_1.join)(tempStageDir, MCP_STAGE_STAMP_FILE);
|
|
436
|
+
(0, fs_1.writeFileSync)(stagedStampPath, `${stamp}\n`, { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
|
|
437
|
+
(0, config_js_1.ensurePrivateFile)(stagedStampPath);
|
|
438
|
+
(0, fs_1.renameSync)(tempStageDir, stagedDir);
|
|
355
439
|
}
|
|
356
|
-
catch {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
};
|
|
363
|
-
const copyMcpNodePackage = (pkgName) => {
|
|
364
|
-
const sourceDir = (0, path_1.resolve)(__dirname, '../../node_modules', pkgName);
|
|
365
|
-
if (!(0, fs_1.existsSync)(sourceDir))
|
|
440
|
+
catch {
|
|
441
|
+
try {
|
|
442
|
+
(0, fs_1.rmSync)(tempStageDir, { recursive: true, force: true });
|
|
443
|
+
}
|
|
444
|
+
catch { /* ignore */ }
|
|
445
|
+
if (!(0, fs_1.existsSync)(stagedDir))
|
|
366
446
|
return false;
|
|
367
|
-
|
|
368
|
-
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
(0, fs_1.symlinkSync)(stagedDir, tempLinkPath, 'dir');
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
let targetExists = false;
|
|
456
|
+
try {
|
|
457
|
+
(0, fs_1.lstatSync)(targetDir);
|
|
458
|
+
targetExists = true;
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
targetExists = false;
|
|
462
|
+
}
|
|
463
|
+
if (targetExists && targetIsSymlink) {
|
|
464
|
+
try {
|
|
465
|
+
const currentTarget = (0, path_1.resolve)(parentDir, (0, fs_1.readlinkSync)(targetDir));
|
|
466
|
+
if (currentTarget === stagedDir) {
|
|
467
|
+
(0, fs_1.rmSync)(tempLinkPath, { force: true });
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
// Fall through to replace the current symlink.
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
if (!targetExists || targetIsSymlink) {
|
|
477
|
+
(0, fs_1.renameSync)(tempLinkPath, targetDir);
|
|
369
478
|
return true;
|
|
370
|
-
};
|
|
371
|
-
const stageSlurmMcpRuntimeDeps = () => {
|
|
372
|
-
(0, config_js_1.ensurePrivateDir)((0, path_1.join)(mcpDir, 'node_modules'));
|
|
373
|
-
// better-sqlite3 resolves through bindings -> file-uri-to-path at runtime.
|
|
374
|
-
return (copyMcpNodePackage('better-sqlite3') &&
|
|
375
|
-
copyMcpNodePackage('bindings') &&
|
|
376
|
-
copyMcpNodePackage('file-uri-to-path'));
|
|
377
|
-
};
|
|
378
|
-
// Dataset MCP server (always)
|
|
379
|
-
const datasetBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/dataset-mcp.bundle.mjs');
|
|
380
|
-
if ((0, fs_1.existsSync)(datasetBundleSrc)) {
|
|
381
|
-
(0, fs_1.copyFileSync)(datasetBundleSrc, (0, path_1.join)(mcpDir, 'dataset-mcp.bundle.mjs'));
|
|
382
|
-
mcpConfig.mcpServers['labgate-datasets'] = {
|
|
383
|
-
command: 'node',
|
|
384
|
-
args: ['/home/sandbox/.mcp-servers/dataset-mcp.bundle.mjs'],
|
|
385
|
-
env,
|
|
386
|
-
};
|
|
387
479
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
command: 'node',
|
|
393
|
-
args: [datasetMcpPath],
|
|
394
|
-
};
|
|
480
|
+
const legacyTargetDir = (0, path_1.join)(parentDir, `.${packageName}.legacy-${(0, crypto_1.randomBytes)(6).toString('hex')}`);
|
|
481
|
+
(0, fs_1.renameSync)(targetDir, legacyTargetDir);
|
|
482
|
+
try {
|
|
483
|
+
(0, fs_1.renameSync)(tempLinkPath, targetDir);
|
|
395
484
|
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
(0, fs_1.copyFileSync)(resultsBundleSrc, (0, path_1.join)(mcpDir, 'results-mcp.bundle.mjs'));
|
|
400
|
-
mcpConfig.mcpServers['labgate-results'] = {
|
|
401
|
-
command: 'node',
|
|
402
|
-
args: ['/home/sandbox/.mcp-servers/results-mcp.bundle.mjs', '--db', '/labgate-config/results.json'],
|
|
403
|
-
env,
|
|
404
|
-
};
|
|
485
|
+
catch (err) {
|
|
486
|
+
(0, fs_1.renameSync)(legacyTargetDir, targetDir);
|
|
487
|
+
throw err;
|
|
405
488
|
}
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
};
|
|
489
|
+
(0, fs_1.rmSync)(legacyTargetDir, { recursive: true, force: true });
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
try {
|
|
494
|
+
(0, fs_1.rmSync)(tempLinkPath, { force: true });
|
|
412
495
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
496
|
+
catch { /* ignore */ }
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function prepareMcpServers(session, options = {}) {
|
|
501
|
+
const agentLower = session.agent.toLowerCase();
|
|
502
|
+
if (agentLower !== 'claude' && agentLower !== 'codex')
|
|
503
|
+
return;
|
|
504
|
+
try {
|
|
505
|
+
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
506
|
+
(0, config_js_1.ensurePrivateDir)((0, path_1.join)(sandboxHome, '.labgate'));
|
|
507
|
+
(0, config_js_1.ensurePrivateDir)((0, path_1.join)(sandboxHome, '.labgate', 'locks'));
|
|
508
|
+
(0, startup_stage_lock_js_1.withStartupStageLock)(getStartupStageLockPath(sandboxHome), 'mcp-stage', () => {
|
|
509
|
+
const mcpDir = (0, path_1.join)(sandboxHome, '.mcp-servers');
|
|
510
|
+
(0, fs_1.mkdirSync)(mcpDir, { recursive: true });
|
|
511
|
+
// Claude Code reads MCP config from ~/.claude.json (in HOME root).
|
|
512
|
+
// For Codex we build the same mcpConfig object, then serialize to TOML below.
|
|
513
|
+
const mcpConfigPath = (0, path_1.join)(sandboxHome, '.claude.json');
|
|
514
|
+
let mcpConfig = {};
|
|
515
|
+
try {
|
|
516
|
+
if ((0, fs_1.existsSync)(mcpConfigPath)) {
|
|
517
|
+
mcpConfig = JSON.parse((0, fs_1.readFileSync)(mcpConfigPath, 'utf-8'));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
catch { /* start fresh */ }
|
|
521
|
+
if (!mcpConfig.mcpServers)
|
|
522
|
+
mcpConfig.mcpServers = {};
|
|
523
|
+
const env = {
|
|
524
|
+
LABGATE_CONFIG_PATH: '/labgate-config/config.json',
|
|
525
|
+
LABGATE_CONTAINER_MODE: '1',
|
|
421
526
|
};
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
527
|
+
const datasetsPluginEnabled = session.config.plugins?.datasets !== false;
|
|
528
|
+
const slurmPluginEnabled = session.config.plugins?.slurm !== false;
|
|
529
|
+
const copyMcpNodePackage = (pkgName) => {
|
|
530
|
+
const sourceDir = options.resolveMcpPackageDir?.(pkgName) ?? (0, path_1.resolve)(__dirname, '../../node_modules', pkgName);
|
|
531
|
+
const targetDir = (0, path_1.join)(mcpDir, 'node_modules', pkgName);
|
|
532
|
+
return syncMcpPackageDir(sourceDir, targetDir);
|
|
428
533
|
};
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
534
|
+
const stageSlurmMcpRuntimeDeps = () => {
|
|
535
|
+
(0, config_js_1.ensurePrivateDir)((0, path_1.join)(mcpDir, 'node_modules'));
|
|
536
|
+
// better-sqlite3 resolves through bindings -> file-uri-to-path at runtime.
|
|
537
|
+
return (copyMcpNodePackage('better-sqlite3') &&
|
|
538
|
+
copyMcpNodePackage('bindings') &&
|
|
539
|
+
copyMcpNodePackage('file-uri-to-path'));
|
|
540
|
+
};
|
|
541
|
+
if (datasetsPluginEnabled) {
|
|
542
|
+
const datasetBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/dataset-mcp.bundle.mjs');
|
|
543
|
+
if (syncMcpBundleFile(datasetBundleSrc, (0, path_1.join)(mcpDir, 'dataset-mcp.bundle.mjs'))) {
|
|
544
|
+
mcpConfig.mcpServers['labgate-datasets'] = {
|
|
545
|
+
command: 'node',
|
|
546
|
+
args: ['/home/sandbox/.mcp-servers/dataset-mcp.bundle.mjs'],
|
|
547
|
+
env,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
// Fallback: host path (development without bundles)
|
|
552
|
+
const datasetMcpPath = (0, path_1.resolve)(__dirname, 'dataset-mcp.js');
|
|
553
|
+
mcpConfig.mcpServers['labgate-datasets'] = {
|
|
554
|
+
command: 'node',
|
|
555
|
+
args: [datasetMcpPath],
|
|
556
|
+
};
|
|
557
|
+
}
|
|
441
558
|
}
|
|
442
559
|
else {
|
|
443
|
-
|
|
444
|
-
mcpConfig.mcpServers['labgate-cluster'] = {
|
|
445
|
-
command: 'node',
|
|
446
|
-
args: [clusterMcpPath],
|
|
447
|
-
};
|
|
560
|
+
delete mcpConfig.mcpServers['labgate-datasets'];
|
|
448
561
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if ((0, fs_1.existsSync)(slurmBundleSrc)) {
|
|
457
|
-
const staged = options.stageSlurmDeps ? options.stageSlurmDeps() : stageSlurmMcpRuntimeDeps();
|
|
458
|
-
if (staged) {
|
|
459
|
-
(0, fs_1.copyFileSync)(slurmBundleSrc, (0, path_1.join)(mcpDir, 'slurm-mcp.bundle.mjs'));
|
|
460
|
-
mcpConfig.mcpServers['labgate-slurm'] = {
|
|
562
|
+
delete mcpConfig.mcpServers['labgate-results'];
|
|
563
|
+
delete mcpConfig.mcpServers['labgate-display'];
|
|
564
|
+
// Cluster MCP server (when SLURM integration is enabled and plugin active)
|
|
565
|
+
if (session.config.slurm.enabled && session.config.slurm.mcp_server && slurmPluginEnabled) {
|
|
566
|
+
const clusterBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/cluster-mcp.bundle.mjs');
|
|
567
|
+
if (syncMcpBundleFile(clusterBundleSrc, (0, path_1.join)(mcpDir, 'cluster-mcp.bundle.mjs'))) {
|
|
568
|
+
mcpConfig.mcpServers['labgate-cluster'] = {
|
|
461
569
|
command: 'node',
|
|
462
|
-
args: ['/home/sandbox/.mcp-servers/
|
|
570
|
+
args: ['/home/sandbox/.mcp-servers/cluster-mcp.bundle.mjs'],
|
|
463
571
|
env,
|
|
464
572
|
};
|
|
465
573
|
}
|
|
466
574
|
else {
|
|
467
|
-
|
|
575
|
+
const clusterMcpPath = (0, path_1.resolve)(__dirname, 'cluster-mcp.js');
|
|
576
|
+
mcpConfig.mcpServers['labgate-cluster'] = {
|
|
577
|
+
command: 'node',
|
|
578
|
+
args: [clusterMcpPath],
|
|
579
|
+
};
|
|
468
580
|
}
|
|
469
581
|
}
|
|
470
582
|
else {
|
|
471
|
-
|
|
472
|
-
const dbPath = (0, config_js_1.getSlurmDbPath)();
|
|
473
|
-
mcpConfig.mcpServers['labgate-slurm'] = {
|
|
474
|
-
command: 'node',
|
|
475
|
-
args: [mcpServerPath, '--db', dbPath],
|
|
476
|
-
};
|
|
583
|
+
delete mcpConfig.mcpServers['labgate-cluster'];
|
|
477
584
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
585
|
+
// SLURM MCP server (when enabled and plugin active)
|
|
586
|
+
if (session.config.slurm.enabled && session.config.slurm.mcp_server && slurmPluginEnabled) {
|
|
587
|
+
const slurmBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/slurm-mcp.bundle.mjs');
|
|
588
|
+
if ((0, fs_1.existsSync)(slurmBundleSrc)) {
|
|
589
|
+
const staged = options.stageSlurmDeps ? options.stageSlurmDeps() : stageSlurmMcpRuntimeDeps();
|
|
590
|
+
if (staged) {
|
|
591
|
+
syncMcpBundleFile(slurmBundleSrc, (0, path_1.join)(mcpDir, 'slurm-mcp.bundle.mjs'));
|
|
592
|
+
mcpConfig.mcpServers['labgate-slurm'] = {
|
|
593
|
+
command: 'node',
|
|
594
|
+
args: ['/home/sandbox/.mcp-servers/slurm-mcp.bundle.mjs', '--db', '/labgate-config/slurm.db'],
|
|
595
|
+
env,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
delete mcpConfig.mcpServers['labgate-slurm'];
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
const mcpServerPath = (0, path_1.resolve)(__dirname, 'slurm-mcp.js');
|
|
604
|
+
const dbPath = (0, config_js_1.getSlurmDbPath)();
|
|
605
|
+
mcpConfig.mcpServers['labgate-slurm'] = {
|
|
606
|
+
command: 'node',
|
|
607
|
+
args: [mcpServerPath, '--db', dbPath],
|
|
608
|
+
};
|
|
495
609
|
}
|
|
496
610
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
611
|
+
else {
|
|
612
|
+
delete mcpConfig.mcpServers['labgate-slurm'];
|
|
613
|
+
}
|
|
614
|
+
if (agentLower === 'claude') {
|
|
615
|
+
const nextClaudeConfig = JSON.stringify(mcpConfig, null, 2);
|
|
616
|
+
writePrivateFileIfChanged(mcpConfigPath, nextClaudeConfig);
|
|
617
|
+
}
|
|
618
|
+
if (agentLower === 'codex') {
|
|
619
|
+
// Codex reads MCP config from ~/.codex/config.toml
|
|
620
|
+
const codexDir = (0, path_1.join)(sandboxHome, '.codex');
|
|
621
|
+
(0, fs_1.mkdirSync)(codexDir, { recursive: true });
|
|
622
|
+
const codexConfigPath = (0, path_1.join)(codexDir, 'config.toml');
|
|
623
|
+
let existingToml = '';
|
|
624
|
+
try {
|
|
625
|
+
if ((0, fs_1.existsSync)(codexConfigPath)) {
|
|
626
|
+
existingToml = (0, fs_1.readFileSync)(codexConfigPath, 'utf-8');
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
catch { /* start fresh */ }
|
|
630
|
+
// Strip old [mcp_servers.*] sections and append fresh ones
|
|
631
|
+
const cleaned = stripTomlMcpSections(existingToml);
|
|
632
|
+
const mcpToml = serializeCodexMcpToml(mcpConfig.mcpServers);
|
|
633
|
+
const separator = cleaned.length > 0 && !cleaned.endsWith('\n') ? '\n\n' : cleaned.length > 0 ? '\n' : '';
|
|
634
|
+
const nextCodexConfig = cleaned + separator + mcpToml;
|
|
635
|
+
writePrivateFileIfChanged(codexConfigPath, nextCodexConfig);
|
|
636
|
+
}
|
|
637
|
+
});
|
|
505
638
|
}
|
|
506
639
|
catch {
|
|
507
640
|
// Best effort — MCP registration failure is non-fatal
|
|
@@ -568,10 +701,31 @@ function cleanStaleSessionFiles() {
|
|
|
568
701
|
// ── SIF image management (Apptainer) ─────────────────────
|
|
569
702
|
/**
|
|
570
703
|
* Convert a container image URI to a local SIF filename.
|
|
571
|
-
*
|
|
704
|
+
* Includes a short hash suffix so distinct image refs cannot collide after sanitization.
|
|
572
705
|
*/
|
|
573
706
|
function imageToSifName(image) {
|
|
574
|
-
|
|
707
|
+
const normalized = String(image || '').trim();
|
|
708
|
+
const readable = normalized
|
|
709
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
|
710
|
+
.replace(/^_+|_+$/g, '')
|
|
711
|
+
.slice(0, 160) || 'image';
|
|
712
|
+
const hash = (0, crypto_1.createHash)('sha256').update(normalized).digest('hex').slice(0, 12);
|
|
713
|
+
return `${readable}-${hash}.sif`;
|
|
714
|
+
}
|
|
715
|
+
const APPTAINER_SIF_INSPECT_TIMEOUT_MS = 15_000;
|
|
716
|
+
function isUsableApptainerSif(runtime, sifPath) {
|
|
717
|
+
if (!(0, fs_1.existsSync)(sifPath))
|
|
718
|
+
return false;
|
|
719
|
+
try {
|
|
720
|
+
(0, child_process_1.execFileSync)(runtime, ['inspect', sifPath], {
|
|
721
|
+
stdio: 'ignore',
|
|
722
|
+
timeout: APPTAINER_SIF_INSPECT_TIMEOUT_MS,
|
|
723
|
+
});
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
575
729
|
}
|
|
576
730
|
/**
|
|
577
731
|
* Ensure a SIF image exists in the cache directory.
|
|
@@ -582,35 +736,59 @@ async function ensureSifImage(runtime, image) {
|
|
|
582
736
|
(0, fs_1.mkdirSync)(imagesDir, { recursive: true });
|
|
583
737
|
const sifPath = (0, path_1.join)(imagesDir, imageToSifName(image));
|
|
584
738
|
const pullLockPath = `${sifPath}.pull.lock`;
|
|
585
|
-
if ((
|
|
739
|
+
if (isUsableApptainerSif(runtime, sifPath) && !(0, fs_1.existsSync)(pullLockPath)) {
|
|
586
740
|
return sifPath;
|
|
587
741
|
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
process.
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
742
|
+
try {
|
|
743
|
+
await (0, image_pull_lock_js_1.withImagePullFileLock)(pullLockPath, image, async () => {
|
|
744
|
+
if (isUsableApptainerSif(runtime, sifPath))
|
|
745
|
+
return;
|
|
746
|
+
if ((0, fs_1.existsSync)(sifPath)) {
|
|
747
|
+
log.warn(`Cached SIF for ${log.dim(image)} failed validation. Re-pulling image.`);
|
|
748
|
+
try {
|
|
749
|
+
(0, fs_1.rmSync)(sifPath, { force: true });
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
// Best effort; the pull below will surface remaining problems.
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const tempSifPath = `${sifPath}.tmp-${process.pid}-${(0, crypto_1.randomBytes)(6).toString('hex')}`;
|
|
756
|
+
log.info(`Pulling image ${log.dim(image)}`);
|
|
757
|
+
try {
|
|
758
|
+
(0, child_process_1.execFileSync)(runtime, ['pull', tempSifPath, `docker://${image}`], {
|
|
759
|
+
stdio: 'inherit',
|
|
760
|
+
});
|
|
761
|
+
if (!isUsableApptainerSif(runtime, tempSifPath)) {
|
|
762
|
+
throw new Error(`Pulled SIF failed validation: ${tempSifPath}`);
|
|
763
|
+
}
|
|
764
|
+
(0, fs_1.renameSync)(tempSifPath, sifPath);
|
|
765
|
+
}
|
|
766
|
+
finally {
|
|
767
|
+
try {
|
|
768
|
+
(0, fs_1.rmSync)(tempSifPath, { force: true });
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
// Best effort cleanup for failed or interrupted pulls.
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
}, {
|
|
775
|
+
onWait: ({ owner }) => {
|
|
776
|
+
const ownerLabel = owner && owner !== 'unknown' ? ` (owner: ${owner})` : '';
|
|
777
|
+
log.step(`Waiting for shared image pull lock for ${log.dim(image)}${ownerLabel}...`);
|
|
778
|
+
},
|
|
779
|
+
onStaleLockRemoved: ({ ageMs, owner }) => {
|
|
780
|
+
const ageSec = Math.max(1, Math.round(ageMs / 1000));
|
|
781
|
+
const ownerLabel = owner && owner !== 'unknown' ? ` (owner: ${owner})` : '';
|
|
782
|
+
log.warn(`Recovered stale image pull lock for ${log.dim(image)} after ${ageSec}s${ownerLabel}.`);
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
}
|
|
786
|
+
catch (err) {
|
|
787
|
+
const msg = err?.stderr?.toString?.() ?? err?.message ?? String(err);
|
|
788
|
+
log.error(`Failed to pull SIF image "${image}" with ${runtime}.`);
|
|
789
|
+
console.error(String(msg).trim().slice(0, 500));
|
|
790
|
+
process.exit(1);
|
|
791
|
+
}
|
|
614
792
|
return sifPath;
|
|
615
793
|
}
|
|
616
794
|
function ensurePodmanImage(runtime, image) {
|
|
@@ -697,6 +875,21 @@ function getMountRoots(session) {
|
|
|
697
875
|
}),
|
|
698
876
|
];
|
|
699
877
|
}
|
|
878
|
+
function getSlurmProxyMountRoots(session) {
|
|
879
|
+
const { workdir, config } = session;
|
|
880
|
+
return [
|
|
881
|
+
{ host: workdir, container: '/work' },
|
|
882
|
+
...config.filesystem.extra_paths.map(({ path: p }) => {
|
|
883
|
+
const resolved = p.replace(/^~/, (0, os_1.homedir)());
|
|
884
|
+
const target = `/mnt/${(0, path_1.basename)(resolved)}`;
|
|
885
|
+
return { host: resolved, container: target };
|
|
886
|
+
}),
|
|
887
|
+
...(config.datasets || []).map(({ path: p, name }) => {
|
|
888
|
+
const resolved = p.replace(/^~/, (0, os_1.homedir)());
|
|
889
|
+
return { host: resolved, container: `/datasets/${name}` };
|
|
890
|
+
}),
|
|
891
|
+
];
|
|
892
|
+
}
|
|
700
893
|
// ── SLURM host proxy (container wrappers -> host execution) ────────
|
|
701
894
|
const SLURM_PROXY_COMMANDS = new Set([
|
|
702
895
|
'srun',
|
|
@@ -708,31 +901,122 @@ const SLURM_PROXY_COMMANDS = new Set([
|
|
|
708
901
|
'scontrol',
|
|
709
902
|
]);
|
|
710
903
|
const SLURM_PROXY_SOCKET_IN_CONTAINER = '/home/sandbox/.labgate/slurm/host-proxy/slurm.sock';
|
|
711
|
-
|
|
712
|
-
|
|
904
|
+
const SLURM_PROXY_PATH_VALUE_OPTIONS = new Set([
|
|
905
|
+
'--chdir',
|
|
906
|
+
'--output',
|
|
907
|
+
'--error',
|
|
908
|
+
'--input',
|
|
909
|
+
'-D',
|
|
910
|
+
'-o',
|
|
911
|
+
'-e',
|
|
912
|
+
'-i',
|
|
913
|
+
]);
|
|
914
|
+
function normalizeSlurmProxyContainerCwd(containerCwd) {
|
|
915
|
+
const raw = String(containerCwd || '').trim();
|
|
916
|
+
if (!raw)
|
|
917
|
+
return '/work';
|
|
918
|
+
return raw.startsWith('/')
|
|
919
|
+
? path_1.posix.resolve('/', raw)
|
|
920
|
+
: path_1.posix.resolve('/work', raw);
|
|
921
|
+
}
|
|
922
|
+
function buildSlurmProxyBlockedPathError(rawPath, resolvedPath) {
|
|
923
|
+
return (`Path blocked by SLURM host proxy: ${rawPath} resolves to ${resolvedPath}. ` +
|
|
924
|
+
'Use /work, /mnt/*, or /datasets/* paths.');
|
|
925
|
+
}
|
|
926
|
+
function isLikelyRelativeSlurmPathArg(arg) {
|
|
927
|
+
const trimmed = String(arg || '').trim();
|
|
928
|
+
if (!trimmed || trimmed.startsWith('-'))
|
|
929
|
+
return false;
|
|
930
|
+
if (trimmed === '.' || trimmed === '..')
|
|
931
|
+
return true;
|
|
932
|
+
if (trimmed.startsWith('./') || trimmed.startsWith('../'))
|
|
933
|
+
return true;
|
|
934
|
+
return trimmed.includes('/');
|
|
935
|
+
}
|
|
936
|
+
function resolveSlurmProxyPathToHost(session, candidatePath, containerCwd = '/work') {
|
|
937
|
+
const raw = String(candidatePath || '').trim();
|
|
938
|
+
if (!raw)
|
|
939
|
+
return null;
|
|
940
|
+
const normalizedCwd = normalizeSlurmProxyContainerCwd(containerCwd);
|
|
941
|
+
const normalizedPath = raw.startsWith('/')
|
|
942
|
+
? path_1.posix.resolve('/', raw)
|
|
943
|
+
: path_1.posix.resolve(normalizedCwd, raw);
|
|
944
|
+
const mounts = getSlurmProxyMountRoots(session).sort((a, b) => b.container.length - a.container.length);
|
|
713
945
|
for (const mount of mounts) {
|
|
714
|
-
if (
|
|
946
|
+
if (normalizedPath === mount.container)
|
|
715
947
|
return mount.host;
|
|
716
948
|
const prefix = `${mount.container}/`;
|
|
717
|
-
if (
|
|
718
|
-
return (0, path_1.join)(mount.host,
|
|
949
|
+
if (normalizedPath.startsWith(prefix)) {
|
|
950
|
+
return (0, path_1.join)(mount.host, normalizedPath.slice(prefix.length));
|
|
719
951
|
}
|
|
720
952
|
}
|
|
721
|
-
return
|
|
953
|
+
return null;
|
|
722
954
|
}
|
|
723
|
-
function
|
|
724
|
-
|
|
725
|
-
|
|
955
|
+
function mapSlurmProxyArgToHostPath(session, arg, containerCwd) {
|
|
956
|
+
const trimmed = String(arg || '').trim();
|
|
957
|
+
if (!trimmed) {
|
|
958
|
+
return { ok: true, arg };
|
|
726
959
|
}
|
|
727
960
|
const eq = arg.indexOf('=');
|
|
728
961
|
if (eq > 0) {
|
|
729
|
-
const key = arg.slice(0, eq
|
|
962
|
+
const key = arg.slice(0, eq);
|
|
730
963
|
const value = arg.slice(eq + 1);
|
|
731
|
-
|
|
732
|
-
|
|
964
|
+
const shouldMapValue = (SLURM_PROXY_PATH_VALUE_OPTIONS.has(key) ||
|
|
965
|
+
value.startsWith('/') ||
|
|
966
|
+
value === '.' ||
|
|
967
|
+
value === '..' ||
|
|
968
|
+
value.startsWith('./') ||
|
|
969
|
+
value.startsWith('../'));
|
|
970
|
+
if (!shouldMapValue) {
|
|
971
|
+
return { ok: true, arg };
|
|
972
|
+
}
|
|
973
|
+
const mapped = resolveSlurmProxyPathToHost(session, value, containerCwd);
|
|
974
|
+
if (!mapped) {
|
|
975
|
+
const resolved = value.startsWith('/')
|
|
976
|
+
? path_1.posix.resolve('/', value)
|
|
977
|
+
: path_1.posix.resolve(normalizeSlurmProxyContainerCwd(containerCwd), value);
|
|
978
|
+
return { ok: false, error: buildSlurmProxyBlockedPathError(value, resolved) };
|
|
979
|
+
}
|
|
980
|
+
return { ok: true, arg: `${key}=${mapped}` };
|
|
981
|
+
}
|
|
982
|
+
if (arg.startsWith('/')) {
|
|
983
|
+
const mapped = resolveSlurmProxyPathToHost(session, arg, containerCwd);
|
|
984
|
+
if (!mapped) {
|
|
985
|
+
return { ok: false, error: buildSlurmProxyBlockedPathError(arg, path_1.posix.resolve('/', arg)) };
|
|
986
|
+
}
|
|
987
|
+
return { ok: true, arg: mapped };
|
|
988
|
+
}
|
|
989
|
+
if (isLikelyRelativeSlurmPathArg(arg)) {
|
|
990
|
+
const mapped = resolveSlurmProxyPathToHost(session, arg, containerCwd);
|
|
991
|
+
if (!mapped) {
|
|
992
|
+
const resolved = path_1.posix.resolve(normalizeSlurmProxyContainerCwd(containerCwd), arg);
|
|
993
|
+
return { ok: false, error: buildSlurmProxyBlockedPathError(arg, resolved) };
|
|
733
994
|
}
|
|
995
|
+
return { ok: true, arg: mapped };
|
|
734
996
|
}
|
|
735
|
-
return arg;
|
|
997
|
+
return { ok: true, arg };
|
|
998
|
+
}
|
|
999
|
+
function mapSlurmProxyArgsToHost(session, args, containerCwd) {
|
|
1000
|
+
const mappedArgs = [];
|
|
1001
|
+
for (let i = 0; i < args.length; i++) {
|
|
1002
|
+
const arg = String(args[i] || '');
|
|
1003
|
+
if (SLURM_PROXY_PATH_VALUE_OPTIONS.has(arg) && i + 1 < args.length) {
|
|
1004
|
+
const nextArg = String(args[i + 1] || '');
|
|
1005
|
+
const mappedValue = resolveSlurmProxyPathToHost(session, nextArg, containerCwd);
|
|
1006
|
+
if (!mappedValue) {
|
|
1007
|
+
const resolved = path_1.posix.resolve(normalizeSlurmProxyContainerCwd(containerCwd), nextArg);
|
|
1008
|
+
return { ok: false, error: buildSlurmProxyBlockedPathError(nextArg, resolved) };
|
|
1009
|
+
}
|
|
1010
|
+
mappedArgs.push(arg, mappedValue);
|
|
1011
|
+
i += 1;
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const mapped = mapSlurmProxyArgToHostPath(session, arg, containerCwd);
|
|
1015
|
+
if (!mapped.ok)
|
|
1016
|
+
return mapped;
|
|
1017
|
+
mappedArgs.push(mapped.arg);
|
|
1018
|
+
}
|
|
1019
|
+
return { ok: true, args: mappedArgs };
|
|
736
1020
|
}
|
|
737
1021
|
function startSlurmHostProxy(session) {
|
|
738
1022
|
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
@@ -791,11 +1075,29 @@ function startSlurmHostProxy(session) {
|
|
|
791
1075
|
return;
|
|
792
1076
|
}
|
|
793
1077
|
const args = Array.isArray(req.args) ? req.args.map(v => String(v)) : [];
|
|
794
|
-
const mappedArgs = args.map(arg => mapProxyArgToHostPath(session, arg));
|
|
795
1078
|
const requestedCwd = String(req.cwd || '').trim();
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
1079
|
+
const containerCwd = normalizeSlurmProxyContainerCwd(requestedCwd || '/work');
|
|
1080
|
+
const mappedCwd = resolveSlurmProxyPathToHost(session, containerCwd, '/work');
|
|
1081
|
+
if (!mappedCwd) {
|
|
1082
|
+
send({
|
|
1083
|
+
ok: false,
|
|
1084
|
+
exit_code: 126,
|
|
1085
|
+
stdout_b64: '',
|
|
1086
|
+
stderr_b64: Buffer.from(buildSlurmProxyBlockedPathError(requestedCwd || '/work', containerCwd)).toString('base64'),
|
|
1087
|
+
});
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
const mappedArgsResult = mapSlurmProxyArgsToHost(session, args, containerCwd);
|
|
1091
|
+
if (!mappedArgsResult.ok) {
|
|
1092
|
+
send({
|
|
1093
|
+
ok: false,
|
|
1094
|
+
exit_code: 126,
|
|
1095
|
+
stdout_b64: '',
|
|
1096
|
+
stderr_b64: Buffer.from(mappedArgsResult.error).toString('base64'),
|
|
1097
|
+
});
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const mappedArgs = mappedArgsResult.args;
|
|
799
1101
|
const stdoutChunks = [];
|
|
800
1102
|
const stderrChunks = [];
|
|
801
1103
|
const child = (0, child_process_1.spawn)(cmd, mappedArgs, {
|
|
@@ -975,6 +1277,7 @@ const CODEX_OAUTH_STARTUP_HEARTBEAT_MS = 10_000;
|
|
|
975
1277
|
const CODEX_OAUTH_STARTUP_HEARTBEAT_MAX_MS = 50_000;
|
|
976
1278
|
const CODEX_OAUTH_STARTUP_SPINNER_MS = 125;
|
|
977
1279
|
const CODEX_OAUTH_STARTUP_SPINNER_FRAMES = ['|', '/', '-', '\\'];
|
|
1280
|
+
const DEFERRED_SLURM_PASSTHROUGH_DELAY_MS = 1_500;
|
|
978
1281
|
function getCodexOauthCallbackPort() {
|
|
979
1282
|
const raw = (process.env.LABGATE_CODEX_OAUTH_CALLBACK_PORT || '').trim();
|
|
980
1283
|
if (!raw)
|
|
@@ -1069,6 +1372,7 @@ function startCodexOauthStartupHeartbeat() {
|
|
|
1069
1372
|
// ── Build Apptainer arguments ─────────────────────────────
|
|
1070
1373
|
function buildApptainerArgs(session, sifPath, sessionId, tokenEnv = []) {
|
|
1071
1374
|
const { agent, workdir, config } = session;
|
|
1375
|
+
const ensureCommands = session.config.commands.ensure_commands;
|
|
1072
1376
|
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
1073
1377
|
const { blockedMounts, emptyDir, envArgs } = prepareCommonArgs(session, sessionId, tokenEnv);
|
|
1074
1378
|
const resultsDbPath = ensureResultsDbPathForContainer();
|
|
@@ -1126,7 +1430,7 @@ function buildApptainerArgs(session, sifPath, sessionId, tokenEnv = []) {
|
|
|
1126
1430
|
// ── SIF image ──
|
|
1127
1431
|
sifPath,
|
|
1128
1432
|
// ── Entrypoint ──
|
|
1129
|
-
'bash', '-lc', buildEntrypoint(agent),
|
|
1433
|
+
'bash', '-lc', buildEntrypoint(agent, { ensureCommands }),
|
|
1130
1434
|
];
|
|
1131
1435
|
}
|
|
1132
1436
|
function getPodmanContainerName(sessionId) {
|
|
@@ -1134,6 +1438,7 @@ function getPodmanContainerName(sessionId) {
|
|
|
1134
1438
|
}
|
|
1135
1439
|
function buildPodmanArgs(session, image, sessionId, tokenEnv = [], options = { tty: false }) {
|
|
1136
1440
|
const { agent, workdir, config } = session;
|
|
1441
|
+
const ensureCommands = session.config.commands.ensure_commands;
|
|
1137
1442
|
const disableClaudeStatusLineForPodmanDarwin = agent === 'claude' && (0, os_1.platform)() === 'darwin';
|
|
1138
1443
|
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
1139
1444
|
const { blockedMounts, emptyDir, envArgs } = prepareCommonArgs(session, sessionId, tokenEnv);
|
|
@@ -1193,7 +1498,10 @@ function buildPodmanArgs(session, image, sessionId, tokenEnv = [], options = { t
|
|
|
1193
1498
|
...envArgs,
|
|
1194
1499
|
// ── Image + entrypoint ──
|
|
1195
1500
|
image,
|
|
1196
|
-
'bash', '-lc', buildEntrypoint(agent, {
|
|
1501
|
+
'bash', '-lc', buildEntrypoint(agent, {
|
|
1502
|
+
disableClaudeStatusLine: disableClaudeStatusLineForPodmanDarwin,
|
|
1503
|
+
ensureCommands,
|
|
1504
|
+
}),
|
|
1197
1505
|
];
|
|
1198
1506
|
}
|
|
1199
1507
|
// ── Entrypoint script (inline) ───────────────────────────
|
|
@@ -1220,6 +1528,10 @@ function buildEntrypoint(agent, options = {}) {
|
|
|
1220
1528
|
|| disableClaudeStatusLineFlag === 'yes'
|
|
1221
1529
|
|| disableClaudeStatusLineFlag === 'on');
|
|
1222
1530
|
const disableClaudeStatusLine = options.disableClaudeStatusLine === true || disableClaudeStatusLineFromEnv;
|
|
1531
|
+
const ensureCommandListRaw = options.ensureCommands === undefined ? ['git'] : options.ensureCommands;
|
|
1532
|
+
const ensureCommands = Array.from(new Set((Array.isArray(ensureCommandListRaw) ? ensureCommandListRaw : [])
|
|
1533
|
+
.map((value) => String(value || '').trim())
|
|
1534
|
+
.filter((value) => /^[a-zA-Z0-9._+-]+$/.test(value))));
|
|
1223
1535
|
const lines = [
|
|
1224
1536
|
'set -euo pipefail',
|
|
1225
1537
|
'export HOME=/home/sandbox',
|
|
@@ -1307,9 +1619,13 @@ function buildEntrypoint(agent, options = {}) {
|
|
|
1307
1619
|
];
|
|
1308
1620
|
const preLaunchLines = [];
|
|
1309
1621
|
if (agent === 'claude') {
|
|
1310
|
-
|
|
1622
|
+
if (ensureCommands.length > 0) {
|
|
1623
|
+
preLaunchLines.push('labgate_install_system_package() {', ' local manager="${1:-}"', ' local pkg="${2:-}"', ' [ -n "$manager" ] && [ -n "$pkg" ] || return 1', ' case "$manager" in', ' apt-get) apt-get install -y "$pkg" >/dev/null 2>&1 ;;', ' apk) apk add --no-cache "$pkg" >/dev/null 2>&1 ;;', ' dnf) dnf install -y "$pkg" >/dev/null 2>&1 ;;', ' yum) yum install -y "$pkg" >/dev/null 2>&1 ;;', ' microdnf) microdnf install -y "$pkg" >/dev/null 2>&1 ;;', ' *) return 1 ;;', ' esac', '}', 'labgate_candidate_packages_for_command() {', ' local cmd="${1:-}"', ' case "$cmd" in', ' rg) echo "ripgrep rg" ;;', ' fd) echo "fd-find fd" ;;', ' pip) echo "python3-pip py3-pip pip" ;;', ' python) echo "python3 python" ;;', ' *) echo "$cmd" ;;', ' esac', '}', 'labgate_ensure_commands() {', ` local required=(${ensureCommands.map((cmd) => JSON.stringify(cmd)).join(' ')})`, ' local missing=()', ' local cmd=""', ' for cmd in "${required[@]}"; do', ' if ! command -v "$cmd" >/dev/null 2>&1; then', ' missing+=("$cmd")', ' fi', ' done', ' if [ "${#missing[@]}" -eq 0 ]; then', ' return 0', ' fi', ' if [ "$(id -u)" != "0" ]; then', ' echo "[labgate] Warning: missing container command(s): ${missing[*]}." >&2', ' echo "[labgate] Configure an image with required tools preinstalled for best Claude UX." >&2', ' return 0', ' fi', ' local manager=""', ' if command -v apt-get >/dev/null 2>&1; then', ' manager="apt-get"', ' export DEBIAN_FRONTEND=noninteractive', ' apt-get update >/dev/null 2>&1 || true', ' elif command -v apk >/dev/null 2>&1; then', ' manager="apk"', ' elif command -v dnf >/dev/null 2>&1; then', ' manager="dnf"', ' elif command -v yum >/dev/null 2>&1; then', ' manager="yum"', ' elif command -v microdnf >/dev/null 2>&1; then', ' manager="microdnf"', ' fi', ' if [ -z "$manager" ]; then', ' echo "[labgate] Warning: cannot install missing commands (${missing[*]}); no known package manager found." >&2', ' return 0', ' fi', ' echo "[labgate] Installing missing container tools: ${missing[*]}..."', ' local candidate=""', ' local installed="0"', ' for cmd in "${missing[@]}"; do', ' installed="0"', ' for candidate in $(labgate_candidate_packages_for_command "$cmd"); do', ' if labgate_install_system_package "$manager" "$candidate"; then', ' installed="1"', ' if command -v "$cmd" >/dev/null 2>&1; then', ' break', ' fi', ' fi', ' done', ' if [ "$installed" != "1" ]; then', ' echo "[labgate] Warning: failed to install command ${cmd}." >&2', ' fi', ' done', ' local still_missing=()', ' for cmd in "${required[@]}"; do', ' if ! command -v "$cmd" >/dev/null 2>&1; then', ' still_missing+=("$cmd")', ' fi', ' done', ' if [ "${#still_missing[@]}" -gt 0 ]; then', ' echo "[labgate] Warning: command(s) still unavailable after install attempt: ${still_missing[*]}." >&2', ' echo "[labgate] Configure an image with these tools preinstalled for reliable startup." >&2', ' fi', '}', 'labgate_ensure_commands', '');
|
|
1624
|
+
}
|
|
1311
1625
|
if (disableClaudeStatusLine) {
|
|
1312
|
-
|
|
1626
|
+
// Set the statusLine to a no-op command so Claude Code renders an empty
|
|
1627
|
+
// status bar instead of falling back to its default "time ➜ at host |".
|
|
1628
|
+
lines.push('mkdir -p "$HOME/.claude"', 'rm -f "$HOME/.claude/labgate-statusline.sh" 2>/dev/null || true', 'node -e \'', ' const fs = require("fs");', ' const path = process.env.HOME + "/.claude/settings.json";', ' let settings = {};', ' try {', ' if (fs.existsSync(path)) settings = JSON.parse(fs.readFileSync(path, "utf8"));', ' } catch {}', ' if (!settings || typeof settings !== "object" || Array.isArray(settings)) {', ' settings = {};', ' }', ' delete settings.statusline;', ' settings.statusLine = { type: "command", command: "true" };', ' fs.writeFileSync(path, JSON.stringify(settings, null, 2));', '\'', '');
|
|
1313
1629
|
}
|
|
1314
1630
|
else {
|
|
1315
1631
|
// Configure a LabGate status line in Claude Code with a dashboard URL.
|
|
@@ -1368,13 +1684,25 @@ let lastHandledOAuthAt = 0;
|
|
|
1368
1684
|
const CLAUDE_OAUTH_MARKER = 'https://claude.ai/oauth/authorize?';
|
|
1369
1685
|
const CLAUDE_MANUAL_CODE_REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';
|
|
1370
1686
|
const CLAUDE_LOCALHOST_REDIRECT_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
1687
|
+
const CLAUDE_AUTH_FALLBACK_URL_RE = /https:\/\/[A-Za-z0-9.-]+\/[^\s"'<>]+/g;
|
|
1371
1688
|
function stripTerminalControlForOAuth(text) {
|
|
1372
|
-
|
|
1689
|
+
const source = String(text || '');
|
|
1690
|
+
const osc8Urls = [];
|
|
1691
|
+
const osc8Re = /\x1b\]8;[^\x07\x1b]*?;([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
|
|
1692
|
+
for (const match of source.matchAll(osc8Re)) {
|
|
1693
|
+
const candidate = String(match[1] || '').trim();
|
|
1694
|
+
if (candidate)
|
|
1695
|
+
osc8Urls.push(candidate);
|
|
1696
|
+
}
|
|
1697
|
+
let stripped = source
|
|
1373
1698
|
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
|
|
1374
1699
|
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
1375
1700
|
.replace(/\x1b[P^_][\s\S]*?\x1b\\/g, '')
|
|
1376
1701
|
.replace(/\x1b[@-_]/g, '')
|
|
1377
1702
|
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
|
|
1703
|
+
if (osc8Urls.length)
|
|
1704
|
+
stripped += `\n${osc8Urls.join('\n')}`;
|
|
1705
|
+
return stripped;
|
|
1378
1706
|
}
|
|
1379
1707
|
function canonicalizeClaudeOAuthRedirectUri(parsed) {
|
|
1380
1708
|
const rawRedirectUri = (parsed.searchParams.get('redirect_uri') || '').trim();
|
|
@@ -1391,45 +1719,84 @@ function canonicalizeClaudeOAuthRedirectUri(parsed) {
|
|
|
1391
1719
|
// Keep original redirect URI if it cannot be parsed.
|
|
1392
1720
|
}
|
|
1393
1721
|
}
|
|
1394
|
-
function
|
|
1395
|
-
const
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
const
|
|
1410
|
-
|
|
1722
|
+
function isClaudeAuthHost(hostname) {
|
|
1723
|
+
const host = String(hostname || '').trim().toLowerCase();
|
|
1724
|
+
return host === 'claude.ai' || host === 'platform.claude.com' || host === 'console.anthropic.com';
|
|
1725
|
+
}
|
|
1726
|
+
function isLikelyClaudeAuthPath(pathname) {
|
|
1727
|
+
const path = String(pathname || '').trim().toLowerCase();
|
|
1728
|
+
if (!path)
|
|
1729
|
+
return false;
|
|
1730
|
+
return path.startsWith('/oauth')
|
|
1731
|
+
|| path.startsWith('/login')
|
|
1732
|
+
|| path.startsWith('/auth')
|
|
1733
|
+
|| path.startsWith('/code/callback');
|
|
1734
|
+
}
|
|
1735
|
+
function hasValidClaudeAuthorizeParams(parsed) {
|
|
1736
|
+
const required = ['client_id', 'state', 'response_type', 'redirect_uri', 'scope', 'code_challenge', 'code_challenge_method'];
|
|
1737
|
+
for (const key of required) {
|
|
1738
|
+
if (!parsed.searchParams.get(key))
|
|
1739
|
+
return false;
|
|
1740
|
+
}
|
|
1741
|
+
if ((parsed.searchParams.get('response_type') || '').trim().toLowerCase() !== 'code')
|
|
1742
|
+
return false;
|
|
1743
|
+
if ((parsed.searchParams.get('code_challenge_method') || '').trim().toUpperCase() !== 'S256')
|
|
1744
|
+
return false;
|
|
1745
|
+
return true;
|
|
1746
|
+
}
|
|
1747
|
+
function normalizeClaudeAuthUrlCandidate(candidate) {
|
|
1748
|
+
if (!candidate)
|
|
1411
1749
|
return null;
|
|
1412
1750
|
try {
|
|
1413
|
-
const parsed = new URL(
|
|
1414
|
-
if (parsed.protocol !== 'https:'
|
|
1751
|
+
const parsed = new URL(String(candidate || '').trim());
|
|
1752
|
+
if (parsed.protocol !== 'https:')
|
|
1415
1753
|
return null;
|
|
1416
|
-
|
|
1417
|
-
const required = ['client_id', 'state', 'response_type', 'redirect_uri', 'scope', 'code_challenge', 'code_challenge_method'];
|
|
1418
|
-
for (const key of required) {
|
|
1419
|
-
if (!parsed.searchParams.get(key))
|
|
1420
|
-
return null;
|
|
1421
|
-
}
|
|
1422
|
-
if ((parsed.searchParams.get('response_type') || '').trim().toLowerCase() !== 'code')
|
|
1754
|
+
if (!isClaudeAuthHost(parsed.hostname))
|
|
1423
1755
|
return null;
|
|
1424
|
-
if ((parsed.
|
|
1756
|
+
if (!isLikelyClaudeAuthPath(parsed.pathname))
|
|
1425
1757
|
return null;
|
|
1426
|
-
|
|
1758
|
+
if (parsed.hostname.toLowerCase() === 'claude.ai' && parsed.pathname === '/oauth/authorize') {
|
|
1759
|
+
if (!hasValidClaudeAuthorizeParams(parsed))
|
|
1760
|
+
return null;
|
|
1761
|
+
canonicalizeClaudeOAuthRedirectUri(parsed);
|
|
1762
|
+
}
|
|
1427
1763
|
return parsed.toString();
|
|
1428
1764
|
}
|
|
1429
1765
|
catch {
|
|
1430
1766
|
return null;
|
|
1431
1767
|
}
|
|
1432
1768
|
}
|
|
1769
|
+
function normalizeClaudeOAuthUrl(raw) {
|
|
1770
|
+
const stripped = stripTerminalControlForOAuth(raw);
|
|
1771
|
+
if (!stripped)
|
|
1772
|
+
return null;
|
|
1773
|
+
const start = stripped.lastIndexOf(CLAUDE_OAUTH_MARKER);
|
|
1774
|
+
if (start !== -1) {
|
|
1775
|
+
let candidate = stripped.slice(start);
|
|
1776
|
+
const endPatterns = [/\n\s*Paste code/i, /\n\s*Browser did(?: not|n't) open\?/i, /\n\s*\n/];
|
|
1777
|
+
let end = candidate.length;
|
|
1778
|
+
for (const pat of endPatterns) {
|
|
1779
|
+
const match = candidate.match(pat);
|
|
1780
|
+
if (match && match.index !== undefined && match.index >= 0 && match.index < end) {
|
|
1781
|
+
end = match.index;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
candidate = candidate.slice(0, end).replace(/\s+/g, '').trim();
|
|
1785
|
+
const strictMatch = candidate.match(/^https:\/\/claude\.ai\/oauth\/authorize\?[A-Za-z0-9\-._~%!$&'()*+,;=:@/?#[\]]+/);
|
|
1786
|
+
if (strictMatch && strictMatch[0]) {
|
|
1787
|
+
const strictUrl = normalizeClaudeAuthUrlCandidate(strictMatch[0]);
|
|
1788
|
+
if (strictUrl)
|
|
1789
|
+
return strictUrl;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
const fallbackMatches = stripped.match(CLAUDE_AUTH_FALLBACK_URL_RE) || [];
|
|
1793
|
+
for (const match of fallbackMatches) {
|
|
1794
|
+
const normalized = normalizeClaudeAuthUrlCandidate(match);
|
|
1795
|
+
if (normalized)
|
|
1796
|
+
return normalized;
|
|
1797
|
+
}
|
|
1798
|
+
return null;
|
|
1799
|
+
}
|
|
1433
1800
|
function handleOAuthUrl(url, options) {
|
|
1434
1801
|
const normalizedUrl = normalizeClaudeOAuthUrl(url);
|
|
1435
1802
|
if (!normalizedUrl)
|
|
@@ -1522,8 +1889,11 @@ function setupBrowserHook(options = {}) {
|
|
|
1522
1889
|
' esac',
|
|
1523
1890
|
'done',
|
|
1524
1891
|
'[ -n "$url" ] || url="${1:-}"',
|
|
1525
|
-
'
|
|
1526
|
-
'
|
|
1892
|
+
'targets="/home/sandbox/.labgate/browser-url /home/sandbox/.labgate/ai-home/.labgate/browser-url"',
|
|
1893
|
+
'for t in $targets; do',
|
|
1894
|
+
' mkdir -p "$(dirname "$t")"',
|
|
1895
|
+
' printf \'%s\\n\' "$url" > "$t"',
|
|
1896
|
+
'done',
|
|
1527
1897
|
'exit 0',
|
|
1528
1898
|
'',
|
|
1529
1899
|
].join('\n'), { mode: 0o755 });
|
|
@@ -1629,49 +1999,18 @@ function renderStickyFooter(line) {
|
|
|
1629
1999
|
/**
|
|
1630
2000
|
* Resolve agent credentials. Checks in order:
|
|
1631
2001
|
* 1. Explicit --api-key flag (passed via apiKey parameter)
|
|
1632
|
-
* 2.
|
|
1633
|
-
* 3. ANTHROPIC_API_KEY in host environment
|
|
2002
|
+
* 2. ANTHROPIC_API_KEY in host environment
|
|
1634
2003
|
*
|
|
1635
2004
|
* Returns env args to inject into the container.
|
|
1636
2005
|
*/
|
|
1637
2006
|
function getAgentTokenEnv(agent, apiKey, deps = {}) {
|
|
1638
2007
|
const env = deps.env ?? process.env;
|
|
1639
|
-
const platformFn = deps.platformFn ?? os_1.platform;
|
|
1640
|
-
const execSync = deps.execFileSyncFn ?? child_process_1.execFileSync;
|
|
1641
|
-
const ensureDir = deps.mkdirSyncFn ?? fs_1.mkdirSync;
|
|
1642
|
-
const writeFile = deps.writeFileSyncFn ?? fs_1.writeFileSync;
|
|
1643
2008
|
// 1. Explicit API key from CLI flag
|
|
1644
2009
|
if (apiKey) {
|
|
1645
2010
|
log.success('Using API key from --api-key flag');
|
|
1646
2011
|
return ['--env', `ANTHROPIC_API_KEY=${apiKey}`];
|
|
1647
2012
|
}
|
|
1648
|
-
// 2.
|
|
1649
|
-
// Sync credentials file into sandbox home, but do not export ANTHROPIC_API_KEY.
|
|
1650
|
-
// Claude may prompt interactively when a custom ANTHROPIC_API_KEY is present,
|
|
1651
|
-
// which can stall UI launches.
|
|
1652
|
-
if (platformFn() === 'darwin' && agent === 'claude') {
|
|
1653
|
-
try {
|
|
1654
|
-
const raw = execSync('security', [
|
|
1655
|
-
'find-generic-password',
|
|
1656
|
-
'-s', 'Claude Code-credentials',
|
|
1657
|
-
'-w',
|
|
1658
|
-
], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
1659
|
-
const creds = JSON.parse(raw);
|
|
1660
|
-
const token = creds.claudeAiOauth?.accessToken;
|
|
1661
|
-
if (token) {
|
|
1662
|
-
const sandboxHome = deps.sandboxHome ?? (0, config_js_1.getSandboxHome)();
|
|
1663
|
-
const claudeDir = (0, path_1.join)(sandboxHome, '.claude');
|
|
1664
|
-
ensureDir(claudeDir, { recursive: true });
|
|
1665
|
-
writeFile((0, path_1.join)(claudeDir, '.credentials.json'), raw, { mode: 0o600 });
|
|
1666
|
-
log.success('Synced credentials from macOS keychain');
|
|
1667
|
-
return [];
|
|
1668
|
-
}
|
|
1669
|
-
}
|
|
1670
|
-
catch {
|
|
1671
|
-
// Fall through to host env var fallback.
|
|
1672
|
-
}
|
|
1673
|
-
}
|
|
1674
|
-
// 3. Forward ANTHROPIC_API_KEY from host environment
|
|
2013
|
+
// 2. Forward ANTHROPIC_API_KEY from host environment
|
|
1675
2014
|
if (env.ANTHROPIC_API_KEY) {
|
|
1676
2015
|
log.success('Forwarding ANTHROPIC_API_KEY from environment');
|
|
1677
2016
|
return ['--env', `ANTHROPIC_API_KEY=${env.ANTHROPIC_API_KEY}`];
|
|
@@ -1685,6 +2024,22 @@ function formatStatusFooter(session, runtime, sessionId, image) {
|
|
|
1685
2024
|
const net = session.config.network.mode;
|
|
1686
2025
|
return `labgate | ${sessionId} | ${runtime} | net=${net} | timeout=${timeoutLabel} | audit=${audit}`;
|
|
1687
2026
|
}
|
|
2027
|
+
function formatStartupDuration(ms) {
|
|
2028
|
+
const rounded = Math.max(0, Math.round(ms));
|
|
2029
|
+
if (rounded < 1000)
|
|
2030
|
+
return `${rounded}ms`;
|
|
2031
|
+
if (rounded < 10_000)
|
|
2032
|
+
return `${(rounded / 1000).toFixed(1)}s`;
|
|
2033
|
+
return `${Math.round(rounded / 1000)}s`;
|
|
2034
|
+
}
|
|
2035
|
+
function logStartupTimings(entries, totalMs) {
|
|
2036
|
+
const summary = entries
|
|
2037
|
+
.filter(([, ms]) => Number.isFinite(ms) && ms >= 0)
|
|
2038
|
+
.map(([label, ms]) => `${label}=${formatStartupDuration(ms)}`)
|
|
2039
|
+
.join(', ');
|
|
2040
|
+
const prefix = summary ? `${summary}, ` : '';
|
|
2041
|
+
log.step(`[labgate] startup timings: ${prefix}total=${formatStartupDuration(totalMs)}`);
|
|
2042
|
+
}
|
|
1688
2043
|
// ── Shared session helpers ─────────────────────────────────
|
|
1689
2044
|
function logSessionStart(session, sessionId) {
|
|
1690
2045
|
if (!session.config.audit.enabled)
|
|
@@ -1780,13 +2135,18 @@ function printSessionInfo(session, sessionId, runtime) {
|
|
|
1780
2135
|
}
|
|
1781
2136
|
// ── Session lifecycle ─────────────────────────────────────
|
|
1782
2137
|
async function startSession(session) {
|
|
2138
|
+
const startupStartedAt = Date.now();
|
|
2139
|
+
const startupTimings = [];
|
|
2140
|
+
const recordStartupTiming = (label, startedAt) => {
|
|
2141
|
+
startupTimings.push([label, Math.max(0, Date.now() - startedAt)]);
|
|
2142
|
+
};
|
|
1783
2143
|
const preferred = session.config.runtime;
|
|
1784
2144
|
const runtime = session.dryRun ? getDryRunRuntime(preferred) : (0, runtime_js_1.getRuntime)(preferred);
|
|
1785
2145
|
const image = session.imageOverride ?? session.config.image;
|
|
1786
2146
|
const sessionId = (0, crypto_1.randomBytes)(4).toString('hex');
|
|
1787
2147
|
const footerMode = session.footerMode ?? 'sticky';
|
|
1788
2148
|
const footerLine = formatStatusFooter(session, runtime, sessionId, image);
|
|
1789
|
-
// Extract agent auth token (CLI flag → env var
|
|
2149
|
+
// Extract agent auth token (CLI flag → env var)
|
|
1790
2150
|
const tokenEnv = session.dryRun ? [] : getAgentTokenEnv(session.agent, session.apiKey);
|
|
1791
2151
|
const bridgeCodexOauthForPodman = runtime === 'podman' && shouldBridgeCodexOauthForPodman(session.agent, session.config.network.mode);
|
|
1792
2152
|
const needsCodexOauthStartupHeartbeat = bridgeCodexOauthForPodman && tokenEnv.length === 0;
|
|
@@ -1819,6 +2179,8 @@ async function startSession(session) {
|
|
|
1819
2179
|
...(browserHook?.env ?? []),
|
|
1820
2180
|
];
|
|
1821
2181
|
let cleanupSlurmHostProxy = () => { };
|
|
2182
|
+
let cleanupDeferredSlurmPassthrough = () => { };
|
|
2183
|
+
let startDeferredSlurmPassthrough = () => { };
|
|
1822
2184
|
// If the agent isn't installed in the persistent sandbox home yet, warn that first run can be slow.
|
|
1823
2185
|
if (!session.dryRun) {
|
|
1824
2186
|
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
@@ -1835,31 +2197,113 @@ async function startSession(session) {
|
|
|
1835
2197
|
// can work out of the box when present on the host. Full SLURM tracking/MCP
|
|
1836
2198
|
// remains controlled by slurm.enabled.
|
|
1837
2199
|
if (!session.dryRun && runtime === 'apptainer') {
|
|
2200
|
+
const hostProxyStartedAt = Date.now();
|
|
1838
2201
|
const hostProxy = startSlurmHostProxy(session);
|
|
2202
|
+
recordStartupTiming('slurm_host_proxy', hostProxyStartedAt);
|
|
2203
|
+
const trackingEnabled = session.config.slurm.enabled;
|
|
2204
|
+
const runSlurmPassthroughStage = (mode) => {
|
|
2205
|
+
const stageStartedAt = Date.now();
|
|
2206
|
+
try {
|
|
2207
|
+
const staged = (0, slurm_cli_passthrough_js_1.stageSlurmCliPassthrough)({
|
|
2208
|
+
sandboxHome: (0, config_js_1.getSandboxHome)(),
|
|
2209
|
+
env: process.env,
|
|
2210
|
+
installMissingWrappers: trackingEnabled,
|
|
2211
|
+
hostProxySocketInContainer: null,
|
|
2212
|
+
preferHostProxy: false,
|
|
2213
|
+
});
|
|
2214
|
+
recordStartupTiming(mode === 'startup' ? 'slurm_passthrough_stage' : 'slurm_passthrough_stage_background', stageStartedAt);
|
|
2215
|
+
if (trackingEnabled) {
|
|
2216
|
+
if (!staged.ok) {
|
|
2217
|
+
log.warn('SLURM is enabled but no SLURM commands were found on the host PATH. ' +
|
|
2218
|
+
'Inside-sandbox SLURM commands (squeue/sbatch/...) will be unavailable.');
|
|
2219
|
+
}
|
|
2220
|
+
else if (staged.reused) {
|
|
2221
|
+
log.step(mode === 'startup'
|
|
2222
|
+
? `SLURM passthrough reused cached stage (${staged.staged.length} commands).`
|
|
2223
|
+
: `SLURM passthrough reused cached stage in background (${staged.staged.length} commands).`);
|
|
2224
|
+
}
|
|
2225
|
+
else if (staged.errors.length > 0) {
|
|
2226
|
+
log.step(mode === 'startup'
|
|
2227
|
+
? `SLURM passthrough staged (${staged.staged.length} commands) with warnings.`
|
|
2228
|
+
: `SLURM passthrough staged in background (${staged.staged.length} commands) with warnings.`);
|
|
2229
|
+
}
|
|
2230
|
+
else {
|
|
2231
|
+
log.step(mode === 'startup'
|
|
2232
|
+
? `SLURM passthrough staged (${staged.staged.length} commands).`
|
|
2233
|
+
: `SLURM passthrough staged in background (${staged.staged.length} commands).`);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
catch (err) {
|
|
2238
|
+
recordStartupTiming(mode === 'startup' ? 'slurm_passthrough_stage' : 'slurm_passthrough_stage_background', stageStartedAt);
|
|
2239
|
+
log.warn(mode === 'startup'
|
|
2240
|
+
? `SLURM passthrough staging failed: ${err?.message ?? String(err)}`
|
|
2241
|
+
: `Deferred SLURM passthrough staging failed: ${err?.message ?? String(err)}`);
|
|
2242
|
+
}
|
|
2243
|
+
};
|
|
1839
2244
|
if (hostProxy) {
|
|
1840
2245
|
cleanupSlurmHostProxy = hostProxy.cleanup;
|
|
1841
2246
|
log.step('SLURM host proxy enabled (wrappers delegate to host CLI).');
|
|
2247
|
+
const stageStartedAt = Date.now();
|
|
2248
|
+
const staged = (0, slurm_cli_passthrough_js_1.stageSlurmCliPassthrough)({
|
|
2249
|
+
sandboxHome: (0, config_js_1.getSandboxHome)(),
|
|
2250
|
+
env: process.env,
|
|
2251
|
+
installMissingWrappers: true,
|
|
2252
|
+
hostProxySocketInContainer: hostProxy.socketContainerPath,
|
|
2253
|
+
preferHostProxy: true,
|
|
2254
|
+
});
|
|
2255
|
+
recordStartupTiming('slurm_passthrough_stage', stageStartedAt);
|
|
2256
|
+
if (trackingEnabled) {
|
|
2257
|
+
if (!staged.ok) {
|
|
2258
|
+
log.warn('SLURM is enabled but no SLURM commands were found on the host PATH. ' +
|
|
2259
|
+
'Inside-sandbox SLURM commands (squeue/sbatch/...) will be unavailable.');
|
|
2260
|
+
}
|
|
2261
|
+
else if (staged.mode === 'proxy-only') {
|
|
2262
|
+
log.step(staged.reused
|
|
2263
|
+
? `SLURM passthrough reused host-proxy stage (${staged.hostCommands.length} commands).`
|
|
2264
|
+
: `SLURM passthrough configured via host proxy (${staged.hostCommands.length} commands).`);
|
|
2265
|
+
}
|
|
2266
|
+
else if (staged.reused) {
|
|
2267
|
+
log.step(`SLURM passthrough reused cached stage (${staged.staged.length} commands).`);
|
|
2268
|
+
}
|
|
2269
|
+
else if (staged.errors.length > 0) {
|
|
2270
|
+
log.step(`SLURM passthrough staged (${staged.staged.length} commands) with warnings.`);
|
|
2271
|
+
}
|
|
2272
|
+
else {
|
|
2273
|
+
log.step(`SLURM passthrough staged (${staged.staged.length} commands).`);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
1842
2276
|
}
|
|
1843
2277
|
else {
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
const staged = (0, slurm_cli_passthrough_js_1.stageSlurmCliPassthrough)({
|
|
1848
|
-
sandboxHome: (0, config_js_1.getSandboxHome)(),
|
|
1849
|
-
env: process.env,
|
|
1850
|
-
installMissingWrappers: trackingEnabled || !!hostProxy,
|
|
1851
|
-
hostProxySocketInContainer: hostProxy?.socketContainerPath ?? null,
|
|
1852
|
-
});
|
|
1853
|
-
if (trackingEnabled) {
|
|
1854
|
-
if (!staged.ok) {
|
|
1855
|
-
log.warn('SLURM is enabled but no SLURM commands were found on the host PATH. ' +
|
|
1856
|
-
'Inside-sandbox SLURM commands (squeue/sbatch/...) will be unavailable.');
|
|
1857
|
-
}
|
|
1858
|
-
else if (staged.errors.length > 0) {
|
|
1859
|
-
log.step(`SLURM passthrough staged (${staged.staged.length} commands) with warnings.`);
|
|
2278
|
+
if (trackingEnabled) {
|
|
2279
|
+
log.step('SLURM host proxy unavailable. Staging SLURM passthrough before startup.');
|
|
2280
|
+
runSlurmPassthroughStage('startup');
|
|
1860
2281
|
}
|
|
1861
2282
|
else {
|
|
1862
|
-
log.step(
|
|
2283
|
+
log.step('SLURM host proxy unavailable. Deferring SLURM passthrough staging until after startup.');
|
|
2284
|
+
let cancelled = false;
|
|
2285
|
+
let timer = null;
|
|
2286
|
+
let started = false;
|
|
2287
|
+
startDeferredSlurmPassthrough = () => {
|
|
2288
|
+
if (started || cancelled)
|
|
2289
|
+
return;
|
|
2290
|
+
started = true;
|
|
2291
|
+
timer = setTimeout(() => {
|
|
2292
|
+
timer = null;
|
|
2293
|
+
if (cancelled)
|
|
2294
|
+
return;
|
|
2295
|
+
runSlurmPassthroughStage('background');
|
|
2296
|
+
}, DEFERRED_SLURM_PASSTHROUGH_DELAY_MS);
|
|
2297
|
+
timer.unref();
|
|
2298
|
+
};
|
|
2299
|
+
cleanupDeferredSlurmPassthrough = () => {
|
|
2300
|
+
cancelled = true;
|
|
2301
|
+
if (timer) {
|
|
2302
|
+
clearTimeout(timer);
|
|
2303
|
+
timer = null;
|
|
2304
|
+
}
|
|
2305
|
+
started = true;
|
|
2306
|
+
};
|
|
1863
2307
|
}
|
|
1864
2308
|
}
|
|
1865
2309
|
}
|
|
@@ -1933,7 +2377,10 @@ async function startSession(session) {
|
|
|
1933
2377
|
}
|
|
1934
2378
|
}
|
|
1935
2379
|
// Register MCP servers for Claude Code (bundled for in-container use)
|
|
2380
|
+
const prepareMcpStartedAt = Date.now();
|
|
1936
2381
|
prepareMcpServers(session);
|
|
2382
|
+
recordStartupTiming('prepare_mcp_servers', prepareMcpStartedAt);
|
|
2383
|
+
logStartupTimings(startupTimings, Date.now() - startupStartedAt);
|
|
1937
2384
|
logSessionStart(session, sessionId);
|
|
1938
2385
|
printSessionInfo(session, sessionId, runtime);
|
|
1939
2386
|
if (footerMode === 'once') {
|
|
@@ -2033,6 +2480,7 @@ async function startSession(session) {
|
|
|
2033
2480
|
clearTimeout(timeoutHandle);
|
|
2034
2481
|
browserHook?.cleanup();
|
|
2035
2482
|
cleanupSlurm();
|
|
2483
|
+
cleanupDeferredSlurmPassthrough();
|
|
2036
2484
|
cleanupSlurmHostProxy();
|
|
2037
2485
|
removeSessionFile(sessionId, session.sharedSessionsDir);
|
|
2038
2486
|
cleanupLabgateInstruction();
|
|
@@ -2046,6 +2494,7 @@ async function startSession(session) {
|
|
|
2046
2494
|
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
2047
2495
|
process.on(sig, () => child.kill(sig));
|
|
2048
2496
|
}
|
|
2497
|
+
startDeferredSlurmPassthrough();
|
|
2049
2498
|
return;
|
|
2050
2499
|
}
|
|
2051
2500
|
}
|
|
@@ -2064,12 +2513,14 @@ async function startSession(session) {
|
|
|
2064
2513
|
// OAuth interception relies on the BROWSER hook + file watcher here
|
|
2065
2514
|
// (the PTY path above uses onData for direct interception).
|
|
2066
2515
|
const child = (0, child_process_1.spawn)(runtime, args, { stdio: 'inherit' });
|
|
2516
|
+
startDeferredSlurmPassthrough();
|
|
2067
2517
|
const timeoutHandle = setupSessionTimeout(session, sessionId, runtime, () => child.exitCode !== null, () => child.kill('SIGTERM'));
|
|
2068
2518
|
child.on('close', (code) => {
|
|
2069
2519
|
if (timeoutHandle)
|
|
2070
2520
|
clearTimeout(timeoutHandle);
|
|
2071
2521
|
browserHook?.cleanup();
|
|
2072
2522
|
cleanupSlurm();
|
|
2523
|
+
cleanupDeferredSlurmPassthrough();
|
|
2073
2524
|
cleanupSlurmHostProxy();
|
|
2074
2525
|
removeSessionFile(sessionId, session.sharedSessionsDir);
|
|
2075
2526
|
cleanupLabgateInstruction();
|