labgate 0.5.30 → 0.5.32

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 (81) hide show
  1. package/README.md +48 -0
  2. package/dist/cli.js +616 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/lib/config.d.ts +11 -0
  5. package/dist/lib/config.js +44 -0
  6. package/dist/lib/config.js.map +1 -1
  7. package/dist/lib/container.d.ts +22 -3
  8. package/dist/lib/container.js +373 -67
  9. package/dist/lib/container.js.map +1 -1
  10. package/dist/lib/display-mcp.d.ts +10 -0
  11. package/dist/lib/display-mcp.js +160 -0
  12. package/dist/lib/display-mcp.js.map +1 -0
  13. package/dist/lib/display-store.d.ts +24 -0
  14. package/dist/lib/display-store.js +150 -0
  15. package/dist/lib/display-store.js.map +1 -0
  16. package/dist/lib/explorer-autopilot.d.ts +16 -0
  17. package/dist/lib/explorer-autopilot.js +573 -0
  18. package/dist/lib/explorer-autopilot.js.map +1 -0
  19. package/dist/lib/explorer-claude.d.ts +16 -0
  20. package/dist/lib/explorer-claude.js +361 -0
  21. package/dist/lib/explorer-claude.js.map +1 -0
  22. package/dist/lib/explorer-compare.d.ts +9 -0
  23. package/dist/lib/explorer-compare.js +190 -0
  24. package/dist/lib/explorer-compare.js.map +1 -0
  25. package/dist/lib/explorer-eval.d.ts +23 -0
  26. package/dist/lib/explorer-eval.js +161 -0
  27. package/dist/lib/explorer-eval.js.map +1 -0
  28. package/dist/lib/explorer-gc.d.ts +11 -0
  29. package/dist/lib/explorer-gc.js +304 -0
  30. package/dist/lib/explorer-gc.js.map +1 -0
  31. package/dist/lib/explorer-git.d.ts +14 -0
  32. package/dist/lib/explorer-git.js +136 -0
  33. package/dist/lib/explorer-git.js.map +1 -0
  34. package/dist/lib/explorer-lock.d.ts +5 -0
  35. package/dist/lib/explorer-lock.js +100 -0
  36. package/dist/lib/explorer-lock.js.map +1 -0
  37. package/dist/lib/explorer-mcp.d.ts +11 -0
  38. package/dist/lib/explorer-mcp.js +611 -0
  39. package/dist/lib/explorer-mcp.js.map +1 -0
  40. package/dist/lib/explorer-retention.d.ts +4 -0
  41. package/dist/lib/explorer-retention.js +58 -0
  42. package/dist/lib/explorer-retention.js.map +1 -0
  43. package/dist/lib/explorer-store.d.ts +77 -0
  44. package/dist/lib/explorer-store.js +950 -0
  45. package/dist/lib/explorer-store.js.map +1 -0
  46. package/dist/lib/explorer-types.d.ts +161 -0
  47. package/dist/lib/explorer-types.js +3 -0
  48. package/dist/lib/explorer-types.js.map +1 -0
  49. package/dist/lib/explorer.d.ts +31 -0
  50. package/dist/lib/explorer.js +247 -0
  51. package/dist/lib/explorer.js.map +1 -0
  52. package/dist/lib/results-mcp.d.ts +2 -2
  53. package/dist/lib/results-mcp.js +26 -4
  54. package/dist/lib/results-mcp.js.map +1 -1
  55. package/dist/lib/results-store.d.ts +1 -0
  56. package/dist/lib/results-store.js +87 -3
  57. package/dist/lib/results-store.js.map +1 -1
  58. package/dist/lib/runtime.d.ts +6 -0
  59. package/dist/lib/runtime.js +46 -19
  60. package/dist/lib/runtime.js.map +1 -1
  61. package/dist/lib/test/integration-harness.js +1 -1
  62. package/dist/lib/test/integration-harness.js.map +1 -1
  63. package/dist/lib/ui.d.ts +1 -0
  64. package/dist/lib/ui.html +11231 -4370
  65. package/dist/lib/ui.js +2564 -277
  66. package/dist/lib/ui.js.map +1 -1
  67. package/dist/lib/web-terminal.d.ts +13 -0
  68. package/dist/lib/web-terminal.js +118 -15
  69. package/dist/lib/web-terminal.js.map +1 -1
  70. package/dist/mcp-bundles/display-mcp.bundle.mjs +30209 -0
  71. package/dist/mcp-bundles/explorer-mcp.bundle.mjs +40044 -0
  72. package/dist/mcp-bundles/results-mcp.bundle.mjs +100 -7
  73. package/package.json +4 -3
  74. package/templates/tsp-lab/API_CONTRACT.md +20 -0
  75. package/templates/tsp-lab/EVAL.md +20 -0
  76. package/templates/tsp-lab/PROBLEM.md +18 -0
  77. package/templates/tsp-lab/data/generate_instances.py +51 -0
  78. package/templates/tsp-lab/data/instances.jsonl +12 -0
  79. package/templates/tsp-lab/eval.py +148 -0
  80. package/templates/tsp-lab/solver.py +88 -0
  81. package/templates/tsp-lab/stub-patches/enable_two_opt.patch +14 -0
@@ -39,8 +39,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.computeMountFingerprint = computeMountFingerprint;
40
40
  exports.prepareMcpServers = prepareMcpServers;
41
41
  exports.imageToSifName = imageToSifName;
42
+ exports.buildCodexOauthPublishSpec = buildCodexOauthPublishSpec;
42
43
  exports.buildEntrypoint = buildEntrypoint;
43
44
  exports.setupBrowserHook = setupBrowserHook;
45
+ exports.getAgentTokenEnv = getAgentTokenEnv;
44
46
  exports.startSession = startSession;
45
47
  exports.listSessions = listSessions;
46
48
  exports.stopSession = stopSession;
@@ -201,6 +203,8 @@ function cleanupLabgateInstructionFile(workdir, filename, deleteWhenEmpty) {
201
203
  }
202
204
  }
