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.
Files changed (47) hide show
  1. package/README.md +132 -248
  2. package/dist/cli.js +9 -33
  3. package/dist/cli.js.map +1 -1
  4. package/dist/lib/config.d.ts +19 -3
  5. package/dist/lib/config.js +154 -75
  6. package/dist/lib/config.js.map +1 -1
  7. package/dist/lib/container.d.ts +11 -9
  8. package/dist/lib/container.js +749 -282
  9. package/dist/lib/container.js.map +1 -1
  10. package/dist/lib/dataset-mcp.js +2 -9
  11. package/dist/lib/dataset-mcp.js.map +1 -1
  12. package/dist/lib/display-mcp.d.ts +2 -2
  13. package/dist/lib/display-mcp.js +17 -38
  14. package/dist/lib/display-mcp.js.map +1 -1
  15. package/dist/lib/doctor.js +8 -0
  16. package/dist/lib/doctor.js.map +1 -1
  17. package/dist/lib/explorer-claude.js +36 -1
  18. package/dist/lib/explorer-claude.js.map +1 -1
  19. package/dist/lib/explorer-eval.js +3 -2
  20. package/dist/lib/explorer-eval.js.map +1 -1
  21. package/dist/lib/image-pull-lock.d.ts +18 -0
  22. package/dist/lib/image-pull-lock.js +167 -0
  23. package/dist/lib/image-pull-lock.js.map +1 -0
  24. package/dist/lib/init.js +22 -19
  25. package/dist/lib/init.js.map +1 -1
  26. package/dist/lib/slurm-cli-passthrough.d.ts +12 -2
  27. package/dist/lib/slurm-cli-passthrough.js +401 -143
  28. package/dist/lib/slurm-cli-passthrough.js.map +1 -1
  29. package/dist/lib/startup-stage-lock.d.ts +21 -0
  30. package/dist/lib/startup-stage-lock.js +196 -0
  31. package/dist/lib/startup-stage-lock.js.map +1 -0
  32. package/dist/lib/ui.d.ts +40 -0
  33. package/dist/lib/ui.html +4953 -3366
  34. package/dist/lib/ui.js +1771 -297
  35. package/dist/lib/ui.js.map +1 -1
  36. package/dist/lib/web-terminal-startup-readiness.d.ts +8 -0
  37. package/dist/lib/web-terminal-startup-readiness.js +29 -0
  38. package/dist/lib/web-terminal-startup-readiness.js.map +1 -0
  39. package/dist/lib/web-terminal.d.ts +51 -0
  40. package/dist/lib/web-terminal.js +171 -1
  41. package/dist/lib/web-terminal.js.map +1 -1
  42. package/dist/mcp-bundles/dataset-mcp.bundle.mjs +144 -93
  43. package/dist/mcp-bundles/display-mcp.bundle.mjs +35 -43
  44. package/dist/mcp-bundles/explorer-mcp.bundle.mjs +263 -146
  45. package/dist/mcp-bundles/results-mcp.bundle.mjs +39 -41
  46. package/dist/mcp-bundles/slurm-mcp.bundle.mjs +19 -21
  47. package/package.json +1 -1
