labgate 0.5.39 → 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 -248
- package/dist/cli.js +9 -33
- package/dist/cli.js.map +1 -1
- package/dist/lib/config.d.ts +19 -3
- package/dist/lib/config.js +154 -75
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/container.d.ts +11 -9
- package/dist/lib/container.js +749 -282
- 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/image-pull-lock.d.ts +18 -0
- package/dist/lib/image-pull-lock.js +167 -0
- package/dist/lib/image-pull-lock.js.map +1 -0
- package/dist/lib/init.js +22 -19
- 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 +1771 -297
- 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 +144 -93
- package/dist/mcp-bundles/display-mcp.bundle.mjs +35 -43
- package/dist/mcp-bundles/explorer-mcp.bundle.mjs +263 -146
- package/dist/mcp-bundles/results-mcp.bundle.mjs +39 -41
- package/dist/mcp-bundles/slurm-mcp.bundle.mjs +19 -21
- 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;
|
|
@@ -56,10 +59,12 @@ const net_1 = require("net");
|
|
|
56
59
|
const fast_glob_1 = __importDefault(require("fast-glob"));
|
|
57
60
|
const config_js_1 = require("./config.js");
|
|
58
61
|
const runtime_js_1 = require("./runtime.js");
|
|
62
|
+
const image_pull_lock_js_1 = require("./image-pull-lock.js");
|
|
59
63
|
const audit_js_1 = require("./audit.js");
|
|
60
64
|
const slurm_db_js_1 = require("./slurm-db.js");
|
|
61
65
|
const slurm_poller_js_1 = require("./slurm-poller.js");
|
|
62
66
|
const slurm_cli_passthrough_js_1 = require("./slurm-cli-passthrough.js");
|
|
67
|
+
const startup_stage_lock_js_1 = require("./startup-stage-lock.js");
|
|
63
68
|
const log = __importStar(require("./log.js"));
|
|
64
69
|
/**
|
|
65
70
|
* Compute a deterministic fingerprint of mount-affecting config fields.
|
|
@@ -205,6 +210,8 @@ function cleanupLabgateInstructionFile(workdir, filename, deleteWhenEmpty) {
|
|
|
205
210
|
function buildLabgateInstructionBlock(session) {
|
|
206
211
|
const agentLower = session.agent.toLowerCase();
|
|
207
212
|
const hasMcp = agentLower === 'claude' || agentLower === 'codex';
|
|
213
|
+
const datasetsPluginEnabled = session.config.plugins?.datasets !== false;
|
|
214
|
+
const slurmPluginEnabled = session.config.plugins?.slurm !== false;
|
|
208
215
|
const lines = [
|
|
209
216
|
LABGATE_INSTRUCTION_START,
|
|
210
217
|
'## LabGate Sandbox Context (Auto-Managed)',
|
|
@@ -237,41 +244,20 @@ function buildLabgateInstructionBlock(session) {
|
|
|
237
244
|
}
|
|
238
245
|
if (hasMcp) {
|
|
239
246
|
lines.push('');
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
lines.push('- Available tools: `list_results`, `register_result`, `get_result`, `update_result`, `delete_result`');
|
|
248
|
-
lines.push('- Use `register_result` sparingly. Do not auto-register routine progress updates or intermediate notes.');
|
|
249
|
-
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).');
|
|
250
|
-
lines.push('- If uncertain whether to register, ask the user first.');
|
|
251
|
-
lines.push('');
|
|
252
|
-
lines.push('### Display Widgets');
|
|
253
|
-
lines.push('- Use the `labgate-display` MCP tool `display_widget` to render rich interactive content in the LabGate web UI.');
|
|
254
|
-
lines.push('- Supported widgets: `markdown`, `table`, `plot`, `image`, `pdf`, `molecule`, `sequence`, `alignment`, `file_preview`, `tree`, `heatmap`, `tracks`, `domains`, `network`');
|
|
255
|
-
lines.push('- Use `molecule` with `pdb_id` to show 3D protein structures (e.g. `{ pdb_id: "2HIU" }`).');
|
|
256
|
-
lines.push('- Use `sequence` to display colored DNA/RNA/protein sequences.');
|
|
257
|
-
lines.push('- Use `plot` with a Plotly.js spec (`{ plotly: { data: [...], layout: {...} } }`) for interactive charts.');
|
|
258
|
-
lines.push('- Use `table` for structured data (`{ columns: [...], rows: [...] }`).');
|
|
259
|
-
lines.push('- Use `image` with `path` to display images from the filesystem.');
|
|
260
|
-
lines.push('- Use `pdf` with `path` to display PDF documents inline (`{ path: "/work/paper.pdf" }`).');
|
|
261
|
-
lines.push('- Use `alignment` for multiple sequence alignments with conservation highlighting.');
|
|
262
|
-
lines.push('- Use `tree` with Newick format string for phylogenetic trees (e.g. `{ newick: "((A,B),(C,D));" }`).');
|
|
263
|
-
lines.push('- Use `heatmap` for clustered heatmaps with optional dendrograms (`{ matrix: [[...]], row_labels: [...], cluster_rows: true }`).');
|
|
264
|
-
lines.push('- Use `tracks` for genome feature viewing via igv.js (`{ tracks: [{ path: "/work/file.gff3", format: "gff3" }] }`).');
|
|
265
|
-
lines.push('- Use `domains` for protein domain architecture (`{ length: 300, features: [{ type: "domain", name: "Kinase", start: 10, end: 120 }] }`).');
|
|
266
|
-
lines.push('- Use `network` for interactive graph visualization (`{ nodes: [{id:"A"}], edges: [{source:"A", target:"B"}] }`).');
|
|
267
|
-
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
|
+
}
|
|
268
254
|
}
|
|
269
255
|
if (session.config.slurm.enabled) {
|
|
270
256
|
lines.push('');
|
|
271
257
|
lines.push('### SLURM Integration');
|
|
272
258
|
lines.push('- SLURM commands (srun, sbatch, squeue, scancel) are available.');
|
|
273
259
|
lines.push('- LabGate tracks your SLURM jobs automatically via squeue polling.');
|
|
274
|
-
if (session.config.slurm.mcp_server && hasMcp) {
|
|
260
|
+
if (slurmPluginEnabled && session.config.slurm.mcp_server && hasMcp) {
|
|
275
261
|
lines.push('- Use the `labgate-cluster` MCP tools to inspect partitions, queue pressure, and available modules.');
|
|
276
262
|
lines.push('- Available tools: `get_cluster_partitions`, `get_partition_limits`, `get_queue_pressure`, `find_module`');
|
|
277
263
|
lines.push('- Use the `labgate-slurm` MCP tools to check job status and read output files.');
|
|
@@ -335,172 +321,320 @@ function serializeCodexMcpToml(servers) {
|
|
|
335
321
|
}
|
|
336
322
|
return lines.join('\n');
|
|
337
323
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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);
|
|
342
333
|
try {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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)) {
|
|
350
433
|
try {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
}
|
|
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);
|
|
354
439
|
}
|
|
355
|
-
catch {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
};
|
|
362
|
-
const copyMcpNodePackage = (pkgName) => {
|
|
363
|
-
const sourceDir = (0, path_1.resolve)(__dirname, '../../node_modules', pkgName);
|
|
364
|
-
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))
|
|
365
446
|
return false;
|
|
366
|
-
|
|
367
|
-
|
|
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);
|
|
368
478
|
return true;
|
|
369
|
-
};
|
|
370
|
-
const stageSlurmMcpRuntimeDeps = () => {
|
|
371
|
-
(0, config_js_1.ensurePrivateDir)((0, path_1.join)(mcpDir, 'node_modules'));
|
|
372
|
-
// better-sqlite3 resolves through bindings -> file-uri-to-path at runtime.
|
|
373
|
-
return (copyMcpNodePackage('better-sqlite3') &&
|
|
374
|
-
copyMcpNodePackage('bindings') &&
|
|
375
|
-
copyMcpNodePackage('file-uri-to-path'));
|
|
376
|
-
};
|
|
377
|
-
// Dataset MCP server (always)
|
|
378
|
-
const datasetBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/dataset-mcp.bundle.mjs');
|
|
379
|
-
if ((0, fs_1.existsSync)(datasetBundleSrc)) {
|
|
380
|
-
(0, fs_1.copyFileSync)(datasetBundleSrc, (0, path_1.join)(mcpDir, 'dataset-mcp.bundle.mjs'));
|
|
381
|
-
mcpConfig.mcpServers['labgate-datasets'] = {
|
|
382
|
-
command: 'node',
|
|
383
|
-
args: ['/home/sandbox/.mcp-servers/dataset-mcp.bundle.mjs'],
|
|
384
|
-
env,
|
|
385
|
-
};
|
|
386
479
|
}
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
command: 'node',
|
|
392
|
-
args: [datasetMcpPath],
|
|
393
|
-
};
|
|
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);
|
|
394
484
|
}
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
(0, fs_1.copyFileSync)(resultsBundleSrc, (0, path_1.join)(mcpDir, 'results-mcp.bundle.mjs'));
|
|
399
|
-
mcpConfig.mcpServers['labgate-results'] = {
|
|
400
|
-
command: 'node',
|
|
401
|
-
args: ['/home/sandbox/.mcp-servers/results-mcp.bundle.mjs', '--db', '/labgate-config/results.json'],
|
|
402
|
-
env,
|
|
403
|
-
};
|
|
485
|
+
catch (err) {
|
|
486
|
+
(0, fs_1.renameSync)(legacyTargetDir, targetDir);
|
|
487
|
+
throw err;
|
|
404
488
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
};
|
|
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 });
|
|
411
495
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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',
|
|
420
526
|
};
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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);
|
|
427
533
|
};
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
+
}
|
|
440
558
|
}
|
|
441
559
|
else {
|
|
442
|
-
|
|
443
|
-
mcpConfig.mcpServers['labgate-cluster'] = {
|
|
444
|
-
command: 'node',
|
|
445
|
-
args: [clusterMcpPath],
|
|
446
|
-
};
|
|
560
|
+
delete mcpConfig.mcpServers['labgate-datasets'];
|
|
447
561
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if ((0, fs_1.existsSync)(slurmBundleSrc)) {
|
|
456
|
-
const staged = options.stageSlurmDeps ? options.stageSlurmDeps() : stageSlurmMcpRuntimeDeps();
|
|
457
|
-
if (staged) {
|
|
458
|
-
(0, fs_1.copyFileSync)(slurmBundleSrc, (0, path_1.join)(mcpDir, 'slurm-mcp.bundle.mjs'));
|
|
459
|
-
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'] = {
|
|
460
569
|
command: 'node',
|
|
461
|
-
args: ['/home/sandbox/.mcp-servers/
|
|
570
|
+
args: ['/home/sandbox/.mcp-servers/cluster-mcp.bundle.mjs'],
|
|
462
571
|
env,
|
|
463
572
|
};
|
|
464
573
|
}
|
|
465
574
|
else {
|
|
466
|
-
|
|
575
|
+
const clusterMcpPath = (0, path_1.resolve)(__dirname, 'cluster-mcp.js');
|
|
576
|
+
mcpConfig.mcpServers['labgate-cluster'] = {
|
|
577
|
+
command: 'node',
|
|
578
|
+
args: [clusterMcpPath],
|
|
579
|
+
};
|
|
467
580
|
}
|
|
468
581
|
}
|
|
469
582
|
else {
|
|
470
|
-
|
|
471
|
-
const dbPath = (0, config_js_1.getSlurmDbPath)();
|
|
472
|
-
mcpConfig.mcpServers['labgate-slurm'] = {
|
|
473
|
-
command: 'node',
|
|
474
|
-
args: [mcpServerPath, '--db', dbPath],
|
|
475
|
-
};
|
|
583
|
+
delete mcpConfig.mcpServers['labgate-cluster'];
|
|
476
584
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
+
};
|
|
494
609
|
}
|
|
495
610
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
+
});
|
|
504
638
|
}
|
|
505
639
|
catch {
|
|
506
640
|
// Best effort — MCP registration failure is non-fatal
|
|
@@ -567,32 +701,92 @@ function cleanStaleSessionFiles() {
|
|
|
567
701
|
// ── SIF image management (Apptainer) ─────────────────────
|
|
568
702
|
/**
|
|
569
703
|
* Convert a container image URI to a local SIF filename.
|
|
570
|
-
*
|
|
704
|
+
* Includes a short hash suffix so distinct image refs cannot collide after sanitization.
|
|
571
705
|
*/
|
|
572
706
|
function imageToSifName(image) {
|
|
573
|
-
|
|
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
|
+
}
|
|
574
729
|
}
|
|
575
730
|
/**
|
|
576
731
|
* Ensure a SIF image exists in the cache directory.
|
|
577
732
|
* Pulls from docker:// URI if not already cached.
|
|
578
733
|
*/
|
|
579
|
-
function ensureSifImage(runtime, image) {
|
|
734
|
+
async function ensureSifImage(runtime, image) {
|
|
580
735
|
const imagesDir = (0, config_js_1.getImagesDir)();
|
|
581
736
|
(0, fs_1.mkdirSync)(imagesDir, { recursive: true });
|
|
582
737
|
const sifPath = (0, path_1.join)(imagesDir, imageToSifName(image));
|
|
583
|
-
|
|
738
|
+
const pullLockPath = `${sifPath}.pull.lock`;
|
|
739
|
+
if (isUsableApptainerSif(runtime, sifPath) && !(0, fs_1.existsSync)(pullLockPath)) {
|
|
584
740
|
return sifPath;
|
|
585
741
|
}
|
|
586
|
-
log.info(`Pulling image ${log.dim(image)}`);
|
|
587
742
|
try {
|
|
588
|
-
(0,
|
|
589
|
-
|
|
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
|
+
},
|
|
590
784
|
});
|
|
591
785
|
}
|
|
592
786
|
catch (err) {
|
|
593
787
|
const msg = err?.stderr?.toString?.() ?? err?.message ?? String(err);
|
|
594
788
|
log.error(`Failed to pull SIF image "${image}" with ${runtime}.`);
|
|
595
|
-
console.error(msg.trim().slice(0, 500));
|
|
789
|
+
console.error(String(msg).trim().slice(0, 500));
|
|
596
790
|
process.exit(1);
|
|
597
791
|
}
|
|
598
792
|
return sifPath;
|
|
@@ -681,6 +875,21 @@ function getMountRoots(session) {
|
|
|
681
875
|
}),
|
|
682
876
|
];
|
|
683
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
|
+
}
|
|
684
893
|
// ── SLURM host proxy (container wrappers -> host execution) ────────
|
|
685
894
|
const SLURM_PROXY_COMMANDS = new Set([
|
|
686
895
|
'srun',
|
|
@@ -692,31 +901,122 @@ const SLURM_PROXY_COMMANDS = new Set([
|
|
|
692
901
|
'scontrol',
|
|
693
902
|
]);
|
|
694
903
|
const SLURM_PROXY_SOCKET_IN_CONTAINER = '/home/sandbox/.labgate/slurm/host-proxy/slurm.sock';
|
|
695
|
-
|
|
696
|
-
|
|
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);
|
|
697
945
|
for (const mount of mounts) {
|
|
698
|
-
if (
|
|
946
|
+
if (normalizedPath === mount.container)
|
|
699
947
|
return mount.host;
|
|
700
948
|
const prefix = `${mount.container}/`;
|
|
701
|
-
if (
|
|
702
|
-
return (0, path_1.join)(mount.host,
|
|
949
|
+
if (normalizedPath.startsWith(prefix)) {
|
|
950
|
+
return (0, path_1.join)(mount.host, normalizedPath.slice(prefix.length));
|
|
703
951
|
}
|
|
704
952
|
}
|
|
705
|
-
return
|
|
953
|
+
return null;
|
|
706
954
|
}
|
|
707
|
-
function
|
|
708
|
-
|
|
709
|
-
|
|
955
|
+
function mapSlurmProxyArgToHostPath(session, arg, containerCwd) {
|
|
956
|
+
const trimmed = String(arg || '').trim();
|
|
957
|
+
if (!trimmed) {
|
|
958
|
+
return { ok: true, arg };
|
|
710
959
|
}
|
|
711
960
|
const eq = arg.indexOf('=');
|
|
712
961
|
if (eq > 0) {
|
|
713
|
-
const key = arg.slice(0, eq
|
|
962
|
+
const key = arg.slice(0, eq);
|
|
714
963
|
const value = arg.slice(eq + 1);
|
|
715
|
-
|
|
716
|
-
|
|
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)) };
|
|
717
986
|
}
|
|
987
|
+
return { ok: true, arg: mapped };
|
|
718
988
|
}
|
|
719
|
-
|
|
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) };
|
|
994
|
+
}
|
|
995
|
+
return { ok: true, arg: mapped };
|
|
996
|
+
}
|
|
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 };
|
|
720
1020
|
}
|
|
721
1021
|
function startSlurmHostProxy(session) {
|
|
722
1022
|
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
@@ -775,11 +1075,29 @@ function startSlurmHostProxy(session) {
|
|
|
775
1075
|
return;
|
|
776
1076
|
}
|
|
777
1077
|
const args = Array.isArray(req.args) ? req.args.map(v => String(v)) : [];
|
|
778
|
-
const mappedArgs = args.map(arg => mapProxyArgToHostPath(session, arg));
|
|
779
1078
|
const requestedCwd = String(req.cwd || '').trim();
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
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;
|
|
783
1101
|
const stdoutChunks = [];
|
|
784
1102
|
const stderrChunks = [];
|
|
785
1103
|
const child = (0, child_process_1.spawn)(cmd, mappedArgs, {
|
|
@@ -959,6 +1277,7 @@ const CODEX_OAUTH_STARTUP_HEARTBEAT_MS = 10_000;
|
|
|
959
1277
|
const CODEX_OAUTH_STARTUP_HEARTBEAT_MAX_MS = 50_000;
|
|
960
1278
|
const CODEX_OAUTH_STARTUP_SPINNER_MS = 125;
|
|
961
1279
|
const CODEX_OAUTH_STARTUP_SPINNER_FRAMES = ['|', '/', '-', '\\'];
|
|
1280
|
+
const DEFERRED_SLURM_PASSTHROUGH_DELAY_MS = 1_500;
|
|
962
1281
|
function getCodexOauthCallbackPort() {
|
|
963
1282
|
const raw = (process.env.LABGATE_CODEX_OAUTH_CALLBACK_PORT || '').trim();
|
|
964
1283
|
if (!raw)
|
|
@@ -1053,6 +1372,7 @@ function startCodexOauthStartupHeartbeat() {
|
|
|
1053
1372
|
// ── Build Apptainer arguments ─────────────────────────────
|
|
1054
1373
|
function buildApptainerArgs(session, sifPath, sessionId, tokenEnv = []) {
|
|
1055
1374
|
const { agent, workdir, config } = session;
|
|
1375
|
+
const ensureCommands = session.config.commands.ensure_commands;
|
|
1056
1376
|
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
1057
1377
|
const { blockedMounts, emptyDir, envArgs } = prepareCommonArgs(session, sessionId, tokenEnv);
|
|
1058
1378
|
const resultsDbPath = ensureResultsDbPathForContainer();
|
|
@@ -1110,7 +1430,7 @@ function buildApptainerArgs(session, sifPath, sessionId, tokenEnv = []) {
|
|
|
1110
1430
|
// ── SIF image ──
|
|
1111
1431
|
sifPath,
|
|
1112
1432
|
// ── Entrypoint ──
|
|
1113
|
-
'bash', '-lc', buildEntrypoint(agent),
|
|
1433
|
+
'bash', '-lc', buildEntrypoint(agent, { ensureCommands }),
|
|
1114
1434
|
];
|
|
1115
1435
|
}
|
|
1116
1436
|
function getPodmanContainerName(sessionId) {
|
|
@@ -1118,6 +1438,7 @@ function getPodmanContainerName(sessionId) {
|
|
|
1118
1438
|
}
|
|
1119
1439
|
function buildPodmanArgs(session, image, sessionId, tokenEnv = [], options = { tty: false }) {
|
|
1120
1440
|
const { agent, workdir, config } = session;
|
|
1441
|
+
const ensureCommands = session.config.commands.ensure_commands;
|
|
1121
1442
|
const disableClaudeStatusLineForPodmanDarwin = agent === 'claude' && (0, os_1.platform)() === 'darwin';
|
|
1122
1443
|
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
1123
1444
|
const { blockedMounts, emptyDir, envArgs } = prepareCommonArgs(session, sessionId, tokenEnv);
|
|
@@ -1177,7 +1498,10 @@ function buildPodmanArgs(session, image, sessionId, tokenEnv = [], options = { t
|
|
|
1177
1498
|
...envArgs,
|
|
1178
1499
|
// ── Image + entrypoint ──
|
|
1179
1500
|
image,
|
|
1180
|
-
'bash', '-lc', buildEntrypoint(agent, {
|
|
1501
|
+
'bash', '-lc', buildEntrypoint(agent, {
|
|
1502
|
+
disableClaudeStatusLine: disableClaudeStatusLineForPodmanDarwin,
|
|
1503
|
+
ensureCommands,
|
|
1504
|
+
}),
|
|
1181
1505
|
];
|
|
1182
1506
|
}
|
|
1183
1507
|
// ── Entrypoint script (inline) ───────────────────────────
|
|
@@ -1204,6 +1528,10 @@ function buildEntrypoint(agent, options = {}) {
|
|
|
1204
1528
|
|| disableClaudeStatusLineFlag === 'yes'
|
|
1205
1529
|
|| disableClaudeStatusLineFlag === 'on');
|
|
1206
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))));
|
|
1207
1535
|
const lines = [
|
|
1208
1536
|
'set -euo pipefail',
|
|
1209
1537
|
'export HOME=/home/sandbox',
|
|
@@ -1291,9 +1619,13 @@ function buildEntrypoint(agent, options = {}) {
|
|
|
1291
1619
|
];
|
|
1292
1620
|
const preLaunchLines = [];
|
|
1293
1621
|
if (agent === 'claude') {
|
|
1294
|
-
|
|
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
|
+
}
|
|
1295
1625
|
if (disableClaudeStatusLine) {
|
|
1296
|
-
|
|
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));', '\'', '');
|
|
1297
1629
|
}
|
|
1298
1630
|
else {
|
|
1299
1631
|
// Configure a LabGate status line in Claude Code with a dashboard URL.
|
|
@@ -1352,13 +1684,25 @@ let lastHandledOAuthAt = 0;
|
|
|
1352
1684
|
const CLAUDE_OAUTH_MARKER = 'https://claude.ai/oauth/authorize?';
|
|
1353
1685
|
const CLAUDE_MANUAL_CODE_REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';
|
|
1354
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;
|
|
1355
1688
|
function stripTerminalControlForOAuth(text) {
|
|
1356
|
-
|
|
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
|
|
1357
1698
|
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
|
|
1358
1699
|
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
1359
1700
|
.replace(/\x1b[P^_][\s\S]*?\x1b\\/g, '')
|
|
1360
1701
|
.replace(/\x1b[@-_]/g, '')
|
|
1361
1702
|
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
|
|
1703
|
+
if (osc8Urls.length)
|
|
1704
|
+
stripped += `\n${osc8Urls.join('\n')}`;
|
|
1705
|
+
return stripped;
|
|
1362
1706
|
}
|
|
1363
1707
|
function canonicalizeClaudeOAuthRedirectUri(parsed) {
|
|
1364
1708
|
const rawRedirectUri = (parsed.searchParams.get('redirect_uri') || '').trim();
|
|
@@ -1375,45 +1719,84 @@ function canonicalizeClaudeOAuthRedirectUri(parsed) {
|
|
|
1375
1719
|
// Keep original redirect URI if it cannot be parsed.
|
|
1376
1720
|
}
|
|
1377
1721
|
}
|
|
1378
|
-
function
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
const
|
|
1394
|
-
|
|
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)
|
|
1395
1749
|
return null;
|
|
1396
1750
|
try {
|
|
1397
|
-
const parsed = new URL(
|
|
1398
|
-
if (parsed.protocol !== 'https:'
|
|
1751
|
+
const parsed = new URL(String(candidate || '').trim());
|
|
1752
|
+
if (parsed.protocol !== 'https:')
|
|
1399
1753
|
return null;
|
|
1400
|
-
|
|
1401
|
-
const required = ['client_id', 'state', 'response_type', 'redirect_uri', 'scope', 'code_challenge', 'code_challenge_method'];
|
|
1402
|
-
for (const key of required) {
|
|
1403
|
-
if (!parsed.searchParams.get(key))
|
|
1404
|
-
return null;
|
|
1405
|
-
}
|
|
1406
|
-
if ((parsed.searchParams.get('response_type') || '').trim().toLowerCase() !== 'code')
|
|
1754
|
+
if (!isClaudeAuthHost(parsed.hostname))
|
|
1407
1755
|
return null;
|
|
1408
|
-
if ((parsed.
|
|
1756
|
+
if (!isLikelyClaudeAuthPath(parsed.pathname))
|
|
1409
1757
|
return null;
|
|
1410
|
-
|
|
1758
|
+
if (parsed.hostname.toLowerCase() === 'claude.ai' && parsed.pathname === '/oauth/authorize') {
|
|
1759
|
+
if (!hasValidClaudeAuthorizeParams(parsed))
|
|
1760
|
+
return null;
|
|
1761
|
+
canonicalizeClaudeOAuthRedirectUri(parsed);
|
|
1762
|
+
}
|
|
1411
1763
|
return parsed.toString();
|
|
1412
1764
|
}
|
|
1413
1765
|
catch {
|
|
1414
1766
|
return null;
|
|
1415
1767
|
}
|
|
1416
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
|
+
}
|
|
1417
1800
|
function handleOAuthUrl(url, options) {
|
|
1418
1801
|
const normalizedUrl = normalizeClaudeOAuthUrl(url);
|
|
1419
1802
|
if (!normalizedUrl)
|
|
@@ -1506,8 +1889,11 @@ function setupBrowserHook(options = {}) {
|
|
|
1506
1889
|
' esac',
|
|
1507
1890
|
'done',
|
|
1508
1891
|
'[ -n "$url" ] || url="${1:-}"',
|
|
1509
|
-
'
|
|
1510
|
-
'
|
|
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',
|
|
1511
1897
|
'exit 0',
|
|
1512
1898
|
'',
|
|
1513
1899
|
].join('\n'), { mode: 0o755 });
|
|
@@ -1613,49 +1999,18 @@ function renderStickyFooter(line) {
|
|
|
1613
1999
|
/**
|
|
1614
2000
|
* Resolve agent credentials. Checks in order:
|
|
1615
2001
|
* 1. Explicit --api-key flag (passed via apiKey parameter)
|
|
1616
|
-
* 2.
|
|
1617
|
-
* 3. ANTHROPIC_API_KEY in host environment
|
|
2002
|
+
* 2. ANTHROPIC_API_KEY in host environment
|
|
1618
2003
|
*
|
|
1619
2004
|
* Returns env args to inject into the container.
|
|
1620
2005
|
*/
|
|
1621
2006
|
function getAgentTokenEnv(agent, apiKey, deps = {}) {
|
|
1622
2007
|
const env = deps.env ?? process.env;
|
|
1623
|
-
const platformFn = deps.platformFn ?? os_1.platform;
|
|
1624
|
-
const execSync = deps.execFileSyncFn ?? child_process_1.execFileSync;
|
|
1625
|
-
const ensureDir = deps.mkdirSyncFn ?? fs_1.mkdirSync;
|
|
1626
|
-
const writeFile = deps.writeFileSyncFn ?? fs_1.writeFileSync;
|
|
1627
2008
|
// 1. Explicit API key from CLI flag
|
|
1628
2009
|
if (apiKey) {
|
|
1629
2010
|
log.success('Using API key from --api-key flag');
|
|
1630
2011
|
return ['--env', `ANTHROPIC_API_KEY=${apiKey}`];
|
|
1631
2012
|
}
|
|
1632
|
-
// 2.
|
|
1633
|
-
// Sync credentials file into sandbox home, but do not export ANTHROPIC_API_KEY.
|
|
1634
|
-
// Claude may prompt interactively when a custom ANTHROPIC_API_KEY is present,
|
|
1635
|
-
// which can stall UI launches.
|
|
1636
|
-
if (platformFn() === 'darwin' && agent === 'claude') {
|
|
1637
|
-
try {
|
|
1638
|
-
const raw = execSync('security', [
|
|
1639
|
-
'find-generic-password',
|
|
1640
|
-
'-s', 'Claude Code-credentials',
|
|
1641
|
-
'-w',
|
|
1642
|
-
], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
1643
|
-
const creds = JSON.parse(raw);
|
|
1644
|
-
const token = creds.claudeAiOauth?.accessToken;
|
|
1645
|
-
if (token) {
|
|
1646
|
-
const sandboxHome = deps.sandboxHome ?? (0, config_js_1.getSandboxHome)();
|
|
1647
|
-
const claudeDir = (0, path_1.join)(sandboxHome, '.claude');
|
|
1648
|
-
ensureDir(claudeDir, { recursive: true });
|
|
1649
|
-
writeFile((0, path_1.join)(claudeDir, '.credentials.json'), raw, { mode: 0o600 });
|
|
1650
|
-
log.success('Synced credentials from macOS keychain');
|
|
1651
|
-
return [];
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
catch {
|
|
1655
|
-
// Fall through to host env var fallback.
|
|
1656
|
-
}
|
|
1657
|
-
}
|
|
1658
|
-
// 3. Forward ANTHROPIC_API_KEY from host environment
|
|
2013
|
+
// 2. Forward ANTHROPIC_API_KEY from host environment
|
|
1659
2014
|
if (env.ANTHROPIC_API_KEY) {
|
|
1660
2015
|
log.success('Forwarding ANTHROPIC_API_KEY from environment');
|
|
1661
2016
|
return ['--env', `ANTHROPIC_API_KEY=${env.ANTHROPIC_API_KEY}`];
|
|
@@ -1669,6 +2024,22 @@ function formatStatusFooter(session, runtime, sessionId, image) {
|
|
|
1669
2024
|
const net = session.config.network.mode;
|
|
1670
2025
|
return `labgate | ${sessionId} | ${runtime} | net=${net} | timeout=${timeoutLabel} | audit=${audit}`;
|
|
1671
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
|
+
}
|
|
1672
2043
|
// ── Shared session helpers ─────────────────────────────────
|
|
1673
2044
|
function logSessionStart(session, sessionId) {
|
|
1674
2045
|
if (!session.config.audit.enabled)
|
|
@@ -1764,13 +2135,18 @@ function printSessionInfo(session, sessionId, runtime) {
|
|
|
1764
2135
|
}
|
|
1765
2136
|
// ── Session lifecycle ─────────────────────────────────────
|
|
1766
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
|
+
};
|
|
1767
2143
|
const preferred = session.config.runtime;
|
|
1768
2144
|
const runtime = session.dryRun ? getDryRunRuntime(preferred) : (0, runtime_js_1.getRuntime)(preferred);
|
|
1769
2145
|
const image = session.imageOverride ?? session.config.image;
|
|
1770
2146
|
const sessionId = (0, crypto_1.randomBytes)(4).toString('hex');
|
|
1771
2147
|
const footerMode = session.footerMode ?? 'sticky';
|
|
1772
2148
|
const footerLine = formatStatusFooter(session, runtime, sessionId, image);
|
|
1773
|
-
// Extract agent auth token (CLI flag → env var
|
|
2149
|
+
// Extract agent auth token (CLI flag → env var)
|
|
1774
2150
|
const tokenEnv = session.dryRun ? [] : getAgentTokenEnv(session.agent, session.apiKey);
|
|
1775
2151
|
const bridgeCodexOauthForPodman = runtime === 'podman' && shouldBridgeCodexOauthForPodman(session.agent, session.config.network.mode);
|
|
1776
2152
|
const needsCodexOauthStartupHeartbeat = bridgeCodexOauthForPodman && tokenEnv.length === 0;
|
|
@@ -1803,6 +2179,8 @@ async function startSession(session) {
|
|
|
1803
2179
|
...(browserHook?.env ?? []),
|
|
1804
2180
|
];
|
|
1805
2181
|
let cleanupSlurmHostProxy = () => { };
|
|
2182
|
+
let cleanupDeferredSlurmPassthrough = () => { };
|
|
2183
|
+
let startDeferredSlurmPassthrough = () => { };
|
|
1806
2184
|
// If the agent isn't installed in the persistent sandbox home yet, warn that first run can be slow.
|
|
1807
2185
|
if (!session.dryRun) {
|
|
1808
2186
|
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
@@ -1819,31 +2197,113 @@ async function startSession(session) {
|
|
|
1819
2197
|
// can work out of the box when present on the host. Full SLURM tracking/MCP
|
|
1820
2198
|
// remains controlled by slurm.enabled.
|
|
1821
2199
|
if (!session.dryRun && runtime === 'apptainer') {
|
|
2200
|
+
const hostProxyStartedAt = Date.now();
|
|
1822
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
|
+
};
|
|
1823
2244
|
if (hostProxy) {
|
|
1824
2245
|
cleanupSlurmHostProxy = hostProxy.cleanup;
|
|
1825
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
|
+
}
|
|
1826
2276
|
}
|
|
1827
2277
|
else {
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
const staged = (0, slurm_cli_passthrough_js_1.stageSlurmCliPassthrough)({
|
|
1832
|
-
sandboxHome: (0, config_js_1.getSandboxHome)(),
|
|
1833
|
-
env: process.env,
|
|
1834
|
-
installMissingWrappers: trackingEnabled || !!hostProxy,
|
|
1835
|
-
hostProxySocketInContainer: hostProxy?.socketContainerPath ?? null,
|
|
1836
|
-
});
|
|
1837
|
-
if (trackingEnabled) {
|
|
1838
|
-
if (!staged.ok) {
|
|
1839
|
-
log.warn('SLURM is enabled but no SLURM commands were found on the host PATH. ' +
|
|
1840
|
-
'Inside-sandbox SLURM commands (squeue/sbatch/...) will be unavailable.');
|
|
1841
|
-
}
|
|
1842
|
-
else if (staged.errors.length > 0) {
|
|
1843
|
-
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');
|
|
1844
2281
|
}
|
|
1845
2282
|
else {
|
|
1846
|
-
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
|
+
};
|
|
1847
2307
|
}
|
|
1848
2308
|
}
|
|
1849
2309
|
}
|
|
@@ -1854,7 +2314,7 @@ async function startSession(session) {
|
|
|
1854
2314
|
sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), imageToSifName(image));
|
|
1855
2315
|
}
|
|
1856
2316
|
else {
|
|
1857
|
-
sifPath = ensureSifImage(runtime, image);
|
|
2317
|
+
sifPath = await ensureSifImage(runtime, image);
|
|
1858
2318
|
}
|
|
1859
2319
|
args = buildApptainerArgs(session, sifPath, sessionId, runtimeEnvArgs);
|
|
1860
2320
|
}
|
|
@@ -1917,7 +2377,10 @@ async function startSession(session) {
|
|
|
1917
2377
|
}
|
|
1918
2378
|
}
|
|
1919
2379
|
// Register MCP servers for Claude Code (bundled for in-container use)
|
|
2380
|
+
const prepareMcpStartedAt = Date.now();
|
|
1920
2381
|
prepareMcpServers(session);
|
|
2382
|
+
recordStartupTiming('prepare_mcp_servers', prepareMcpStartedAt);
|
|
2383
|
+
logStartupTimings(startupTimings, Date.now() - startupStartedAt);
|
|
1921
2384
|
logSessionStart(session, sessionId);
|
|
1922
2385
|
printSessionInfo(session, sessionId, runtime);
|
|
1923
2386
|
if (footerMode === 'once') {
|
|
@@ -2017,6 +2480,7 @@ async function startSession(session) {
|
|
|
2017
2480
|
clearTimeout(timeoutHandle);
|
|
2018
2481
|
browserHook?.cleanup();
|
|
2019
2482
|
cleanupSlurm();
|
|
2483
|
+
cleanupDeferredSlurmPassthrough();
|
|
2020
2484
|
cleanupSlurmHostProxy();
|
|
2021
2485
|
removeSessionFile(sessionId, session.sharedSessionsDir);
|
|
2022
2486
|
cleanupLabgateInstruction();
|
|
@@ -2030,6 +2494,7 @@ async function startSession(session) {
|
|
|
2030
2494
|
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
2031
2495
|
process.on(sig, () => child.kill(sig));
|
|
2032
2496
|
}
|
|
2497
|
+
startDeferredSlurmPassthrough();
|
|
2033
2498
|
return;
|
|
2034
2499
|
}
|
|
2035
2500
|
}
|
|
@@ -2048,12 +2513,14 @@ async function startSession(session) {
|
|
|
2048
2513
|
// OAuth interception relies on the BROWSER hook + file watcher here
|
|
2049
2514
|
// (the PTY path above uses onData for direct interception).
|
|
2050
2515
|
const child = (0, child_process_1.spawn)(runtime, args, { stdio: 'inherit' });
|
|
2516
|
+
startDeferredSlurmPassthrough();
|
|
2051
2517
|
const timeoutHandle = setupSessionTimeout(session, sessionId, runtime, () => child.exitCode !== null, () => child.kill('SIGTERM'));
|
|
2052
2518
|
child.on('close', (code) => {
|
|
2053
2519
|
if (timeoutHandle)
|
|
2054
2520
|
clearTimeout(timeoutHandle);
|
|
2055
2521
|
browserHook?.cleanup();
|
|
2056
2522
|
cleanupSlurm();
|
|
2523
|
+
cleanupDeferredSlurmPassthrough();
|
|
2057
2524
|
cleanupSlurmHostProxy();
|
|
2058
2525
|
removeSessionFile(sessionId, session.sharedSessionsDir);
|
|
2059
2526
|
cleanupLabgateInstruction();
|