203
205
  function buildLabgateInstructionBlock(session) {
206
+ const agentLower = session.agent.toLowerCase();
207
+ const hasMcp = agentLower === 'claude' || agentLower === 'codex';
204
208
  const lines = [
205
209
  LABGATE_INSTRUCTION_START,
206
210
  '## LabGate Sandbox Context (Auto-Managed)',
@@ -231,7 +235,7 @@ function buildLabgateInstructionBlock(session) {
231
235
  lines.push('');
232
236
  lines.push('Use these dataset paths directly when the user references data by name.');
233
237
  }
234
- if (session.agent.toLowerCase() === 'claude') {
238
+ if (hasMcp) {
235
239
  lines.push('');
236
240
  lines.push('### Dataset Management');
237
241
  lines.push('- Use the `labgate-datasets` MCP tools to manage and explore datasets.');
@@ -242,13 +246,30 @@ function buildLabgateInstructionBlock(session) {
242
246
  lines.push('- Use the `labgate-results` MCP tools to record and revisit important outcomes.');
243
247
  lines.push('- Available tools: `list_results`, `register_result`, `get_result`, `update_result`, `delete_result`');
244
248
  lines.push('- When you finish meaningful work, call `register_result` with a concise title, summary, and relevant tags/artifacts.');
249
+ lines.push('');
250
+ lines.push('### Display Widgets');
251
+ lines.push('- Use the `labgate-display` MCP tool `display_widget` to render rich interactive content in the LabGate web UI.');
252
+ lines.push('- Supported widgets: `markdown`, `table`, `plot`, `image`, `pdf`, `molecule`, `sequence`, `alignment`, `file_preview`, `tree`, `heatmap`, `tracks`, `domains`, `network`');
253
+ lines.push('- Use `molecule` with `pdb_id` to show 3D protein structures (e.g. `{ pdb_id: "2HIU" }`).');
254
+ lines.push('- Use `sequence` to display colored DNA/RNA/protein sequences.');
255
+ lines.push('- Use `plot` with a Plotly.js spec (`{ plotly: { data: [...], layout: {...} } }`) for interactive charts.');
256
+ lines.push('- Use `table` for structured data (`{ columns: [...], rows: [...] }`).');
257
+ lines.push('- Use `image` with `path` to display images from the filesystem.');
258
+ lines.push('- Use `pdf` with `path` to display PDF documents inline (`{ path: "/work/paper.pdf" }`).');
259
+ lines.push('- Use `alignment` for multiple sequence alignments with conservation highlighting.');
260
+ lines.push('- Use `tree` with Newick format string for phylogenetic trees (e.g. `{ newick: "((A,B),(C,D));" }`).');
261
+ lines.push('- Use `heatmap` for clustered heatmaps with optional dendrograms (`{ matrix: [[...]], row_labels: [...], cluster_rows: true }`).');
262
+ lines.push('- Use `tracks` for genome feature viewing via igv.js (`{ tracks: [{ path: "/work/file.gff3", format: "gff3" }] }`).');
263
+ lines.push('- Use `domains` for protein domain architecture (`{ length: 300, features: [{ type: "domain", name: "Kinase", start: 10, end: 120 }] }`).');
264
+ lines.push('- Use `network` for interactive graph visualization (`{ nodes: [{id:"A"}], edges: [{source:"A", target:"B"}] }`).');
265
+ lines.push('- Prefer `display_widget` over plain text when showing structured data, sequences, or molecular information.');
245
266
  }
246
267
  if (session.config.slurm.enabled) {
247
268
  lines.push('');
248
269
  lines.push('### SLURM Integration');
249
270
  lines.push('- SLURM commands (srun, sbatch, squeue, scancel) are available.');
250
271
  lines.push('- LabGate tracks your SLURM jobs automatically via squeue polling.');
251
- if (session.config.slurm.mcp_server && session.agent.toLowerCase() === 'claude') {
272
+ if (session.config.slurm.mcp_server && hasMcp) {
252
273
  lines.push('- Use the `labgate-cluster` MCP tools to inspect partitions, queue pressure, and available modules.');
253
274
  lines.push('- Available tools: `get_cluster_partitions`, `get_partition_limits`, `get_queue_pressure`, `find_module`');
254
275
  lines.push('- Use the `labgate-slurm` MCP tools to check job status and read output files.');
@@ -264,14 +285,64 @@ function buildLabgateInstructionBlock(session) {
264
285
  lines.push(LABGATE_INSTRUCTION_END);
265
286
  return lines.join('\n');
266
287
  }
288
+ /**
289
+ * Remove all [mcp_servers.*] sections (and their key/value lines) from a TOML string,
290
+ * preserving all other sections (model, projects, features, etc.).
291
+ */
292
+ function stripTomlMcpSections(toml) {
293
+ const lines = toml.split('\n');
294
+ const result = [];
295
+ let inMcpSection = false;
296
+ for (const line of lines) {
297
+ const trimmed = line.trim();
298
+ if (/^\[mcp_servers[\].]/i.test(trimmed)) {
299
+ inMcpSection = true;
300
+ continue;
301
+ }
302
+ if (inMcpSection && /^\[/.test(trimmed) && !/^\[mcp_servers[\].]/i.test(trimmed)) {
303
+ inMcpSection = false;
304
+ }
305
+ if (!inMcpSection) {
306
+ result.push(line);
307
+ }
308
+ }
309
+ // Trim trailing blank lines
310
+ while (result.length > 0 && result[result.length - 1].trim() === '') {
311
+ result.pop();
312
+ }
313
+ return result.join('\n');
314
+ }
315
+ /**
316
+ * Serialize MCP server entries to Codex-compatible TOML format.
317
+ * Produces [mcp_servers."<name>"] sections with command, args, and optional env.
318
+ */
319
+ function serializeCodexMcpToml(servers) {
320
+ const lines = [];
321
+ for (const [name, server] of Object.entries(servers)) {
322
+ lines.push(`[mcp_servers.${JSON.stringify(name)}]`);
323
+ lines.push(`command = ${JSON.stringify(server.command)}`);
324
+ lines.push(`args = [${server.args.map((a) => JSON.stringify(a)).join(', ')}]`);
325
+ if (server.env && Object.keys(server.env).length > 0) {
326
+ lines.push('');
327
+ lines.push(`[mcp_servers.${JSON.stringify(name)}.env]`);
328
+ for (const [k, v] of Object.entries(server.env)) {
329
+ lines.push(`${k} = ${JSON.stringify(v)}`);
330
+ }
331
+ }
332
+ lines.push('');
333
+ }
334
+ return lines.join('\n');
335
+ }
267
336
  function prepareMcpServers(session, options = {}) {
268
- if (session.agent.toLowerCase() !== 'claude')
337
+ const agentLower = session.agent.toLowerCase();
338
+ if (agentLower !== 'claude' && agentLower !== 'codex')
269
339
  return;
270
340
  try {
271
341
  const sandboxHome = (0, config_js_1.getSandboxHome)();
272
342
  const mcpDir = (0, path_1.join)(sandboxHome, '.mcp-servers');
273
343
  (0, fs_1.mkdirSync)(mcpDir, { recursive: true });
274
- // Claude Code reads MCP config from ~/.claude.json (in HOME root)
344
+ // Claude Code reads MCP config from ~/.claude.json (in HOME root).
345
+ // For Codex we build the same mcpConfig object, then serialize to TOML below.
275
346
  const mcpConfigPath = (0, path_1.join)(sandboxHome, '.claude.json');
276
347
  let mcpConfig = {};
277
348
  try {
@@ -301,7 +372,7 @@ function prepareMcpServers(session, options = {}) {
301
372
  copyMcpNodePackage('bindings') &&
302
373
  copyMcpNodePackage('file-uri-to-path'));
303
374
  };
304
- // Dataset MCP server (always for Claude)
375
+ // Dataset MCP server (always)
305
376
  const datasetBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/dataset-mcp.bundle.mjs');
306
377
  if ((0, fs_1.existsSync)(datasetBundleSrc)) {
307
378
  (0, fs_1.copyFileSync)(datasetBundleSrc, (0, path_1.join)(mcpDir, 'dataset-mcp.bundle.mjs'));
@@ -319,13 +390,13 @@ function prepareMcpServers(session, options = {}) {
319
390
  args: [datasetMcpPath],
320
391
  };
321
392
  }
322
- // Results MCP server (always for Claude)
393
+ // Results MCP server (always)
323
394
  const resultsBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/results-mcp.bundle.mjs');
324
395
  if ((0, fs_1.existsSync)(resultsBundleSrc)) {
325
396
  (0, fs_1.copyFileSync)(resultsBundleSrc, (0, path_1.join)(mcpDir, 'results-mcp.bundle.mjs'));
326
397
  mcpConfig.mcpServers['labgate-results'] = {
327
398
  command: 'node',
328
- args: ['/home/sandbox/.mcp-servers/results-mcp.bundle.mjs'],
399
+ args: ['/home/sandbox/.mcp-servers/results-mcp.bundle.mjs', '--db', '/labgate-config/results.json'],
329
400
  env,
330
401
  };
331
402
  }
@@ -333,7 +404,24 @@ function prepareMcpServers(session, options = {}) {
333
404
  const resultsMcpPath = (0, path_1.resolve)(__dirname, 'results-mcp.js');
334
405
  mcpConfig.mcpServers['labgate-results'] = {
335
406
  command: 'node',
336
- args: [resultsMcpPath],
407
+ args: [resultsMcpPath, '--db', (0, config_js_1.getResultsDbPath)()],
408
+ };
409
+ }
410
+ // Display MCP server (always — renders rich widgets in LabGate UI)
411
+ const displayBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/display-mcp.bundle.mjs');
412
+ if ((0, fs_1.existsSync)(displayBundleSrc)) {
413
+ (0, fs_1.copyFileSync)(displayBundleSrc, (0, path_1.join)(mcpDir, 'display-mcp.bundle.mjs'));
414
+ mcpConfig.mcpServers['labgate-display'] = {
415
+ command: 'node',
416
+ args: ['/home/sandbox/.mcp-servers/display-mcp.bundle.mjs', '--db', '/labgate-config/display.json'],
417
+ env,
418
+ };
419
+ }
420
+ else {
421
+ const displayMcpPath = (0, path_1.resolve)(__dirname, 'display-mcp.js');
422
+ mcpConfig.mcpServers['labgate-display'] = {
423
+ command: 'node',
424
+ args: [displayMcpPath, '--db', (0, config_js_1.getDisplayDbPath)()],
337
425
  };
338
426
  }
339
427
  // Cluster MCP server (when SLURM integration is enabled)
@@ -387,8 +475,29 @@ function prepareMcpServers(session, options = {}) {
387
475
  else {
388
476
  delete mcpConfig.mcpServers['labgate-slurm'];
389
477
  }
390
- (0, fs_1.writeFileSync)(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
391
- (0, config_js_1.ensurePrivateFile)(mcpConfigPath);
478
+ if (agentLower === 'claude') {
479
+ (0, fs_1.writeFileSync)(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
480
+ (0, config_js_1.ensurePrivateFile)(mcpConfigPath);
481
+ }
482
+ if (agentLower === 'codex') {
483
+ // Codex reads MCP config from ~/.codex/config.toml
484
+ const codexDir = (0, path_1.join)(sandboxHome, '.codex');
485
+ (0, fs_1.mkdirSync)(codexDir, { recursive: true });
486
+ const codexConfigPath = (0, path_1.join)(codexDir, 'config.toml');
487
+ let existingToml = '';
488
+ try {
489
+ if ((0, fs_1.existsSync)(codexConfigPath)) {
490
+ existingToml = (0, fs_1.readFileSync)(codexConfigPath, 'utf-8');
491
+ }
492
+ }
493
+ catch { /* start fresh */ }
494
+ // Strip old [mcp_servers.*] sections and append fresh ones
495
+ const cleaned = stripTomlMcpSections(existingToml);
496
+ const mcpToml = serializeCodexMcpToml(mcpConfig.mcpServers);
497
+ const separator = cleaned.length > 0 && !cleaned.endsWith('\n') ? '\n\n' : cleaned.length > 0 ? '\n' : '';
498
+ (0, fs_1.writeFileSync)(codexConfigPath, cleaned + separator + mcpToml, 'utf-8');
499
+ (0, config_js_1.ensurePrivateFile)(codexConfigPath);
500
+ }
392
501
  }
393
502
  catch {
394
503
  // Best effort — MCP registration failure is non-fatal
@@ -803,6 +912,7 @@ function prepareCommonArgs(session, sessionId, tokenEnv) {
803
912
  '--env', `LABGATE_NETWORK_MODE=${config.network.mode}`,
804
913
  '--env', `LABGATE_DATASET_COUNT=${datasetCount}`,
805
914
  '--env', `LABGATE_RESULT_COUNT=${resultCount}`,
915
+ '--env', 'LABGATE_RESULTS_DB_PATH=/labgate-config/results.json',
806
916
  // HOME is set inside the entrypoint script (Apptainer rejects --env HOME)
807
917
  ...(commandBlacklist ? ['--env', `LABGATE_CMD_BLACKLIST=${commandBlacklist}`] : []),
808
918
  ...(dashboardUrl ? ['--env', `LABGATE_DASHBOARD_URL=${dashboardUrl}`] : []),
@@ -812,6 +922,23 @@ function prepareCommonArgs(session, sessionId, tokenEnv) {
812
922
  ];
813
923
  return { blockedMounts, emptyDir, envArgs };
814
924
  }
925
+ function ensureResultsDbPathForContainer() {
926
+ const resultsPath = (0, config_js_1.getResultsDbPath)();
927
+ try {
928
+ (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(resultsPath));
929
+ if (!(0, fs_1.existsSync)(resultsPath)) {
930
+ (0, fs_1.writeFileSync)(resultsPath, JSON.stringify({ version: 1, results: [] }, null, 2) + '\n', {
931
+ encoding: 'utf-8',
932
+ mode: config_js_1.PRIVATE_FILE_MODE,
933
+ });
934
+ }
935
+ (0, config_js_1.ensurePrivateFile)(resultsPath);
936
+ return resultsPath;
937
+ }
938
+ catch {
939
+ return (0, fs_1.existsSync)(resultsPath) ? resultsPath : null;
940
+ }
941
+ }
815
942
  function getRegisteredResultsCount() {
816
943
  try {
817
944
  const resultsPath = (0, config_js_1.getResultsDbPath)();
@@ -825,6 +952,10 @@ function getRegisteredResultsCount() {
825
952
  }
826
953
  }
827
954
  const DEFAULT_CODEX_OAUTH_CALLBACK_PORT = 1455;
955
+ const CODEX_OAUTH_STARTUP_HEARTBEAT_MS = 10_000;
956
+ const CODEX_OAUTH_STARTUP_HEARTBEAT_MAX_MS = 50_000;
957
+ const CODEX_OAUTH_STARTUP_SPINNER_MS = 125;
958
+ const CODEX_OAUTH_STARTUP_SPINNER_FRAMES = ['|', '/', '-', '\\'];
828
959
  function getCodexOauthCallbackPort() {
829
960
  const raw = (process.env.LABGATE_CODEX_OAUTH_CALLBACK_PORT || '').trim();
830
961
  if (!raw)
@@ -838,13 +969,90 @@ function getCodexOauthCallbackPort() {
838
969
  return port;
839
970
  }
840
971
  function shouldBridgeCodexOauthForPodman(agent, networkMode) {
841
- return agent === 'codex' && networkMode !== 'none' && (0, os_1.platform)() === 'darwin';
972
+ if (agent !== 'codex' || networkMode === 'none' || (0, os_1.platform)() !== 'darwin')
973
+ return false;
974
+ // Default off: the macOS Podman path now uses device-auth fallback, and forcing
975
+ // a fixed localhost publish often fails with "proxy already running" in rootless
976
+ // Podman. Keep an explicit opt-in for troubleshooting legacy callback flows.
977
+ const optIn = (process.env.LABGATE_CODEX_PODMAN_OAUTH_BRIDGE || '').trim().toLowerCase();
978
+ return optIn === '1' || optIn === 'true' || optIn === 'yes';
979
+ }
980
+ function buildCodexOauthPublishSpec(port) {
981
+ // Podman macOS rootless networking does not allow publishing the same host
982
+ // port for both 127.0.0.1 and ::1 simultaneously (rootlessport conflict).
983
+ // Publishing without an explicit host IP gives dual-stack localhost reachability.
984
+ return `${port}:${port}`;
985
+ }
986
+ function startCodexOauthStartupHeartbeat() {
987
+ const startedAt = Date.now();
988
+ let stopped = false;
989
+ let sawOutput = false;
990
+ let interval = null;
991
+ let maxTimer = null;
992
+ let spinnerFrame = 0;
993
+ let spinnerActive = false;
994
+ const useSpinner = !!(process.stderr.isTTY && process.stdout.isTTY);
995
+ const renderSpinner = () => {
996
+ const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAt) / 1000));
997
+ const frame = CODEX_OAUTH_STARTUP_SPINNER_FRAMES[spinnerFrame];
998
+ spinnerFrame = (spinnerFrame + 1) % CODEX_OAUTH_STARTUP_SPINNER_FRAMES.length;
999
+ process.stderr.write(`\r\x1b[2K${log.dim('›')} Waiting for Codex login output... ${frame} ${elapsedSeconds}s`);
1000
+ spinnerActive = true;
1001
+ };
1002
+ const stop = () => {
1003
+ if (stopped)
1004
+ return;
1005
+ stopped = true;
1006
+ if (interval) {
1007
+ clearInterval(interval);
1008
+ interval = null;
1009
+ }
1010
+ if (maxTimer) {
1011
+ clearTimeout(maxTimer);
1012
+ maxTimer = null;
1013
+ }
1014
+ if (spinnerActive) {
1015
+ process.stderr.write('\r\x1b[2K');
1016
+ spinnerActive = false;
1017
+ }
1018
+ };
1019
+ if (useSpinner) {
1020
+ renderSpinner();
1021
+ interval = setInterval(() => {
1022
+ if (stopped || sawOutput)
1023
+ return;
1024
+ renderSpinner();
1025
+ }, CODEX_OAUTH_STARTUP_SPINNER_MS);
1026
+ }
1027
+ else {
1028
+ interval = setInterval(() => {
1029
+ if (stopped || sawOutput)
1030
+ return;
1031
+ const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAt) / 1000));
1032
+ log.step(`Still starting Codex OAuth flow... (${elapsedSeconds}s elapsed)`);
1033
+ }, CODEX_OAUTH_STARTUP_HEARTBEAT_MS);
1034
+ }
1035
+ maxTimer = setTimeout(() => stop(), CODEX_OAUTH_STARTUP_HEARTBEAT_MAX_MS);
1036
+ interval.unref();
1037
+ maxTimer.unref();
1038
+ return {
1039
+ noteOutput(data) {
1040
+ if (stopped || sawOutput)
1041
+ return;
1042
+ if (!data || data.trim().length === 0)
1043
+ return;
1044
+ sawOutput = true;
1045
+ stop();
1046
+ },
1047
+ stop,
1048
+ };
842
1049
  }
843
1050
  // ── Build Apptainer arguments ─────────────────────────────
844
1051
  function buildApptainerArgs(session, sifPath, sessionId, tokenEnv = []) {
845
1052
  const { agent, workdir, config } = session;
846
1053
  const sandboxHome = (0, config_js_1.getSandboxHome)();
847
1054
  const { blockedMounts, emptyDir, envArgs } = prepareCommonArgs(session, sessionId, tokenEnv);
1055
+ const resultsDbPath = ensureResultsDbPathForContainer();
848
1056
  // SLURM auth passthrough: bind host munged socket dir when present.
849
1057
  // This enables staged SLURM client commands inside the container to authenticate.
850
1058
  const mungeBinds = [];
@@ -882,6 +1090,8 @@ function buildApptainerArgs(session, sifPath, sessionId, tokenEnv = []) {
882
1090
  }),
883
1091
  // ── LabGate config for in-container MCP servers ──
884
1092
  ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--bind', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
1093
+ ...(resultsDbPath ? ['--bind', `${resultsDbPath}:/labgate-config/results.json`] : []),
1094
+ ...((0, fs_1.existsSync)((0, config_js_1.getDisplayDbPath)()) ? ['--bind', `${(0, config_js_1.getDisplayDbPath)()}:/labgate-config/display.json`] : []),
885
1095
  ...(config.slurm.enabled && (0, fs_1.existsSync)((0, config_js_1.getSlurmDbPath)())
886
1096
  ? ['--bind', `${(0, config_js_1.getSlurmDbPath)()}:/labgate-config/slurm.db`] : []),
887
1097
  ...(config.slurm.enabled && (0, fs_1.existsSync)(`${(0, config_js_1.getSlurmDbPath)()}.json`)
@@ -907,6 +1117,7 @@ function buildPodmanArgs(session, image, sessionId, tokenEnv = [], options = { t
907
1117
  const { agent, workdir, config } = session;
908
1118
  const sandboxHome = (0, config_js_1.getSandboxHome)();
909
1119
  const { blockedMounts, emptyDir, envArgs } = prepareCommonArgs(session, sessionId, tokenEnv);
1120
+ const resultsDbPath = ensureResultsDbPathForContainer();
910
1121
  const bridgeCodexOauth = shouldBridgeCodexOauthForPodman(agent, config.network.mode);
911
1122
  const codexOauthPort = bridgeCodexOauth ? getCodexOauthCallbackPort() : 0;
912
1123
  const networkArgs = config.network.mode === 'none'
@@ -915,7 +1126,7 @@ function buildPodmanArgs(session, image, sessionId, tokenEnv = [], options = { t
915
1126
  ? (bridgeCodexOauth ? [] : ['--network', 'host'])
916
1127
  : [];
917
1128
  const publishArgs = bridgeCodexOauth
918
- ? ['--publish', `127.0.0.1:${codexOauthPort}:${codexOauthPort}`]
1129
+ ? ['--publish', buildCodexOauthPublishSpec(codexOauthPort)]
919
1130
  : [];
920
1131
  return [
921
1132
  'run',
@@ -943,6 +1154,8 @@ function buildPodmanArgs(session, image, sessionId, tokenEnv = [], options = { t
943
1154
  }),
944
1155
  // ── LabGate config for in-container MCP servers ──
945
1156
  ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--volume', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
1157
+ ...(resultsDbPath ? ['--volume', `${resultsDbPath}:/labgate-config/results.json`] : []),
1158
+ ...((0, fs_1.existsSync)((0, config_js_1.getDisplayDbPath)()) ? ['--volume', `${(0, config_js_1.getDisplayDbPath)()}:/labgate-config/display.json`] : []),
946
1159
  ...(config.slurm.enabled && (0, fs_1.existsSync)((0, config_js_1.getSlurmDbPath)())
947
1160
  ? ['--volume', `${(0, config_js_1.getSlurmDbPath)()}:/labgate-config/slurm.db`] : []),
948
1161
  ...(config.slurm.enabled && (0, fs_1.existsSync)(`${(0, config_js_1.getSlurmDbPath)()}.json`)
@@ -1070,7 +1283,11 @@ function buildEntrypoint(agent) {
1070
1283
  // Configure a LabGate status line in Claude Code with a dashboard URL.
1071
1284
  lines.push('mkdir -p "$HOME/.claude"', 'cat > "$HOME/.claude/labgate-statusline.sh" <<\'EOF\'', '#!/usr/bin/env bash', 'status_input="$(cat || true)"', 'url="${LABGATE_DASHBOARD_URL:-}"', 'if [ -z "$url" ]; then', ' dashboard_link_file="${LABGATE_DASHBOARD_LINK_FILE:-$HOME/.labgate-dashboard-url}"', ' if [ -f "$dashboard_link_file" ]; then', ' url="$(cat "$dashboard_link_file" 2>/dev/null || true)"', ' fi', 'fi', 'if [ -z "$url" ]; then', ' url="http://localhost:7700"', 'fi', 'dataset_count="${LABGATE_DATASET_COUNT:-0}"', 'result_count="${LABGATE_RESULT_COUNT:-0}"', 'status_meta="$(printf "%s" "$status_input" | node -e \'', ' const fs = require("fs");', ' let raw = "";', ' try { raw = fs.readFileSync(0, "utf8"); } catch {}', ' let model = "Unknown";', ' let remaining = "";', ' try {', ' const parsed = JSON.parse(raw || "{}");', ' const modelObj = parsed && parsed.model ? parsed.model : null;', ' if (modelObj) model = modelObj.display_name || modelObj.id || model;', ' const ctx = parsed && parsed.context_window ? parsed.context_window : null;', ' if (ctx && Number.isFinite(ctx.remaining_percentage)) {', ' remaining = String(Math.max(0, Math.min(100, Math.round(ctx.remaining_percentage))));', ' } else if (ctx && Number.isFinite(ctx.used_percentage)) {', ' remaining = String(Math.max(0, Math.min(100, Math.round(100 - ctx.used_percentage))));', ' }', ' } catch {}', ' process.stdout.write(String(model) + "\\t" + String(remaining));', '\' 2>/dev/null || true)"', 'model_display="Unknown"', 'context_remaining=""', 'if [ -n "$status_meta" ]; then', ' IFS=$\'\\t\' read -r model_display context_remaining <<< "$status_meta"', 'fi', 'if [ -z "$model_display" ]; then', ' model_display="Unknown"', 'fi', 'context_note="context availability unknown"', 'if [[ "$context_remaining" =~ ^[0-9]+$ ]]; then', ' context_note="${context_remaining}% context available"', 'fi', 'results_file="${LABGATE_RESULTS_DB_PATH:-$HOME/.labgate/results.json}"', 'baseline_file="$HOME/.claude/labgate-statusline.results-baseline.${LABGATE_SESSION:-default}"', 'read_results_count() {', ' local path="${1:-}"', ' [ -n "$path" ] || return 0', ' [ -f "$path" ] || return 0', ' node -e \'', ' const fs = require("fs");', ' const p = process.argv[1];', ' try {', ' const parsed = JSON.parse(fs.readFileSync(p, "utf8"));', ' const rows = Array.isArray(parsed?.results) ? parsed.results : [];', ' process.stdout.write(String(rows.length));', ' } catch {}', ' \' "$path" 2>/dev/null || true', '}', 'current_result_count="$(read_results_count "$results_file")"', 'if ! [[ "$current_result_count" =~ ^[0-9]+$ ]]; then', ' current_result_count="$result_count"', 'fi', 'baseline_result_count=""', 'if [ -f "$baseline_file" ]; then', ' baseline_result_count="$(cat "$baseline_file" 2>/dev/null || true)"', 'fi', 'if ! [[ "$baseline_result_count" =~ ^[0-9]+$ ]]; then', ' baseline_result_count="$current_result_count"', ' printf "%s\\n" "$baseline_result_count" > "$baseline_file" 2>/dev/null || true', 'fi', 'result_delta=$(( current_result_count - baseline_result_count ))', 'if [ "$result_delta" -lt 0 ]; then', ' result_delta=0', 'fi', 'dataset_suffix="s"', 'result_suffix="s"', 'delta_suffix="s"', '[ "$dataset_count" = "1" ] && dataset_suffix=""', '[ "$current_result_count" = "1" ] && result_suffix=""', '[ "$result_delta" = "1" ] && delta_suffix=""', 'delta_note=""', 'if [ "$result_delta" -gt 0 ]; then', ' delta_note=" (${result_delta} new result${delta_suffix} since this session)"', 'fi', 'summary="model ${model_display} | ${context_note} | ${dataset_count} dataset${dataset_suffix} registered | ${current_result_count} result${result_suffix} registered${delta_note}"', 'if [[ "$url" =~ ^https?:// ]]; then', ' echo "LabGate | ${url} -- ${summary}"', 'else', ' echo "LabGate -- ${summary}"', 'fi', 'EOF', 'chmod +x "$HOME/.claude/labgate-statusline.sh"', '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 {}', ' settings.statusLine = {', ' type: "command",', ' command: "~/.claude/labgate-statusline.sh",', ' padding: 1,', ' };', ' fs.writeFileSync(path, JSON.stringify(settings, null, 2));', '\'', '');
1072
1285
  }
1073
- lines.push(`if ! command -v ${setup.bin} >/dev/null 2>&1; then`, ` ${setup.installer}`, 'fi', `echo "[labgate] Starting ${setup.bin} in /work"`, `exec ${setup.bin}`);
1286
+ const preLaunchLines = [];
1287
+ if (agent === 'codex') {
1288
+ preLaunchLines.push('labgate_ensure_ca_certificates() {', ' if [ -s /etc/ssl/certs/ca-certificates.crt ] || [ -s /etc/ssl/cert.pem ]; then', ' return 0', ' fi', ' if [ "$(id -u)" != "0" ]; then', ' echo "[labgate] Warning: missing CA certificates and no root access to install them; Codex login may fail." >&2', ' return 0', ' fi', ' echo "[labgate] Installing ca-certificates for Codex TLS..."', ' 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 ca-certificates >/dev/null 2>&1 || return 0', ' update-ca-certificates >/dev/null 2>&1 || true', ' return 0', ' fi', ' if command -v apk >/dev/null 2>&1; then', ' apk add --no-cache ca-certificates >/dev/null 2>&1 || return 0', ' update-ca-certificates >/dev/null 2>&1 || true', ' return 0', ' fi', ' if command -v dnf >/dev/null 2>&1; then', ' dnf install -y ca-certificates >/dev/null 2>&1 || return 0', ' return 0', ' fi', ' if command -v yum >/dev/null 2>&1; then', ' yum install -y ca-certificates >/dev/null 2>&1 || return 0', ' return 0', ' fi', ' if command -v microdnf >/dev/null 2>&1; then', ' microdnf install -y ca-certificates >/dev/null 2>&1 || return 0', ' fi', '}', 'labgate_ensure_ca_certificates', 'if [ "${LABGATE_CODEX_PODMAN_DARWIN_WORKAROUND:-}" = "1" ]; then', ' if ! codex login status >/dev/null 2>&1; then', ' echo "[labgate] Starting Codex device-code login (Podman macOS callback workaround)..."', ' codex login --device-auth', ' fi', 'fi');
1289
+ }
1290
+ lines.push(`if ! command -v ${setup.bin} >/dev/null 2>&1; then`, ` ${setup.installer}`, 'fi', ...preLaunchLines, `echo "[labgate] Starting ${setup.bin} in /work"`, `exec ${setup.bin}`);
1074
1291
  return lines.join('\n');
1075
1292
  }
1076
1293
  // ── Browser-open hook for OAuth (via sandbox home) ────────
@@ -1101,45 +1318,113 @@ function osc52Copy(text) {
1101
1318
  const OAUTH_URL_DEDUPE_WINDOW_MS = 20_000;
1102
1319
  let lastHandledOAuthUrl = '';
1103
1320
  let lastHandledOAuthAt = 0;
1321
+ const CLAUDE_OAUTH_MARKER = 'https://claude.ai/oauth/authorize?';
1322
+ const CLAUDE_MANUAL_CODE_REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';
1323
+ const CLAUDE_LOCALHOST_REDIRECT_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
1324
+ function stripTerminalControlForOAuth(text) {
1325
+ return String(text || '')
1326
+ .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
1327
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
1328
+ .replace(/\x1b[P^_][\s\S]*?\x1b\\/g, '')
1329
+ .replace(/\x1b[@-_]/g, '')
1330
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
1331
+ }
1332
+ function canonicalizeClaudeOAuthRedirectUri(parsed) {
1333
+ const rawRedirectUri = (parsed.searchParams.get('redirect_uri') || '').trim();
1334
+ if (!rawRedirectUri)
1335
+ return;
1336
+ try {
1337
+ const redirectUri = new URL(rawRedirectUri);
1338
+ const host = (redirectUri.hostname || '').trim().toLowerCase();
1339
+ if (!CLAUDE_LOCALHOST_REDIRECT_HOSTS.has(host))
1340
+ return;
1341
+ parsed.searchParams.set('redirect_uri', CLAUDE_MANUAL_CODE_REDIRECT_URI);
1342
+ }
1343
+ catch {
1344
+ // Keep original redirect URI if it cannot be parsed.
1345
+ }
1346
+ }
1347
+ function normalizeClaudeOAuthUrl(raw) {
1348
+ const stripped = stripTerminalControlForOAuth(raw);
1349
+ const start = stripped.lastIndexOf(CLAUDE_OAUTH_MARKER);
1350
+ if (start === -1)
1351
+ return null;
1352
+ let candidate = stripped.slice(start);
1353
+ const endPatterns = [/\n\s*Paste code/i, /\n\s*Browser did(?: not|n't) open\?/i, /\n\s*\n/];
1354
+ let end = candidate.length;
1355
+ for (const pat of endPatterns) {
1356
+ const match = candidate.match(pat);
1357
+ if (match && match.index !== undefined && match.index >= 0 && match.index < end) {
1358
+ end = match.index;
1359
+ }
1360
+ }
1361
+ candidate = candidate.slice(0, end).replace(/\s+/g, '').trim();
1362
+ const urlMatch = candidate.match(/^https:\/\/claude\.ai\/oauth\/authorize\?[A-Za-z0-9\-._~%!$&'()*+,;=:@/?#[\]]+/);
1363
+ if (!urlMatch)
1364
+ return null;
1365
+ try {
1366
+ const parsed = new URL(urlMatch[0]);
1367
+ if (parsed.protocol !== 'https:' || parsed.hostname !== 'claude.ai' || parsed.pathname !== '/oauth/authorize') {
1368
+ return null;
1369
+ }
1370
+ const required = ['client_id', 'state', 'response_type', 'redirect_uri', 'scope', 'code_challenge', 'code_challenge_method'];
1371
+ for (const key of required) {
1372
+ if (!parsed.searchParams.get(key))
1373
+ return null;
1374
+ }
1375
+ if ((parsed.searchParams.get('response_type') || '').trim().toLowerCase() !== 'code')
1376
+ return null;
1377
+ if ((parsed.searchParams.get('code_challenge_method') || '').trim().toUpperCase() !== 'S256')
1378
+ return null;
1379
+ canonicalizeClaudeOAuthRedirectUri(parsed);
1380
+ return parsed.toString();
1381
+ }
1382
+ catch {
1383
+ return null;
1384
+ }
1385
+ }
1104
1386
  function handleOAuthUrl(url, options) {
1387
+ const normalizedUrl = normalizeClaudeOAuthUrl(url);
1388
+ if (!normalizedUrl)
1389
+ return;
1105
1390
  const now = Date.now();
1106
- if (url === lastHandledOAuthUrl && (now - lastHandledOAuthAt) < OAUTH_URL_DEDUPE_WINDOW_MS) {
1391
+ if (normalizedUrl === lastHandledOAuthUrl && (now - lastHandledOAuthAt) < OAUTH_URL_DEDUPE_WINDOW_MS) {
1107
1392
  return;
1108
1393
  }
1109
- lastHandledOAuthUrl = url;
1394
+ lastHandledOAuthUrl = normalizedUrl;
1110
1395
  lastHandledOAuthAt = now;
1111
1396
  if (options.isRemote) {
1112
1397
  // SSH / headless: push URL to local clipboard via OSC 52 if supported.
1113
- osc52Copy(url);
1114
- log.info(`Login URL:\n${url}`);
1398
+ osc52Copy(normalizedUrl);
1399
+ log.info(`Login URL:\n${normalizedUrl}`);
1115
1400
  log.info('Tried to copy URL via terminal clipboard (OSC 52).');
1116
1401
  log.info('Open it in your local browser, then paste the code back here.');
1117
1402
  return;
1118
1403
  }
1119
1404
  if (options.hostPlatform === 'darwin') {
1120
1405
  try {
1121
- options.execSync('pbcopy', [], { input: url, stdio: ['pipe', 'ignore', 'ignore'] });
1406
+ options.execSync('pbcopy', [], { input: normalizedUrl, stdio: ['pipe', 'ignore', 'ignore'] });
1122
1407
  }
1123
1408
  catch { /* best effort */ }
1124
1409
  try {
1125
- options.execSync('open', [url], { stdio: 'ignore' });
1410
+ options.execSync('open', [normalizedUrl], { stdio: 'ignore' });
1126
1411
  log.success('Login URL opened in browser');
1127
1412
  }
1128
1413
  catch {
1129
1414
  log.warn('Could not auto-open browser. URL copied to clipboard (if available).');
1130
- log.info(`Login URL:\n${url}`);
1415
+ log.info(`Login URL:\n${normalizedUrl}`);
1131
1416
  }
1132
1417
  return;
1133
1418
  }
1134
1419
  // Local Linux with display
1135
1420
  try {
1136
- options.execSync('xdg-open', [url], { stdio: 'ignore' });
1421
+ options.execSync('xdg-open', [normalizedUrl], { stdio: 'ignore' });
1137
1422
  log.success('Login URL opened in browser');
1138
1423
  }
1139
1424
  catch {
1140
- osc52Copy(url);
1425
+ osc52Copy(normalizedUrl);
1141
1426
  log.warn('Could not auto-open browser. Tried clipboard copy via terminal.');
1142
- log.info(`Login URL:\n${url}`);
1427
+ log.info(`Login URL:\n${normalizedUrl}`);
1143
1428
  }
1144
1429
  }
1145
1430
  /**
@@ -1161,25 +1446,11 @@ function createOAuthInterceptor(options = {}) {
1161
1446
  if (buffer.length > 8000) {
1162
1447
  buffer = buffer.slice(-6000);
1163
1448
  }
1164
- // Look for the OAuth URL pattern — it may be split across lines.
1165
- const match = buffer.match(/https:\/\/claude\.ai\/oauth\/authorize\?[^\s]*/);
1166
- if (!match)
1449
+ const normalizedUrl = normalizeClaudeOAuthUrl(buffer);
1450
+ if (!normalizedUrl)
1167
1451
  return data;
1168
- // Reassemble wrapped URL by stripping terminal-inserted whitespace.
1169
- const urlStart = buffer.indexOf(match[0]);
1170
- let raw = buffer.slice(urlStart);
1171
- const endPatterns = [/\n\s*\n/, /Paste code/, /\n\s*$/];
1172
- for (const pat of endPatterns) {
1173
- const endMatch = raw.match(pat);
1174
- if (endMatch && endMatch.index) {
1175
- raw = raw.slice(0, endMatch.index);
1176
- }
1177
- }
1178
- const cleanUrl = raw.replace(/\s+/g, '').trim();
1179
- if (cleanUrl.length > 50 && cleanUrl.startsWith('https://')) {
1180
- handled = true;
1181
- handleOAuthUrl(cleanUrl, { isRemote, hostPlatform, execSync });
1182
- }
1452
+ handled = true;
1453
+ handleOAuthUrl(normalizedUrl, { isRemote, hostPlatform, execSync });
1183
1454
  return data;
1184
1455
  },
1185
1456
  };
@@ -1307,51 +1578,56 @@ function renderStickyFooter(line) {
1307
1578
  const padded = trimmed.padEnd(cols, ' ');
1308
1579
  process.stdout.write(`\x1b7\x1b[${rows};1H\x1b[2K${padded}\x1b8`);
1309
1580
  }
1310
- // ── Agent credential extraction ───────────────────────────
1311
1581
  /**
1312
1582
  * Resolve agent credentials. Checks in order:
1313
1583
  * 1. Explicit --api-key flag (passed via apiKey parameter)
1314
- * 2. ANTHROPIC_API_KEY in host environment
1315
- * 3. macOS keychain (Claude Code OAuth tokens)
1584
+ * 2. macOS keychain (Claude Code OAuth tokens, Claude only)
1585
+ * 3. ANTHROPIC_API_KEY in host environment
1316
1586
  *
1317
1587
  * Returns env args to inject into the container.
1318
1588
  */
1319
- function getAgentTokenEnv(agent, apiKey) {
1589
+ function getAgentTokenEnv(agent, apiKey, deps = {}) {
1590
+ const env = deps.env ?? process.env;
1591
+ const platformFn = deps.platformFn ?? os_1.platform;
1592
+ const execSync = deps.execFileSyncFn ?? child_process_1.execFileSync;
1593
+ const ensureDir = deps.mkdirSyncFn ?? fs_1.mkdirSync;
1594
+ const writeFile = deps.writeFileSyncFn ?? fs_1.writeFileSync;
1320
1595
  // 1. Explicit API key from CLI flag
1321
1596
  if (apiKey) {
1322
1597
  log.success('Using API key from --api-key flag');
1323
1598
  return ['--env', `ANTHROPIC_API_KEY=${apiKey}`];
1324
1599
  }
1325
- // 2. Forward ANTHROPIC_API_KEY from host environment
1326
- if (process.env.ANTHROPIC_API_KEY) {
1327
- log.success('Forwarding ANTHROPIC_API_KEY from environment');
1328
- return ['--env', `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`];
1329
- }
1330
- // 3. macOS keychain extraction (local only)
1331
- if ((0, os_1.platform)() === 'darwin' && agent === 'claude') {
1600
+ // 2. macOS keychain extraction (local only, Claude)
1601
+ // Sync credentials file into sandbox home, but do not export ANTHROPIC_API_KEY.
1602
+ // Claude may prompt interactively when a custom ANTHROPIC_API_KEY is present,
1603
+ // which can stall UI launches.
1604
+ if (platformFn() === 'darwin' && agent === 'claude') {
1332
1605
  try {
1333
- const raw = (0, child_process_1.execFileSync)('security', [
1606
+ const raw = execSync('security', [
1334
1607
  'find-generic-password',
1335
1608
  '-s', 'Claude Code-credentials',
1336
1609
  '-w',
1337
1610
  ], { encoding: 'utf-8', timeout: 5000 }).trim();
1338
1611
  const creds = JSON.parse(raw);
1339
1612
  const token = creds.claudeAiOauth?.accessToken;
1340
- if (!token)
1613
+ if (token) {
1614
+ const sandboxHome = deps.sandboxHome ?? (0, config_js_1.getSandboxHome)();
1615
+ const claudeDir = (0, path_1.join)(sandboxHome, '.claude');
1616
+ ensureDir(claudeDir, { recursive: true });
1617
+ writeFile((0, path_1.join)(claudeDir, '.credentials.json'), raw, { mode: 0o600 });
1618
+ log.success('Synced credentials from macOS keychain');
1341
1619
  return [];
1342
- // Sync the full credential file into the sandbox home so
1343
- // Claude Code picks it up natively (no OAuth flow needed)
1344
- const sandboxHome = (0, config_js_1.getSandboxHome)();
1345
- const claudeDir = (0, path_1.join)(sandboxHome, '.claude');
1346
- (0, fs_1.mkdirSync)(claudeDir, { recursive: true });
1347
- (0, fs_1.writeFileSync)((0, path_1.join)(claudeDir, '.credentials.json'), raw, { mode: 0o600 });
1348
- log.success('Synced credentials from macOS keychain');
1349
- return ['--env', `ANTHROPIC_API_KEY=${token}`];
1620
+ }
1350
1621
  }
1351
1622
  catch {
1352
- return [];
1623
+ // Fall through to host env var fallback.
1353
1624
  }
1354
1625
  }
1626
+ // 3. Forward ANTHROPIC_API_KEY from host environment
1627
+ if (env.ANTHROPIC_API_KEY) {
1628
+ log.success('Forwarding ANTHROPIC_API_KEY from environment');
1629
+ return ['--env', `ANTHROPIC_API_KEY=${env.ANTHROPIC_API_KEY}`];
1630
+ }
1355
1631
  return [];
1356
1632
  }
1357
1633
  function formatStatusFooter(session, runtime, sessionId, image) {
@@ -1464,9 +1740,28 @@ async function startSession(session) {
1464
1740
  const footerLine = formatStatusFooter(session, runtime, sessionId, image);
1465
1741
  // Extract agent auth token (CLI flag → env var → macOS keychain)
1466
1742
  const tokenEnv = session.dryRun ? [] : getAgentTokenEnv(session.agent, session.apiKey);
1743
+ const bridgeCodexOauthForPodman = runtime === 'podman' && shouldBridgeCodexOauthForPodman(session.agent, session.config.network.mode);
1744
+ const needsCodexOauthStartupHeartbeat = bridgeCodexOauthForPodman && tokenEnv.length === 0;
1467
1745
  // Set up browser hook so OAuth URLs get opened on the host
1468
1746
  // Skip if we already have an API key (no OAuth needed)
1469
1747
  const browserHook = session.dryRun || tokenEnv.length > 0 ? undefined : setupBrowserHook();
1748
+ // Some Podman/appliance setups may leak host env vars into the container.
1749
+ // For Claude sessions without an explicit API-key auth source, clear
1750
+ // ANTHROPIC_API_KEY to avoid interactive "use custom key?" prompts.
1751
+ const claudeEnvGuard = (session.agent === 'claude' && tokenEnv.length === 0)
1752
+ ? ['--env', 'ANTHROPIC_API_KEY=']
1753
+ : [];
1754
+ const codexPodmanDarwinLoginWorkaround = (runtime === 'podman' &&
1755
+ session.agent === 'codex' &&
1756
+ (0, os_1.platform)() === 'darwin')
1757
+ ? ['--env', 'LABGATE_CODEX_PODMAN_DARWIN_WORKAROUND=1']
1758
+ : [];
1759
+ const runtimeEnvArgs = [
1760
+ ...claudeEnvGuard,
1761
+ ...codexPodmanDarwinLoginWorkaround,
1762
+ ...tokenEnv,
1763
+ ...(browserHook?.env ?? []),
1764
+ ];
1470
1765
  let cleanupSlurmHostProxy = () => { };
1471
1766
  // If the agent isn't installed in the persistent sandbox home yet, warn that first run can be slow.
1472
1767
  if (!session.dryRun) {
@@ -1521,18 +1816,21 @@ async function startSession(session) {
1521
1816
  else {
1522
1817
  sifPath = ensureSifImage(runtime, image);
1523
1818
  }
1524
- args = buildApptainerArgs(session, sifPath, sessionId, [...tokenEnv, ...(browserHook?.env ?? [])]);
1819
+ args = buildApptainerArgs(session, sifPath, sessionId, runtimeEnvArgs);
1525
1820
  }
1526
1821
  else {
1527
- if (shouldBridgeCodexOauthForPodman(session.agent, session.config.network.mode)) {
1822
+ if (bridgeCodexOauthForPodman) {
1528
1823
  const port = getCodexOauthCallbackPort();
1529
1824
  log.step(`Codex OAuth callback bridge enabled on localhost:${port} ` +
1530
1825
  '(Podman macOS uses bridge networking for callback compatibility).');
1826
+ if (needsCodexOauthStartupHeartbeat) {
1827
+ log.step('Launching Codex OAuth flow. Login output may take ~30s to appear.');
1828
+ }
1531
1829
  }
1532
1830
  if (!session.dryRun) {
1533
1831
  ensurePodmanImage(runtime, image);
1534
1832
  }
1535
- args = buildPodmanArgs(session, image, sessionId, [...tokenEnv, ...(browserHook?.env ?? [])], { tty: !!(process.stdout.isTTY && process.stdin.isTTY) });
1833
+ args = buildPodmanArgs(session, image, sessionId, runtimeEnvArgs, { tty: !!(process.stdout.isTTY && process.stdin.isTTY) });
1536
1834
  }
1537
1835
  if (session.dryRun) {
1538
1836
  prettyPrintCommand(runtime, args);
@@ -1588,7 +1886,7 @@ async function startSession(session) {
1588
1886
  const wantsSticky = footerMode === 'sticky';
1589
1887
  const needsOAuthPtyFallback = !!oauthInterceptor;
1590
1888
  const hasTty = !!(process.stdout.isTTY && process.stdin.isTTY);
1591
- const shouldUsePty = hasTty && (wantsSticky || needsOAuthPtyFallback);
1889
+ const shouldUsePty = hasTty && (wantsSticky || needsOAuthPtyFallback || needsCodexOauthStartupHeartbeat);
1592
1890
  if (shouldUsePty) {
1593
1891
  const pty = await loadPty();
1594
1892
  if (!pty) {
@@ -1598,6 +1896,9 @@ async function startSession(session) {
1598
1896
  else if (needsOAuthPtyFallback) {
1599
1897
  log.step('OAuth URL fallback interceptor unavailable (node-pty missing).');
1600
1898
  }
1899
+ else if (needsCodexOauthStartupHeartbeat) {
1900
+ log.step('Codex startup heartbeat unavailable (node-pty missing).');
1901
+ }
1601
1902
  }
1602
1903
  else {
1603
1904
  let runtimePath;
@@ -1633,6 +1934,9 @@ async function startSession(session) {
1633
1934
  // Continue below with non-PTY spawn.
1634
1935
  }
1635
1936
  else {
1937
+ const codexOauthHeartbeat = needsCodexOauthStartupHeartbeat
1938
+ ? startCodexOauthStartupHeartbeat()
1939
+ : null;
1636
1940
  let exited = false;
1637
1941
  const resizeHandler = () => {
1638
1942
  child.resize(process.stdout.columns || 80, process.stdout.rows || 24);
@@ -1645,6 +1949,7 @@ async function startSession(session) {
1645
1949
  renderStickyFooter(footerLine);
1646
1950
  }
1647
1951
  child.onData((data) => {
1952
+ codexOauthHeartbeat?.noteOutput(data);
1648
1953
  if (oauthInterceptor)
1649
1954
  oauthInterceptor.feed(data);
1650
1955
  process.stdout.write(data);
@@ -1667,6 +1972,7 @@ async function startSession(session) {
1667
1972
  const timeoutHandle = setupSessionTimeout(session, sessionId, runtime, () => exited, () => child.kill('SIGTERM'));
1668
1973
  child.onExit((event) => {
1669
1974
  exited = true;
1975
+ codexOauthHeartbeat?.stop();
1670
1976
  if (timeoutHandle)
1671
1977
  clearTimeout(timeoutHandle);
1672
1978
  browserHook?.cleanup();