labgate 0.5.29 → 0.5.31
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 +6 -2
- package/dist/cli.js +101 -0
- package/dist/cli.js.map +1 -1
- package/dist/lib/container.d.ts +19 -0
- package/dist/lib/container.js +256 -59
- package/dist/lib/container.js.map +1 -1
- package/dist/lib/init.d.ts +1 -0
- package/dist/lib/init.js +68 -10
- package/dist/lib/init.js.map +1 -1
- 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 +50 -0
- 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/ui.d.ts +2 -0
- package/dist/lib/ui.html +7498 -3438
- package/dist/lib/ui.js +1555 -128
- package/dist/lib/ui.js.map +1 -1
- package/dist/lib/web-terminal.d.ts +13 -0
- package/dist/lib/web-terminal.js +126 -15
- package/dist/lib/web-terminal.js.map +1 -1
- package/dist/mcp-bundles/results-mcp.bundle.mjs +70 -3
- package/package.json +2 -2
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;
|
|
@@ -325,7 +327,7 @@ function prepareMcpServers(session, options = {}) {
|
|
|
325
327
|
(0, fs_1.copyFileSync)(resultsBundleSrc, (0, path_1.join)(mcpDir, 'results-mcp.bundle.mjs'));
|
|
326
328
|
mcpConfig.mcpServers['labgate-results'] = {
|
|
327
329
|
command: 'node',
|
|
328
|
-
args: ['/home/sandbox/.mcp-servers/results-mcp.bundle.mjs'],
|
|
330
|
+
args: ['/home/sandbox/.mcp-servers/results-mcp.bundle.mjs', '--db', '/labgate-config/results.json'],
|
|
329
331
|
env,
|
|
330
332
|
};
|
|
331
333
|
}
|
|
@@ -333,7 +335,7 @@ function prepareMcpServers(session, options = {}) {
|
|
|
333
335
|
const resultsMcpPath = (0, path_1.resolve)(__dirname, 'results-mcp.js');
|
|
334
336
|
mcpConfig.mcpServers['labgate-results'] = {
|
|
335
337
|
command: 'node',
|
|
336
|
-
args: [resultsMcpPath],
|
|
338
|
+
args: [resultsMcpPath, '--db', (0, config_js_1.getResultsDbPath)()],
|
|
337
339
|
};
|
|
338
340
|
}
|
|
339
341
|
// Cluster MCP server (when SLURM integration is enabled)
|
|
@@ -803,6 +805,7 @@ function prepareCommonArgs(session, sessionId, tokenEnv) {
|
|
|
803
805
|
'--env', `LABGATE_NETWORK_MODE=${config.network.mode}`,
|
|
804
806
|
'--env', `LABGATE_DATASET_COUNT=${datasetCount}`,
|
|
805
807
|
'--env', `LABGATE_RESULT_COUNT=${resultCount}`,
|
|
808
|
+
'--env', 'LABGATE_RESULTS_DB_PATH=/labgate-config/results.json',
|
|
806
809
|
// HOME is set inside the entrypoint script (Apptainer rejects --env HOME)
|
|
807
810
|
...(commandBlacklist ? ['--env', `LABGATE_CMD_BLACKLIST=${commandBlacklist}`] : []),
|
|
808
811
|
...(dashboardUrl ? ['--env', `LABGATE_DASHBOARD_URL=${dashboardUrl}`] : []),
|
|
@@ -812,6 +815,23 @@ function prepareCommonArgs(session, sessionId, tokenEnv) {
|
|
|
812
815
|
];
|
|
813
816
|
return { blockedMounts, emptyDir, envArgs };
|
|
814
817
|
}
|
|
818
|
+
function ensureResultsDbPathForContainer() {
|
|
819
|
+
const resultsPath = (0, config_js_1.getResultsDbPath)();
|
|
820
|
+
try {
|
|
821
|
+
(0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(resultsPath));
|
|
822
|
+
if (!(0, fs_1.existsSync)(resultsPath)) {
|
|
823
|
+
(0, fs_1.writeFileSync)(resultsPath, JSON.stringify({ version: 1, results: [] }, null, 2) + '\n', {
|
|
824
|
+
encoding: 'utf-8',
|
|
825
|
+
mode: config_js_1.PRIVATE_FILE_MODE,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
(0, config_js_1.ensurePrivateFile)(resultsPath);
|
|
829
|
+
return resultsPath;
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
return (0, fs_1.existsSync)(resultsPath) ? resultsPath : null;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
815
835
|
function getRegisteredResultsCount() {
|
|
816
836
|
try {
|
|
817
837
|
const resultsPath = (0, config_js_1.getResultsDbPath)();
|
|
@@ -825,6 +845,10 @@ function getRegisteredResultsCount() {
|
|
|
825
845
|
}
|
|
826
846
|
}
|
|
827
847
|
const DEFAULT_CODEX_OAUTH_CALLBACK_PORT = 1455;
|
|
848
|
+
const CODEX_OAUTH_STARTUP_HEARTBEAT_MS = 10_000;
|
|
849
|
+
const CODEX_OAUTH_STARTUP_HEARTBEAT_MAX_MS = 50_000;
|
|
850
|
+
const CODEX_OAUTH_STARTUP_SPINNER_MS = 125;
|
|
851
|
+
const CODEX_OAUTH_STARTUP_SPINNER_FRAMES = ['|', '/', '-', '\\'];
|
|
828
852
|
function getCodexOauthCallbackPort() {
|
|
829
853
|
const raw = (process.env.LABGATE_CODEX_OAUTH_CALLBACK_PORT || '').trim();
|
|
830
854
|
if (!raw)
|
|
@@ -838,13 +862,90 @@ function getCodexOauthCallbackPort() {
|
|
|
838
862
|
return port;
|
|
839
863
|
}
|
|
840
864
|
function shouldBridgeCodexOauthForPodman(agent, networkMode) {
|
|
841
|
-
|
|
865
|
+
if (agent !== 'codex' || networkMode === 'none' || (0, os_1.platform)() !== 'darwin')
|
|
866
|
+
return false;
|
|
867
|
+
// Default off: the macOS Podman path now uses device-auth fallback, and forcing
|
|
868
|
+
// a fixed localhost publish often fails with "proxy already running" in rootless
|
|
869
|
+
// Podman. Keep an explicit opt-in for troubleshooting legacy callback flows.
|
|
870
|
+
const optIn = (process.env.LABGATE_CODEX_PODMAN_OAUTH_BRIDGE || '').trim().toLowerCase();
|
|
871
|
+
return optIn === '1' || optIn === 'true' || optIn === 'yes';
|
|
872
|
+
}
|
|
873
|
+
function buildCodexOauthPublishSpec(port) {
|
|
874
|
+
// Podman macOS rootless networking does not allow publishing the same host
|
|
875
|
+
// port for both 127.0.0.1 and ::1 simultaneously (rootlessport conflict).
|
|
876
|
+
// Publishing without an explicit host IP gives dual-stack localhost reachability.
|
|
877
|
+
return `${port}:${port}`;
|
|
878
|
+
}
|
|
879
|
+
function startCodexOauthStartupHeartbeat() {
|
|
880
|
+
const startedAt = Date.now();
|
|
881
|
+
let stopped = false;
|
|
882
|
+
let sawOutput = false;
|
|
883
|
+
let interval = null;
|
|
884
|
+
let maxTimer = null;
|
|
885
|
+
let spinnerFrame = 0;
|
|
886
|
+
let spinnerActive = false;
|
|
887
|
+
const useSpinner = !!(process.stderr.isTTY && process.stdout.isTTY);
|
|
888
|
+
const renderSpinner = () => {
|
|
889
|
+
const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAt) / 1000));
|
|
890
|
+
const frame = CODEX_OAUTH_STARTUP_SPINNER_FRAMES[spinnerFrame];
|
|
891
|
+
spinnerFrame = (spinnerFrame + 1) % CODEX_OAUTH_STARTUP_SPINNER_FRAMES.length;
|
|
892
|
+
process.stderr.write(`\r\x1b[2K${log.dim('›')} Waiting for Codex login output... ${frame} ${elapsedSeconds}s`);
|
|
893
|
+
spinnerActive = true;
|
|
894
|
+
};
|
|
895
|
+
const stop = () => {
|
|
896
|
+
if (stopped)
|
|
897
|
+
return;
|
|
898
|
+
stopped = true;
|
|
899
|
+
if (interval) {
|
|
900
|
+
clearInterval(interval);
|
|
901
|
+
interval = null;
|
|
902
|
+
}
|
|
903
|
+
if (maxTimer) {
|
|
904
|
+
clearTimeout(maxTimer);
|
|
905
|
+
maxTimer = null;
|
|
906
|
+
}
|
|
907
|
+
if (spinnerActive) {
|
|
908
|
+
process.stderr.write('\r\x1b[2K');
|
|
909
|
+
spinnerActive = false;
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
if (useSpinner) {
|
|
913
|
+
renderSpinner();
|
|
914
|
+
interval = setInterval(() => {
|
|
915
|
+
if (stopped || sawOutput)
|
|
916
|
+
return;
|
|
917
|
+
renderSpinner();
|
|
918
|
+
}, CODEX_OAUTH_STARTUP_SPINNER_MS);
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
interval = setInterval(() => {
|
|
922
|
+
if (stopped || sawOutput)
|
|
923
|
+
return;
|
|
924
|
+
const elapsedSeconds = Math.max(1, Math.floor((Date.now() - startedAt) / 1000));
|
|
925
|
+
log.step(`Still starting Codex OAuth flow... (${elapsedSeconds}s elapsed)`);
|
|
926
|
+
}, CODEX_OAUTH_STARTUP_HEARTBEAT_MS);
|
|
927
|
+
}
|
|
928
|
+
maxTimer = setTimeout(() => stop(), CODEX_OAUTH_STARTUP_HEARTBEAT_MAX_MS);
|
|
929
|
+
interval.unref();
|
|
930
|
+
maxTimer.unref();
|
|
931
|
+
return {
|
|
932
|
+
noteOutput(data) {
|
|
933
|
+
if (stopped || sawOutput)
|
|
934
|
+
return;
|
|
935
|
+
if (!data || data.trim().length === 0)
|
|
936
|
+
return;
|
|
937
|
+
sawOutput = true;
|
|
938
|
+
stop();
|
|
939
|
+
},
|
|
940
|
+
stop,
|
|
941
|
+
};
|
|
842
942
|
}
|
|
843
943
|
// ── Build Apptainer arguments ─────────────────────────────
|
|
844
944
|
function buildApptainerArgs(session, sifPath, sessionId, tokenEnv = []) {
|
|
845
945
|
const { agent, workdir, config } = session;
|
|
846
946
|
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
847
947
|
const { blockedMounts, emptyDir, envArgs } = prepareCommonArgs(session, sessionId, tokenEnv);
|
|
948
|
+
const resultsDbPath = ensureResultsDbPathForContainer();
|
|
848
949
|
// SLURM auth passthrough: bind host munged socket dir when present.
|
|
849
950
|
// This enables staged SLURM client commands inside the container to authenticate.
|
|
850
951
|
const mungeBinds = [];
|
|
@@ -882,6 +983,7 @@ function buildApptainerArgs(session, sifPath, sessionId, tokenEnv = []) {
|
|
|
882
983
|
}),
|
|
883
984
|
// ── LabGate config for in-container MCP servers ──
|
|
884
985
|
...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--bind', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
|
|
986
|
+
...(resultsDbPath ? ['--bind', `${resultsDbPath}:/labgate-config/results.json`] : []),
|
|
885
987
|
...(config.slurm.enabled && (0, fs_1.existsSync)((0, config_js_1.getSlurmDbPath)())
|
|
886
988
|
? ['--bind', `${(0, config_js_1.getSlurmDbPath)()}:/labgate-config/slurm.db`] : []),
|
|
887
989
|
...(config.slurm.enabled && (0, fs_1.existsSync)(`${(0, config_js_1.getSlurmDbPath)()}.json`)
|
|
@@ -907,6 +1009,7 @@ function buildPodmanArgs(session, image, sessionId, tokenEnv = [], options = { t
|
|
|
907
1009
|
const { agent, workdir, config } = session;
|
|
908
1010
|
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
909
1011
|
const { blockedMounts, emptyDir, envArgs } = prepareCommonArgs(session, sessionId, tokenEnv);
|
|
1012
|
+
const resultsDbPath = ensureResultsDbPathForContainer();
|
|
910
1013
|
const bridgeCodexOauth = shouldBridgeCodexOauthForPodman(agent, config.network.mode);
|
|
911
1014
|
const codexOauthPort = bridgeCodexOauth ? getCodexOauthCallbackPort() : 0;
|
|
912
1015
|
const networkArgs = config.network.mode === 'none'
|
|
@@ -915,7 +1018,7 @@ function buildPodmanArgs(session, image, sessionId, tokenEnv = [], options = { t
|
|
|
915
1018
|
? (bridgeCodexOauth ? [] : ['--network', 'host'])
|
|
916
1019
|
: [];
|
|
917
1020
|
const publishArgs = bridgeCodexOauth
|
|
918
|
-
? ['--publish',
|
|
1021
|
+
? ['--publish', buildCodexOauthPublishSpec(codexOauthPort)]
|
|
919
1022
|
: [];
|
|
920
1023
|
return [
|
|
921
1024
|
'run',
|
|
@@ -943,6 +1046,7 @@ function buildPodmanArgs(session, image, sessionId, tokenEnv = [], options = { t
|
|
|
943
1046
|
}),
|
|
944
1047
|
// ── LabGate config for in-container MCP servers ──
|
|
945
1048
|
...((0, fs_1.existsSync)((0, config_js_1.getConfigPath)()) ? ['--volume', `${(0, config_js_1.getConfigPath)()}:/labgate-config/config.json`] : []),
|
|
1049
|
+
...(resultsDbPath ? ['--volume', `${resultsDbPath}:/labgate-config/results.json`] : []),
|
|
946
1050
|
...(config.slurm.enabled && (0, fs_1.existsSync)((0, config_js_1.getSlurmDbPath)())
|
|
947
1051
|
? ['--volume', `${(0, config_js_1.getSlurmDbPath)()}:/labgate-config/slurm.db`] : []),
|
|
948
1052
|
...(config.slurm.enabled && (0, fs_1.existsSync)(`${(0, config_js_1.getSlurmDbPath)()}.json`)
|
|
@@ -1070,7 +1174,11 @@ function buildEntrypoint(agent) {
|
|
|
1070
1174
|
// Configure a LabGate status line in Claude Code with a dashboard URL.
|
|
1071
1175
|
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
1176
|
}
|
|
1073
|
-
|
|
1177
|
+
const preLaunchLines = [];
|
|
1178
|
+
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');
|
|
1180
|
+
}
|
|
1181
|
+
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
1182
|
return lines.join('\n');
|
|
1075
1183
|
}
|
|
1076
1184
|
// ── Browser-open hook for OAuth (via sandbox home) ────────
|
|
@@ -1101,45 +1209,113 @@ function osc52Copy(text) {
|
|
|
1101
1209
|
const OAUTH_URL_DEDUPE_WINDOW_MS = 20_000;
|
|
1102
1210
|
let lastHandledOAuthUrl = '';
|
|
1103
1211
|
let lastHandledOAuthAt = 0;
|
|
1212
|
+
const CLAUDE_OAUTH_MARKER = 'https://claude.ai/oauth/authorize?';
|
|
1213
|
+
const CLAUDE_MANUAL_CODE_REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';
|
|
1214
|
+
const CLAUDE_LOCALHOST_REDIRECT_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
1215
|
+
function stripTerminalControlForOAuth(text) {
|
|
1216
|
+
return String(text || '')
|
|
1217
|
+
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
|
|
1218
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
|
|
1219
|
+
.replace(/\x1b[P^_][\s\S]*?\x1b\\/g, '')
|
|
1220
|
+
.replace(/\x1b[@-_]/g, '')
|
|
1221
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
|
|
1222
|
+
}
|
|
1223
|
+
function canonicalizeClaudeOAuthRedirectUri(parsed) {
|
|
1224
|
+
const rawRedirectUri = (parsed.searchParams.get('redirect_uri') || '').trim();
|
|
1225
|
+
if (!rawRedirectUri)
|
|
1226
|
+
return;
|
|
1227
|
+
try {
|
|
1228
|
+
const redirectUri = new URL(rawRedirectUri);
|
|
1229
|
+
const host = (redirectUri.hostname || '').trim().toLowerCase();
|
|
1230
|
+
if (!CLAUDE_LOCALHOST_REDIRECT_HOSTS.has(host))
|
|
1231
|
+
return;
|
|
1232
|
+
parsed.searchParams.set('redirect_uri', CLAUDE_MANUAL_CODE_REDIRECT_URI);
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
// Keep original redirect URI if it cannot be parsed.
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
function normalizeClaudeOAuthUrl(raw) {
|
|
1239
|
+
const stripped = stripTerminalControlForOAuth(raw);
|
|
1240
|
+
const start = stripped.lastIndexOf(CLAUDE_OAUTH_MARKER);
|
|
1241
|
+
if (start === -1)
|
|
1242
|
+
return null;
|
|
1243
|
+
let candidate = stripped.slice(start);
|
|
1244
|
+
const endPatterns = [/\n\s*Paste code/i, /\n\s*Browser did(?: not|n't) open\?/i, /\n\s*\n/];
|
|
1245
|
+
let end = candidate.length;
|
|
1246
|
+
for (const pat of endPatterns) {
|
|
1247
|
+
const match = candidate.match(pat);
|
|
1248
|
+
if (match && match.index !== undefined && match.index >= 0 && match.index < end) {
|
|
1249
|
+
end = match.index;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
candidate = candidate.slice(0, end).replace(/\s+/g, '').trim();
|
|
1253
|
+
const urlMatch = candidate.match(/^https:\/\/claude\.ai\/oauth\/authorize\?[A-Za-z0-9\-._~%!$&'()*+,;=:@/?#[\]]+/);
|
|
1254
|
+
if (!urlMatch)
|
|
1255
|
+
return null;
|
|
1256
|
+
try {
|
|
1257
|
+
const parsed = new URL(urlMatch[0]);
|
|
1258
|
+
if (parsed.protocol !== 'https:' || parsed.hostname !== 'claude.ai' || parsed.pathname !== '/oauth/authorize') {
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
const required = ['client_id', 'state', 'response_type', 'redirect_uri', 'scope', 'code_challenge', 'code_challenge_method'];
|
|
1262
|
+
for (const key of required) {
|
|
1263
|
+
if (!parsed.searchParams.get(key))
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
if ((parsed.searchParams.get('response_type') || '').trim().toLowerCase() !== 'code')
|
|
1267
|
+
return null;
|
|
1268
|
+
if ((parsed.searchParams.get('code_challenge_method') || '').trim().toUpperCase() !== 'S256')
|
|
1269
|
+
return null;
|
|
1270
|
+
canonicalizeClaudeOAuthRedirectUri(parsed);
|
|
1271
|
+
return parsed.toString();
|
|
1272
|
+
}
|
|
1273
|
+
catch {
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1104
1277
|
function handleOAuthUrl(url, options) {
|
|
1278
|
+
const normalizedUrl = normalizeClaudeOAuthUrl(url);
|
|
1279
|
+
if (!normalizedUrl)
|
|
1280
|
+
return;
|
|
1105
1281
|
const now = Date.now();
|
|
1106
|
-
if (
|
|
1282
|
+
if (normalizedUrl === lastHandledOAuthUrl && (now - lastHandledOAuthAt) < OAUTH_URL_DEDUPE_WINDOW_MS) {
|
|
1107
1283
|
return;
|
|
1108
1284
|
}
|
|
1109
|
-
lastHandledOAuthUrl =
|
|
1285
|
+
lastHandledOAuthUrl = normalizedUrl;
|
|
1110
1286
|
lastHandledOAuthAt = now;
|
|
1111
1287
|
if (options.isRemote) {
|
|
1112
1288
|
// SSH / headless: push URL to local clipboard via OSC 52 if supported.
|
|
1113
|
-
osc52Copy(
|
|
1114
|
-
log.info(`Login URL:\n${
|
|
1289
|
+
osc52Copy(normalizedUrl);
|
|
1290
|
+
log.info(`Login URL:\n${normalizedUrl}`);
|
|
1115
1291
|
log.info('Tried to copy URL via terminal clipboard (OSC 52).');
|
|
1116
1292
|
log.info('Open it in your local browser, then paste the code back here.');
|
|
1117
1293
|
return;
|
|
1118
1294
|
}
|
|
1119
1295
|
if (options.hostPlatform === 'darwin') {
|
|
1120
1296
|
try {
|
|
1121
|
-
options.execSync('pbcopy', [], { input:
|
|
1297
|
+
options.execSync('pbcopy', [], { input: normalizedUrl, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
1122
1298
|
}
|
|
1123
1299
|
catch { /* best effort */ }
|
|
1124
1300
|
try {
|
|
1125
|
-
options.execSync('open', [
|
|
1301
|
+
options.execSync('open', [normalizedUrl], { stdio: 'ignore' });
|
|
1126
1302
|
log.success('Login URL opened in browser');
|
|
1127
1303
|
}
|
|
1128
1304
|
catch {
|
|
1129
1305
|
log.warn('Could not auto-open browser. URL copied to clipboard (if available).');
|
|
1130
|
-
log.info(`Login URL:\n${
|
|
1306
|
+
log.info(`Login URL:\n${normalizedUrl}`);
|
|
1131
1307
|
}
|
|
1132
1308
|
return;
|
|
1133
1309
|
}
|
|
1134
1310
|
// Local Linux with display
|
|
1135
1311
|
try {
|
|
1136
|
-
options.execSync('xdg-open', [
|
|
1312
|
+
options.execSync('xdg-open', [normalizedUrl], { stdio: 'ignore' });
|
|
1137
1313
|
log.success('Login URL opened in browser');
|
|
1138
1314
|
}
|
|
1139
1315
|
catch {
|
|
1140
|
-
osc52Copy(
|
|
1316
|
+
osc52Copy(normalizedUrl);
|
|
1141
1317
|
log.warn('Could not auto-open browser. Tried clipboard copy via terminal.');
|
|
1142
|
-
log.info(`Login URL:\n${
|
|
1318
|
+
log.info(`Login URL:\n${normalizedUrl}`);
|
|
1143
1319
|
}
|
|
1144
1320
|
}
|
|
1145
1321
|
/**
|
|
@@ -1161,25 +1337,11 @@ function createOAuthInterceptor(options = {}) {
|
|
|
1161
1337
|
if (buffer.length > 8000) {
|
|
1162
1338
|
buffer = buffer.slice(-6000);
|
|
1163
1339
|
}
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
if (!match)
|
|
1340
|
+
const normalizedUrl = normalizeClaudeOAuthUrl(buffer);
|
|
1341
|
+
if (!normalizedUrl)
|
|
1167
1342
|
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
|
-
}
|
|
1343
|
+
handled = true;
|
|
1344
|
+
handleOAuthUrl(normalizedUrl, { isRemote, hostPlatform, execSync });
|
|
1183
1345
|
return data;
|
|
1184
1346
|
},
|
|
1185
1347
|
};
|
|
@@ -1307,51 +1469,56 @@ function renderStickyFooter(line) {
|
|
|
1307
1469
|
const padded = trimmed.padEnd(cols, ' ');
|
|
1308
1470
|
process.stdout.write(`\x1b7\x1b[${rows};1H\x1b[2K${padded}\x1b8`);
|
|
1309
1471
|
}
|
|
1310
|
-
// ── Agent credential extraction ───────────────────────────
|
|
1311
1472
|
/**
|
|
1312
1473
|
* Resolve agent credentials. Checks in order:
|
|
1313
1474
|
* 1. Explicit --api-key flag (passed via apiKey parameter)
|
|
1314
|
-
* 2.
|
|
1315
|
-
* 3.
|
|
1475
|
+
* 2. macOS keychain (Claude Code OAuth tokens, Claude only)
|
|
1476
|
+
* 3. ANTHROPIC_API_KEY in host environment
|
|
1316
1477
|
*
|
|
1317
1478
|
* Returns env args to inject into the container.
|
|
1318
1479
|
*/
|
|
1319
|
-
function getAgentTokenEnv(agent, apiKey) {
|
|
1480
|
+
function getAgentTokenEnv(agent, apiKey, deps = {}) {
|
|
1481
|
+
const env = deps.env ?? process.env;
|
|
1482
|
+
const platformFn = deps.platformFn ?? os_1.platform;
|
|
1483
|
+
const execSync = deps.execFileSyncFn ?? child_process_1.execFileSync;
|
|
1484
|
+
const ensureDir = deps.mkdirSyncFn ?? fs_1.mkdirSync;
|
|
1485
|
+
const writeFile = deps.writeFileSyncFn ?? fs_1.writeFileSync;
|
|
1320
1486
|
// 1. Explicit API key from CLI flag
|
|
1321
1487
|
if (apiKey) {
|
|
1322
1488
|
log.success('Using API key from --api-key flag');
|
|
1323
1489
|
return ['--env', `ANTHROPIC_API_KEY=${apiKey}`];
|
|
1324
1490
|
}
|
|
1325
|
-
// 2.
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
// 3. macOS keychain extraction (local only)
|
|
1331
|
-
if ((0, os_1.platform)() === 'darwin' && agent === 'claude') {
|
|
1491
|
+
// 2. macOS keychain extraction (local only, Claude)
|
|
1492
|
+
// Sync credentials file into sandbox home, but do not export ANTHROPIC_API_KEY.
|
|
1493
|
+
// Claude may prompt interactively when a custom ANTHROPIC_API_KEY is present,
|
|
1494
|
+
// which can stall UI launches.
|
|
1495
|
+
if (platformFn() === 'darwin' && agent === 'claude') {
|
|
1332
1496
|
try {
|
|
1333
|
-
const raw = (
|
|
1497
|
+
const raw = execSync('security', [
|
|
1334
1498
|
'find-generic-password',
|
|
1335
1499
|
'-s', 'Claude Code-credentials',
|
|
1336
1500
|
'-w',
|
|
1337
1501
|
], { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
1338
1502
|
const creds = JSON.parse(raw);
|
|
1339
1503
|
const token = creds.claudeAiOauth?.accessToken;
|
|
1340
|
-
if (
|
|
1504
|
+
if (token) {
|
|
1505
|
+
const sandboxHome = deps.sandboxHome ?? (0, config_js_1.getSandboxHome)();
|
|
1506
|
+
const claudeDir = (0, path_1.join)(sandboxHome, '.claude');
|
|
1507
|
+
ensureDir(claudeDir, { recursive: true });
|
|
1508
|
+
writeFile((0, path_1.join)(claudeDir, '.credentials.json'), raw, { mode: 0o600 });
|
|
1509
|
+
log.success('Synced credentials from macOS keychain');
|
|
1341
1510
|
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}`];
|
|
1511
|
+
}
|
|
1350
1512
|
}
|
|
1351
1513
|
catch {
|
|
1352
|
-
|
|
1514
|
+
// Fall through to host env var fallback.
|
|
1353
1515
|
}
|
|
1354
1516
|
}
|
|
1517
|
+
// 3. Forward ANTHROPIC_API_KEY from host environment
|
|
1518
|
+
if (env.ANTHROPIC_API_KEY) {
|
|
1519
|
+
log.success('Forwarding ANTHROPIC_API_KEY from environment');
|
|
1520
|
+
return ['--env', `ANTHROPIC_API_KEY=${env.ANTHROPIC_API_KEY}`];
|
|
1521
|
+
}
|
|
1355
1522
|
return [];
|
|
1356
1523
|
}
|
|
1357
1524
|
function formatStatusFooter(session, runtime, sessionId, image) {
|
|
@@ -1464,9 +1631,28 @@ async function startSession(session) {
|
|
|
1464
1631
|
const footerLine = formatStatusFooter(session, runtime, sessionId, image);
|
|
1465
1632
|
// Extract agent auth token (CLI flag → env var → macOS keychain)
|
|
1466
1633
|
const tokenEnv = session.dryRun ? [] : getAgentTokenEnv(session.agent, session.apiKey);
|
|
1634
|
+
const bridgeCodexOauthForPodman = runtime === 'podman' && shouldBridgeCodexOauthForPodman(session.agent, session.config.network.mode);
|
|
1635
|
+
const needsCodexOauthStartupHeartbeat = bridgeCodexOauthForPodman && tokenEnv.length === 0;
|
|
1467
1636
|
// Set up browser hook so OAuth URLs get opened on the host
|
|
1468
1637
|
// Skip if we already have an API key (no OAuth needed)
|
|
1469
1638
|
const browserHook = session.dryRun || tokenEnv.length > 0 ? undefined : setupBrowserHook();
|
|
1639
|
+
// Some Podman/appliance setups may leak host env vars into the container.
|
|
1640
|
+
// For Claude sessions without an explicit API-key auth source, clear
|
|
1641
|
+
// ANTHROPIC_API_KEY to avoid interactive "use custom key?" prompts.
|
|
1642
|
+
const claudeEnvGuard = (session.agent === 'claude' && tokenEnv.length === 0)
|
|
1643
|
+
? ['--env', 'ANTHROPIC_API_KEY=']
|
|
1644
|
+
: [];
|
|
1645
|
+
const codexPodmanDarwinLoginWorkaround = (runtime === 'podman' &&
|
|
1646
|
+
session.agent === 'codex' &&
|
|
1647
|
+
(0, os_1.platform)() === 'darwin')
|
|
1648
|
+
? ['--env', 'LABGATE_CODEX_PODMAN_DARWIN_WORKAROUND=1']
|
|
1649
|
+
: [];
|
|
1650
|
+
const runtimeEnvArgs = [
|
|
1651
|
+
...claudeEnvGuard,
|
|
1652
|
+
...codexPodmanDarwinLoginWorkaround,
|
|
1653
|
+
...tokenEnv,
|
|
1654
|
+
...(browserHook?.env ?? []),
|
|
1655
|
+
];
|
|
1470
1656
|
let cleanupSlurmHostProxy = () => { };
|
|
1471
1657
|
// If the agent isn't installed in the persistent sandbox home yet, warn that first run can be slow.
|
|
1472
1658
|
if (!session.dryRun) {
|
|
@@ -1521,18 +1707,21 @@ async function startSession(session) {
|
|
|
1521
1707
|
else {
|
|
1522
1708
|
sifPath = ensureSifImage(runtime, image);
|
|
1523
1709
|
}
|
|
1524
|
-
args = buildApptainerArgs(session, sifPath, sessionId,
|
|
1710
|
+
args = buildApptainerArgs(session, sifPath, sessionId, runtimeEnvArgs);
|
|
1525
1711
|
}
|
|
1526
1712
|
else {
|
|
1527
|
-
if (
|
|
1713
|
+
if (bridgeCodexOauthForPodman) {
|
|
1528
1714
|
const port = getCodexOauthCallbackPort();
|
|
1529
1715
|
log.step(`Codex OAuth callback bridge enabled on localhost:${port} ` +
|
|
1530
1716
|
'(Podman macOS uses bridge networking for callback compatibility).');
|
|
1717
|
+
if (needsCodexOauthStartupHeartbeat) {
|
|
1718
|
+
log.step('Launching Codex OAuth flow. Login output may take ~30s to appear.');
|
|
1719
|
+
}
|
|
1531
1720
|
}
|
|
1532
1721
|
if (!session.dryRun) {
|
|
1533
1722
|
ensurePodmanImage(runtime, image);
|
|
1534
1723
|
}
|
|
1535
|
-
args = buildPodmanArgs(session, image, sessionId,
|
|
1724
|
+
args = buildPodmanArgs(session, image, sessionId, runtimeEnvArgs, { tty: !!(process.stdout.isTTY && process.stdin.isTTY) });
|
|
1536
1725
|
}
|
|
1537
1726
|
if (session.dryRun) {
|
|
1538
1727
|
prettyPrintCommand(runtime, args);
|
|
@@ -1588,7 +1777,7 @@ async function startSession(session) {
|
|
|
1588
1777
|
const wantsSticky = footerMode === 'sticky';
|
|
1589
1778
|
const needsOAuthPtyFallback = !!oauthInterceptor;
|
|
1590
1779
|
const hasTty = !!(process.stdout.isTTY && process.stdin.isTTY);
|
|
1591
|
-
const shouldUsePty = hasTty && (wantsSticky || needsOAuthPtyFallback);
|
|
1780
|
+
const shouldUsePty = hasTty && (wantsSticky || needsOAuthPtyFallback || needsCodexOauthStartupHeartbeat);
|
|
1592
1781
|
if (shouldUsePty) {
|
|
1593
1782
|
const pty = await loadPty();
|
|
1594
1783
|
if (!pty) {
|
|
@@ -1598,6 +1787,9 @@ async function startSession(session) {
|
|
|
1598
1787
|
else if (needsOAuthPtyFallback) {
|
|
1599
1788
|
log.step('OAuth URL fallback interceptor unavailable (node-pty missing).');
|
|
1600
1789
|
}
|
|
1790
|
+
else if (needsCodexOauthStartupHeartbeat) {
|
|
1791
|
+
log.step('Codex startup heartbeat unavailable (node-pty missing).');
|
|
1792
|
+
}
|
|
1601
1793
|
}
|
|
1602
1794
|
else {
|
|
1603
1795
|
let runtimePath;
|
|
@@ -1633,6 +1825,9 @@ async function startSession(session) {
|
|
|
1633
1825
|
// Continue below with non-PTY spawn.
|
|
1634
1826
|
}
|
|
1635
1827
|
else {
|
|
1828
|
+
const codexOauthHeartbeat = needsCodexOauthStartupHeartbeat
|
|
1829
|
+
? startCodexOauthStartupHeartbeat()
|
|
1830
|
+
: null;
|
|
1636
1831
|
let exited = false;
|
|
1637
1832
|
const resizeHandler = () => {
|
|
1638
1833
|
child.resize(process.stdout.columns || 80, process.stdout.rows || 24);
|
|
@@ -1645,6 +1840,7 @@ async function startSession(session) {
|
|
|
1645
1840
|
renderStickyFooter(footerLine);
|
|
1646
1841
|
}
|
|
1647
1842
|
child.onData((data) => {
|
|
1843
|
+
codexOauthHeartbeat?.noteOutput(data);
|
|
1648
1844
|
if (oauthInterceptor)
|
|
1649
1845
|
oauthInterceptor.feed(data);
|
|
1650
1846
|
process.stdout.write(data);
|
|
@@ -1667,6 +1863,7 @@ async function startSession(session) {
|
|
|
1667
1863
|
const timeoutHandle = setupSessionTimeout(session, sessionId, runtime, () => exited, () => child.kill('SIGTERM'));
|
|
1668
1864
|
child.onExit((event) => {
|
|
1669
1865
|
exited = true;
|
|
1866
|
+
codexOauthHeartbeat?.stop();
|
|
1670
1867
|
if (timeoutHandle)
|
|
1671
1868
|
clearTimeout(timeoutHandle);
|
|
1672
1869
|
browserHook?.cleanup();
|