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.
- package/README.md +48 -0
- package/dist/cli.js +616 -0
- package/dist/cli.js.map +1 -1
- package/dist/lib/config.d.ts +11 -0
- package/dist/lib/config.js +44 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/container.d.ts +22 -3
- package/dist/lib/container.js +373 -67
- package/dist/lib/container.js.map +1 -1
- package/dist/lib/display-mcp.d.ts +10 -0
- package/dist/lib/display-mcp.js +160 -0
- package/dist/lib/display-mcp.js.map +1 -0
- package/dist/lib/display-store.d.ts +24 -0
- package/dist/lib/display-store.js +150 -0
- package/dist/lib/display-store.js.map +1 -0
- package/dist/lib/explorer-autopilot.d.ts +16 -0
- package/dist/lib/explorer-autopilot.js +573 -0
- package/dist/lib/explorer-autopilot.js.map +1 -0
- package/dist/lib/explorer-claude.d.ts +16 -0
- package/dist/lib/explorer-claude.js +361 -0
- package/dist/lib/explorer-claude.js.map +1 -0
- package/dist/lib/explorer-compare.d.ts +9 -0
- package/dist/lib/explorer-compare.js +190 -0
- package/dist/lib/explorer-compare.js.map +1 -0
- package/dist/lib/explorer-eval.d.ts +23 -0
- package/dist/lib/explorer-eval.js +161 -0
- package/dist/lib/explorer-eval.js.map +1 -0
- package/dist/lib/explorer-gc.d.ts +11 -0
- package/dist/lib/explorer-gc.js +304 -0
- package/dist/lib/explorer-gc.js.map +1 -0
- package/dist/lib/explorer-git.d.ts +14 -0
- package/dist/lib/explorer-git.js +136 -0
- package/dist/lib/explorer-git.js.map +1 -0
- package/dist/lib/explorer-lock.d.ts +5 -0
- package/dist/lib/explorer-lock.js +100 -0
- package/dist/lib/explorer-lock.js.map +1 -0
- package/dist/lib/explorer-mcp.d.ts +11 -0
- package/dist/lib/explorer-mcp.js +611 -0
- package/dist/lib/explorer-mcp.js.map +1 -0
- package/dist/lib/explorer-retention.d.ts +4 -0
- package/dist/lib/explorer-retention.js +58 -0
- package/dist/lib/explorer-retention.js.map +1 -0
- package/dist/lib/explorer-store.d.ts +77 -0
- package/dist/lib/explorer-store.js +950 -0
- package/dist/lib/explorer-store.js.map +1 -0
- package/dist/lib/explorer-types.d.ts +161 -0
- package/dist/lib/explorer-types.js +3 -0
- package/dist/lib/explorer-types.js.map +1 -0
- package/dist/lib/explorer.d.ts +31 -0
- package/dist/lib/explorer.js +247 -0
- package/dist/lib/explorer.js.map +1 -0
- package/dist/lib/results-mcp.d.ts +2 -2
- package/dist/lib/results-mcp.js +26 -4
- package/dist/lib/results-mcp.js.map +1 -1
- package/dist/lib/results-store.d.ts +1 -0
- package/dist/lib/results-store.js +87 -3
- package/dist/lib/results-store.js.map +1 -1
- package/dist/lib/runtime.d.ts +6 -0
- package/dist/lib/runtime.js +46 -19
- package/dist/lib/runtime.js.map +1 -1
- package/dist/lib/test/integration-harness.js +1 -1
- package/dist/lib/test/integration-harness.js.map +1 -1
- package/dist/lib/ui.d.ts +1 -0
- package/dist/lib/ui.html +11231 -4370
- package/dist/lib/ui.js +2564 -277
- package/dist/lib/ui.js.map +1 -1
- package/dist/lib/web-terminal.d.ts +13 -0
- package/dist/lib/web-terminal.js +118 -15
- package/dist/lib/web-terminal.js.map +1 -1
- package/dist/mcp-bundles/display-mcp.bundle.mjs +30209 -0
- package/dist/mcp-bundles/explorer-mcp.bundle.mjs +40044 -0
- package/dist/mcp-bundles/results-mcp.bundle.mjs +100 -7
- package/package.json +4 -3
- package/templates/tsp-lab/API_CONTRACT.md +20 -0
- package/templates/tsp-lab/EVAL.md +20 -0
- package/templates/tsp-lab/PROBLEM.md +18 -0
- package/templates/tsp-lab/data/generate_instances.py +51 -0
- package/templates/tsp-lab/data/instances.jsonl +12 -0
- package/templates/tsp-lab/eval.py +148 -0
- package/templates/tsp-lab/solver.py +88 -0
- package/templates/tsp-lab/stub-patches/enable_two_opt.patch +14 -0
package/dist/lib/container.js
CHANGED
|
@@ -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 (
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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 (
|
|
1391
|
+
if (normalizedUrl === lastHandledOAuthUrl && (now - lastHandledOAuthAt) < OAUTH_URL_DEDUPE_WINDOW_MS) {
|
|
1107
1392
|
return;
|
|
1108
1393
|
}
|
|
1109
|
-
lastHandledOAuthUrl =
|
|
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(
|
|
1114
|
-
log.info(`Login URL:\n${
|
|
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:
|
|
1406
|
+
options.execSync('pbcopy', [], { input: normalizedUrl, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
1122
1407
|
}
|
|
1123
1408
|
catch { /* best effort */ }
|
|
1124
1409
|
try {
|
|
1125
|
-
options.execSync('open', [
|
|
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${
|
|
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', [
|
|
1421
|
+
options.execSync('xdg-open', [normalizedUrl], { stdio: 'ignore' });
|
|
1137
1422
|
log.success('Login URL opened in browser');
|
|
1138
1423
|
}
|
|
1139
1424
|
catch {
|
|
1140
|
-
osc52Copy(
|
|
1425
|
+
osc52Copy(normalizedUrl);
|
|
1141
1426
|
log.warn('Could not auto-open browser. Tried clipboard copy via terminal.');
|
|
1142
|
-
log.info(`Login URL:\n${
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
if (!match)
|
|
1449
|
+
const normalizedUrl = normalizeClaudeOAuthUrl(buffer);
|
|
1450
|
+
if (!normalizedUrl)
|
|
1167
1451
|
return data;
|
|
1168
|
-
|
|
1169
|
-
|
|
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.
|
|
1315
|
-
* 3.
|
|
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.
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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 = (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1819
|
+
args = buildApptainerArgs(session, sifPath, sessionId, runtimeEnvArgs);
|
|
1525
1820
|
}
|
|
1526
1821
|
else {
|
|
1527
|
-
if (
|
|
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,
|
|
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();
|