labgate 0.5.31 → 0.5.33

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 (73) hide show
  1. package/README.md +50 -2
  2. package/dist/cli.js +533 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/lib/config.d.ts +11 -0
  5. package/dist/lib/config.js +45 -4
  6. package/dist/lib/config.js.map +1 -1
  7. package/dist/lib/container.d.ts +3 -3
  8. package/dist/lib/container.js +144 -12
  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-store.js +37 -3
  53. package/dist/lib/results-store.js.map +1 -1
  54. package/dist/lib/test/integration-harness.js +1 -1
  55. package/dist/lib/test/integration-harness.js.map +1 -1
  56. package/dist/lib/ui.html +5115 -2052
  57. package/dist/lib/ui.js +906 -39
  58. package/dist/lib/ui.js.map +1 -1
  59. package/dist/lib/web-terminal.js +4 -3
  60. package/dist/lib/web-terminal.js.map +1 -1
  61. package/dist/mcp-bundles/dataset-mcp.bundle.mjs +0 -8
  62. package/dist/mcp-bundles/display-mcp.bundle.mjs +30209 -0
  63. package/dist/mcp-bundles/explorer-mcp.bundle.mjs +40036 -0
  64. package/dist/mcp-bundles/results-mcp.bundle.mjs +30 -4
  65. package/package.json +3 -2
  66. package/templates/tsp-lab/API_CONTRACT.md +20 -0
  67. package/templates/tsp-lab/EVAL.md +20 -0
  68. package/templates/tsp-lab/PROBLEM.md +18 -0
  69. package/templates/tsp-lab/data/generate_instances.py +51 -0
  70. package/templates/tsp-lab/data/instances.jsonl +12 -0
  71. package/templates/tsp-lab/eval.py +148 -0
  72. package/templates/tsp-lab/solver.py +88 -0
  73. package/templates/tsp-lab/stub-patches/enable_two_opt.patch +14 -0
@@ -203,6 +203,8 @@ function cleanupLabgateInstructionFile(workdir, filename, deleteWhenEmpty) {
203
203
  }
204
204
  }
