labgate 0.5.40 → 0.5.42

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