@@ -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
- lines.push('### Dataset Management');
241
- lines.push('- Use the `labgate-datasets` MCP tools to manage and explore datasets.');
242
- lines.push('- Available tools: `list_datasets`, `inspect_dataset`, `search_dataset`, `get_dataset_summary`, `read_dataset_file`, `validate_dataset`, `register_dataset`, `update_dataset`, `unregister_dataset`');
243
- lines.push('- Use `register_dataset` to add new host directories as named datasets.');
244
- lines.push('');
245
- lines.push('### Results Registry');
246
- lines.push('- Use the `labgate-results` MCP tools to record and revisit important outcomes.');
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
- function prepareMcpServers(session, options = {}) {
339
- const agentLower = session.agent.toLowerCase();
340
- if (agentLower !== 'claude' && agentLower !== 'codex')
341
- return;
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
- const sandboxHome = (0, config_js_1.getSandboxHome)();
344
- const mcpDir = (0, path_1.join)(sandboxHome, '.mcp-servers');
345
- (0, fs_1.mkdirSync)(mcpDir, { recursive: true });
346
- // Claude Code reads MCP config from ~/.claude.json (in HOME root).
347
- // For Codex we build the same mcpConfig object, then serialize to TOML below.
348
- const mcpConfigPath = (0, path_1.join)(sandboxHome, '.claude.json');
349
- let mcpConfig = {};
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
- if ((0, fs_1.existsSync)(mcpConfigPath)) {
352
- mcpConfig = JSON.parse((0, fs_1.readFileSync)(mcpConfigPath, 'utf-8'));
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 { /* start fresh */ }
356
- if (!mcpConfig.mcpServers)
357
- mcpConfig.mcpServers = {};
358
- const env = {
359
- LABGATE_CONFIG_PATH: '/labgate-config/config.json',
360
- LABGATE_CONTAINER_MODE: '1',
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
- const targetDir = (0, path_1.join)(mcpDir, 'node_modules', pkgName);
367
- (0, fs_1.cpSync)(sourceDir, targetDir, { recursive: true, force: true });
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
- else {
388
- // Fallback: host path (development without bundles)
389
- const datasetMcpPath = (0, path_1.resolve)(__dirname, 'dataset-mcp.js');
390
- mcpConfig.mcpServers['labgate-datasets'] = {
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
- // Results MCP server (always)
396
- const resultsBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/results-mcp.bundle.mjs');
397
- if ((0, fs_1.existsSync)(resultsBundleSrc)) {
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
- else {
406
- const resultsMcpPath = (0, path_1.resolve)(__dirname, 'results-mcp.js');
407
- mcpConfig.mcpServers['labgate-results'] = {
408
- command: 'node',
409
- args: [resultsMcpPath, '--db', (0, config_js_1.getResultsDbPath)()],
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
- // Display MCP server (always — renders rich widgets in LabGate UI)
413
- const displayBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/display-mcp.bundle.mjs');
414
- if ((0, fs_1.existsSync)(displayBundleSrc)) {
415
- (0, fs_1.copyFileSync)(displayBundleSrc, (0, path_1.join)(mcpDir, 'display-mcp.bundle.mjs'));
416
- mcpConfig.mcpServers['labgate-display'] = {
417
- command: 'node',
418
- args: ['/home/sandbox/.mcp-servers/display-mcp.bundle.mjs', '--db', '/labgate-config/display.json'],
419
- env,
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
- else {
423
- const displayMcpPath = (0, path_1.resolve)(__dirname, 'display-mcp.js');
424
- mcpConfig.mcpServers['labgate-display'] = {
425
- command: 'node',
426
- args: [displayMcpPath, '--db', (0, config_js_1.getDisplayDbPath)()],
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
- // Cluster MCP server (when SLURM integration is enabled and plugin active)
430
- const slurmPluginEnabled = session.config.plugins?.slurm !== false;
431
- if (session.config.slurm.enabled && session.config.slurm.mcp_server && slurmPluginEnabled) {
432
- const clusterBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/cluster-mcp.bundle.mjs');
433
- if ((0, fs_1.existsSync)(clusterBundleSrc)) {
434
- (0, fs_1.copyFileSync)(clusterBundleSrc, (0, path_1.join)(mcpDir, 'cluster-mcp.bundle.mjs'));
435
- mcpConfig.mcpServers['labgate-cluster'] = {
436
- command: 'node',
437
- args: ['/home/sandbox/.mcp-servers/cluster-mcp.bundle.mjs'],
438
- env,
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
- const clusterMcpPath = (0, path_1.resolve)(__dirname, 'cluster-mcp.js');
443
- mcpConfig.mcpServers['labgate-cluster'] = {
444
- command: 'node',
445
- args: [clusterMcpPath],
446
- };
560
+ delete mcpConfig.mcpServers['labgate-datasets'];
447
561
  }
448
- }
449
- else {
450
- delete mcpConfig.mcpServers['labgate-cluster'];
451
- }
452
- // SLURM MCP server (when enabled and plugin active)
453
- if (session.config.slurm.enabled && session.config.slurm.mcp_server && slurmPluginEnabled) {
454
- const slurmBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/slurm-mcp.bundle.mjs');
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/slurm-mcp.bundle.mjs', '--db', '/labgate-config/slurm.db'],
570
+ args: ['/home/sandbox/.mcp-servers/cluster-mcp.bundle.mjs'],
462
571
  env,
463
572
  };
464
573
  }
465
574
  else {
466
- delete mcpConfig.mcpServers['labgate-slurm'];
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
- const mcpServerPath = (0, path_1.resolve)(__dirname, 'slurm-mcp.js');
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
- else {
479
- delete mcpConfig.mcpServers['labgate-slurm'];
480
- }
481
- if (agentLower === 'claude') {
482
- (0, fs_1.writeFileSync)(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
483
- (0, config_js_1.ensurePrivateFile)(mcpConfigPath);
484
- }
485
- if (agentLower === 'codex') {
486
- // Codex reads MCP config from ~/.codex/config.toml
487
- const codexDir = (0, path_1.join)(sandboxHome, '.codex');
488
- (0, fs_1.mkdirSync)(codexDir, { recursive: true });
489
- const codexConfigPath = (0, path_1.join)(codexDir, 'config.toml');
490
- let existingToml = '';
491
- try {
492
- if ((0, fs_1.existsSync)(codexConfigPath)) {
493
- existingToml = (0, fs_1.readFileSync)(codexConfigPath, 'utf-8');
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
- catch { /* start fresh */ }
497
- // Strip old [mcp_servers.*] sections and append fresh ones
498
- const cleaned = stripTomlMcpSections(existingToml);
499
- const mcpToml = serializeCodexMcpToml(mcpConfig.mcpServers);
500
- const separator = cleaned.length > 0 && !cleaned.endsWith('\n') ? '\n\n' : cleaned.length > 0 ? '\n' : '';
501
- (0, fs_1.writeFileSync)(codexConfigPath, cleaned + separator + mcpToml, 'utf-8');
502
- (0, config_js_1.ensurePrivateFile)(codexConfigPath);
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
- * e.g. "docker.io/library/ubuntu:22.04" "docker.io_library_ubuntu_22.04.sif"
704
+ * Includes a short hash suffix so distinct image refs cannot collide after sanitization.
571
705
  */
572
706
  function imageToSifName(image) {
573
- return image.replace(/[/:]/g, '_') + '.sif';
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
- if ((0, fs_1.existsSync)(sifPath)) {
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, child_process_1.execFileSync)(runtime, ['pull', sifPath, `docker://${image}`], {
589
- stdio: 'inherit',
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
- function mapContainerPathToHost(session, containerPath) {
696
- const mounts = getMountRoots(session).sort((a, b) => b.container.length - a.container.length);
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 (containerPath === mount.container)
946
+ if (normalizedPath === mount.container)
699
947
  return mount.host;
700
948
  const prefix = `${mount.container}/`;
701
- if (containerPath.startsWith(prefix)) {
702
- return (0, path_1.join)(mount.host, containerPath.slice(prefix.length));
949
+ if (normalizedPath.startsWith(prefix)) {
950
+ return (0, path_1.join)(mount.host, normalizedPath.slice(prefix.length));
703
951
  }
704
952
  }
705
- return containerPath;
953
+ return null;
706
954
  }
707
- function mapProxyArgToHostPath(session, arg) {
708
- if (arg.startsWith('/')) {
709
- return mapContainerPathToHost(session, arg);
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 + 1);
962
+ const key = arg.slice(0, eq);
714
963
  const value = arg.slice(eq + 1);
715
- if (value.startsWith('/')) {
716
- return `${key}${mapContainerPathToHost(session, value)}`;
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
- return arg;
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 mappedCwd = requestedCwd
781
- ? mapContainerPathToHost(session, requestedCwd)
782
- : session.workdir;
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, { disableClaudeStatusLine: disableClaudeStatusLineForPodmanDarwin }),
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
- preLaunchLines.push('labgate_ensure_git() {', ' if command -v git >/dev/null 2>&1; then', ' return 0', ' fi', ' if [ "$(id -u)" != "0" ]; then', ' echo "[labgate] Warning: git is missing in this container. Claude marketplace requires git." >&2', ' echo "[labgate] Configure an image with git installed (for example docker.io/library/node:20-bookworm)." >&2', ' return 0', ' fi', ' echo "[labgate] Installing git for Claude marketplace support..."', ' if command -v apt-get >/dev/null 2>&1; then', ' export DEBIAN_FRONTEND=noninteractive', ' apt-get update >/dev/null 2>&1 || return 0', ' apt-get install -y git >/dev/null 2>&1 || return 0', ' elif command -v apk >/dev/null 2>&1; then', ' apk add --no-cache git >/dev/null 2>&1 || return 0', ' elif command -v dnf >/dev/null 2>&1; then', ' dnf install -y git >/dev/null 2>&1 || return 0', ' elif command -v yum >/dev/null 2>&1; then', ' yum install -y git >/dev/null 2>&1 || return 0', ' elif command -v microdnf >/dev/null 2>&1; then', ' microdnf install -y git >/dev/null 2>&1 || return 0', ' fi', ' if ! command -v git >/dev/null 2>&1; then', ' echo "[labgate] Warning: git is still unavailable; Claude marketplace may stay disabled." >&2', ' fi', '}', 'labgate_ensure_git', '');
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
- 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 = {};', ' }', ' // LabGate may disable Claude status lines for specific environments.', ' // Remove both camelCase and lowercase variants for compatibility.', ' delete settings.statusLine;', ' delete settings.statusline;', ' fs.writeFileSync(path, JSON.stringify(settings, null, 2));', '\'', '');
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
- return String(text || '')
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 normalizeClaudeOAuthUrl(raw) {
1379
- const stripped = stripTerminalControlForOAuth(raw);
1380
- const start = stripped.lastIndexOf(CLAUDE_OAUTH_MARKER);
1381
- if (start === -1)
1382
- return null;
1383
- let candidate = stripped.slice(start);
1384
- const endPatterns = [/\n\s*Paste code/i, /\n\s*Browser did(?: not|n't) open\?/i, /\n\s*\n/];
1385
- let end = candidate.length;
1386
- for (const pat of endPatterns) {
1387
- const match = candidate.match(pat);
1388
- if (match && match.index !== undefined && match.index >= 0 && match.index < end) {
1389
- end = match.index;
1390
- }
1391
- }
1392
- candidate = candidate.slice(0, end).replace(/\s+/g, '').trim();
1393
- const urlMatch = candidate.match(/^https:\/\/claude\.ai\/oauth\/authorize\?[A-Za-z0-9\-._~%!$&'()*+,;=:@/?#[\]]+/);
1394
- if (!urlMatch)
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(urlMatch[0]);
1398
- if (parsed.protocol !== 'https:' || parsed.hostname !== 'claude.ai' || parsed.pathname !== '/oauth/authorize') {
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.searchParams.get('code_challenge_method') || '').trim().toUpperCase() !== 'S256')
1756
+ if (!isLikelyClaudeAuthPath(parsed.pathname))
1409
1757
  return null;
1410
- canonicalizeClaudeOAuthRedirectUri(parsed);
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
- 'mkdir -p /home/sandbox/.labgate',
1510
- 'printf \'%s\\n\' "$url" > /home/sandbox/.labgate/browser-url',
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. macOS keychain (Claude Code OAuth tokens, Claude only)
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. macOS keychain extraction (local only, Claude)
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 → macOS keychain)
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
- log.warn('SLURM host proxy unavailable; wrappers will use in-container staged binaries only.');
1829
- }
1830
- const trackingEnabled = session.config.slurm.enabled;
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(`SLURM passthrough staged (${staged.staged.length} commands).`);
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();