205
205
  function buildLabgateInstructionBlock(session) {
206
+ const agentLower = session.agent.toLowerCase();
207
+ const hasMcp = agentLower === 'claude' || agentLower === 'codex';
206
208
  const lines = [
207
209
  LABGATE_INSTRUCTION_START,
208
210
  '## LabGate Sandbox Context (Auto-Managed)',
@@ -233,7 +235,7 @@ function buildLabgateInstructionBlock(session) {
233
235
  lines.push('');
234
236
  lines.push('Use these dataset paths directly when the user references data by name.');
235
237
  }
236
- if (session.agent.toLowerCase() === 'claude') {
238
+ if (hasMcp) {
237
239
  lines.push('');
238
240
  lines.push('### Dataset Management');
239
241
  lines.push('- Use the `labgate-datasets` MCP tools to manage and explore datasets.');
@@ -244,13 +246,30 @@ function buildLabgateInstructionBlock(session) {
244
246
  lines.push('- Use the `labgate-results` MCP tools to record and revisit important outcomes.');
245
247
  lines.push('- Available tools: `list_results`, `register_result`, `get_result`, `update_result`, `delete_result`');
246
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.');
247
266
  }
248
267
  if (session.config.slurm.enabled) {
249
268
  lines.push('');
250
269
  lines.push('### SLURM Integration');
251
270
  lines.push('- SLURM commands (srun, sbatch, squeue, scancel) are available.');
252
271
  lines.push('- LabGate tracks your SLURM jobs automatically via squeue polling.');
253
- if (session.config.slurm.mcp_server && session.agent.toLowerCase() === 'claude') {
272
+ if (session.config.slurm.mcp_server && hasMcp) {
254
273
  lines.push('- Use the `labgate-cluster` MCP tools to inspect partitions, queue pressure, and available modules.');
255
274
  lines.push('- Available tools: `get_cluster_partitions`, `get_partition_limits`, `get_queue_pressure`, `find_module`');
256
275
  lines.push('- Use the `labgate-slurm` MCP tools to check job status and read output files.');
@@ -266,14 +285,64 @@ function buildLabgateInstructionBlock(session) {
266
285
  lines.push(LABGATE_INSTRUCTION_END);
267
286
  return lines.join('\n');
268
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
+ }
269
336
  function prepareMcpServers(session, options = {}) {
270
- if (session.agent.toLowerCase() !== 'claude')
337
+ const agentLower = session.agent.toLowerCase();
338
+ if (agentLower !== 'claude' && agentLower !== 'codex')
271
339
  return;
272
340
  try {
273
341
  const sandboxHome = (0, config_js_1.getSandboxHome)();
274
342
  const mcpDir = (0, path_1.join)(sandboxHome, '.mcp-servers');
275
343
  (0, fs_1.mkdirSync)(mcpDir, { recursive: true });
276
- // 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.
277
346
  const mcpConfigPath = (0, path_1.join)(sandboxHome, '.claude.json');
278
347
  let mcpConfig = {};
279
348
  try {
@@ -303,7 +372,7 @@ function prepareMcpServers(session, options = {}) {
303
372
  copyMcpNodePackage('bindings') &&
304
373
  copyMcpNodePackage('file-uri-to-path'));
305
374
  };
306
- // Dataset MCP server (always for Claude)
375
+ // Dataset MCP server (always)
307
376
  const datasetBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/dataset-mcp.bundle.mjs');
308
377
  if ((0, fs_1.existsSync)(datasetBundleSrc)) {
309
378
  (0, fs_1.copyFileSync)(datasetBundleSrc, (0, path_1.join)(mcpDir, 'dataset-mcp.bundle.mjs'));
@@ -321,7 +390,7 @@ function prepareMcpServers(session, options = {}) {
321
390
  args: [datasetMcpPath],
322
391
  };
323
392
  }
324
- // Results MCP server (always for Claude)
393
+ // Results MCP server (always)
325
394
  const resultsBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/results-mcp.bundle.mjs');
326
395
  if ((0, fs_1.existsSync)(resultsBundleSrc)) {
327
396
  (0, fs_1.copyFileSync)(resultsBundleSrc, (0, path_1.join)(mcpDir, 'results-mcp.bundle.mjs'));
@@ -338,6 +407,23 @@ function prepareMcpServers(session, options = {}) {
338
407
  args: [resultsMcpPath, '--db', (0, config_js_1.getResultsDbPath)()],
339
408
  };
340
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)()],
425
+ };
426
+ }
341
427
  // Cluster MCP server (when SLURM integration is enabled)
342
428
  if (session.config.slurm.enabled && session.config.slurm.mcp_server) {
343
429
  const clusterBundleSrc = (0, path_1.resolve)(__dirname, '../mcp-bundles/cluster-mcp.bundle.mjs');
@@ -389,8 +475,29 @@ function prepareMcpServers(session, options = {}) {
389
475
  else {
390
476
  delete mcpConfig.mcpServers['labgate-slurm'];
391
477
  }
392
- (0, fs_1.writeFileSync)(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
393
- (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
+ }
394
501
  }
395
502
  catch {
396
503
  // Best effort — MCP registration failure is non-fatal
@@ -984,6 +1091,7 @@ function buildApptainerArgs(session, sifPath, sessionId, tokenEnv = []) {
984
1091
  // ── LabGate config for in-container MCP servers ──
985
1092
  ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--bind', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
986
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`] : []),
987
1095
  ...(config.slurm.enabled && (0, fs_1.existsSync)((0, config_js_1.getSlurmDbPath)())
988
1096
  ? ['--bind', `${(0, config_js_1.getSlurmDbPath)()}:/labgate-config/slurm.db`] : []),
989
1097
  ...(config.slurm.enabled && (0, fs_1.existsSync)(`${(0, config_js_1.getSlurmDbPath)()}.json`)
@@ -1047,6 +1155,7 @@ function buildPodmanArgs(session, image, sessionId, tokenEnv = [], options = { t
1047
1155
  // ── LabGate config for in-container MCP servers ──
1048
1156
  ...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--volume', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
1049
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`] : []),
1050
1159
  ...(config.slurm.enabled && (0, fs_1.existsSync)((0, config_js_1.getSlurmDbPath)())
1051
1160
  ? ['--volume', `${(0, config_js_1.getSlurmDbPath)()}:/labgate-config/slurm.db`] : []),
1052
1161
  ...(config.slurm.enabled && (0, fs_1.existsSync)(`${(0, config_js_1.getSlurmDbPath)()}.json`)
@@ -1176,7 +1285,7 @@ function buildEntrypoint(agent) {
1176
1285
  }
1177
1286
  const preLaunchLines = [];
1178
1287
  if (agent === 'codex') {
1179
- 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');
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" ] || [ "${LABGATE_CODEX_APPTAINER_REMOTE_DEVICE_AUTH:-}" = "1" ]; then', ' if ! codex login status >/dev/null 2>&1; then', ' if [ "${LABGATE_CODEX_PODMAN_DARWIN_WORKAROUND:-}" = "1" ]; then', ' echo "[labgate] Starting Codex device-code login (Podman macOS callback workaround)..."', ' else', ' echo "[labgate] Starting Codex device-code login (Apptainer remote login flow)..."', ' fi', ' codex login --device-auth', ' fi', 'fi');
1180
1289
  }
1181
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}`);
1182
1291
  return lines.join('\n');
@@ -1206,6 +1315,21 @@ function osc52Copy(text) {
1206
1315
  const tty = process.stderr.isTTY ? process.stderr : process.stdout;
1207
1316
  tty.write(`\x1b]52;c;${encoded}\x07`);
1208
1317
  }
1318
+ function supportsTerminalHyperlinks() {
1319
+ if (!(process.stderr.isTTY ?? false))
1320
+ return false;
1321
+ if (process.env.LABGATE_HYPERLINKS === '0')
1322
+ return false;
1323
+ if ((process.env.TERM || '').toLowerCase() === 'dumb')
1324
+ return false;
1325
+ return true;
1326
+ }
1327
+ function formatTerminalHyperlink(url) {
1328
+ if (!supportsTerminalHyperlinks())
1329
+ return url;
1330
+ const safe = String(url || '').replace(/[\u001b\u0007]/g, '');
1331
+ return `\u001b]8;;${safe}\u0007${url}\u001b]8;;\u0007`;
1332
+ }
1209
1333
  const OAUTH_URL_DEDUPE_WINDOW_MS = 20_000;
1210
1334
  let lastHandledOAuthUrl = '';
1211
1335
  let lastHandledOAuthAt = 0;
@@ -1278,6 +1402,7 @@ function handleOAuthUrl(url, options) {
1278
1402
  const normalizedUrl = normalizeClaudeOAuthUrl(url);
1279
1403
  if (!normalizedUrl)
1280
1404
  return;
1405
+ const displayUrl = formatTerminalHyperlink(normalizedUrl);
1281
1406
  const now = Date.now();
1282
1407
  if (normalizedUrl === lastHandledOAuthUrl && (now - lastHandledOAuthAt) < OAUTH_URL_DEDUPE_WINDOW_MS) {
1283
1408
  return;
@@ -1287,7 +1412,7 @@ function handleOAuthUrl(url, options) {
1287
1412
  if (options.isRemote) {
1288
1413
  // SSH / headless: push URL to local clipboard via OSC 52 if supported.
1289
1414
  osc52Copy(normalizedUrl);
1290
- log.info(`Login URL:\n${normalizedUrl}`);
1415
+ log.info(`Login URL:\n${displayUrl}`);
1291
1416
  log.info('Tried to copy URL via terminal clipboard (OSC 52).');
1292
1417
  log.info('Open it in your local browser, then paste the code back here.');
1293
1418
  return;
@@ -1303,7 +1428,7 @@ function handleOAuthUrl(url, options) {
1303
1428
  }
1304
1429
  catch {
1305
1430
  log.warn('Could not auto-open browser. URL copied to clipboard (if available).');
1306
- log.info(`Login URL:\n${normalizedUrl}`);
1431
+ log.info(`Login URL:\n${displayUrl}`);
1307
1432
  }
1308
1433
  return;
1309
1434
  }
@@ -1315,7 +1440,7 @@ function handleOAuthUrl(url, options) {
1315
1440
  catch {
1316
1441
  osc52Copy(normalizedUrl);
1317
1442
  log.warn('Could not auto-open browser. Tried clipboard copy via terminal.');
1318
- log.info(`Login URL:\n${normalizedUrl}`);
1443
+ log.info(`Login URL:\n${displayUrl}`);
1319
1444
  }
1320
1445
  }
1321
1446
  /**
@@ -1647,9 +1772,16 @@ async function startSession(session) {
1647
1772
  (0, os_1.platform)() === 'darwin')
1648
1773
  ? ['--env', 'LABGATE_CODEX_PODMAN_DARWIN_WORKAROUND=1']
1649
1774
  : [];
1775
+ const codexApptainerRemoteDeviceAuth = (runtime === 'apptainer' &&
1776
+ session.agent === 'codex' &&
1777
+ tokenEnv.length === 0 &&
1778
+ !canOpenBrowser())
1779
+ ? ['--env', 'LABGATE_CODEX_APPTAINER_REMOTE_DEVICE_AUTH=1']
1780
+ : [];
1650
1781
  const runtimeEnvArgs = [
1651
1782
  ...claudeEnvGuard,
1652
1783
  ...codexPodmanDarwinLoginWorkaround,
1784
+ ...codexApptainerRemoteDeviceAuth,
1653
1785
  ...tokenEnv,
1654
1786
  ...(browserHook?.env ?? []),
1655
1787
  ];