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.
@@ -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
- return agent === 'codex' && networkMode !== 'none' && (0, os_1.platform)() === 'darwin';
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', `127.0.0.1:${codexOauthPort}:${codexOauthPort}`]
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
- lines.push(`if ! command -v ${setup.bin} >/dev/null 2>&1; then`, ` ${setup.installer}`, 'fi', `echo "[labgate] Starting ${setup.bin} in /work"`, `exec ${setup.bin}`);
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 (url === lastHandledOAuthUrl && (now - lastHandledOAuthAt) < OAUTH_URL_DEDUPE_WINDOW_MS) {
1282
+ if (normalizedUrl === lastHandledOAuthUrl && (now - lastHandledOAuthAt) < OAUTH_URL_DEDUPE_WINDOW_MS) {
1107
1283
  return;
1108
1284
  }
1109
- lastHandledOAuthUrl = url;
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(url);
1114
- log.info(`Login URL:\n${url}`);
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: url, stdio: ['pipe', 'ignore', 'ignore'] });
1297
+ options.execSync('pbcopy', [], { input: normalizedUrl, stdio: ['pipe', 'ignore', 'ignore'] });
1122
1298
  }
1123
1299
  catch { /* best effort */ }
1124
1300
  try {
1125
- options.execSync('open', [url], { stdio: 'ignore' });
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${url}`);
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', [url], { stdio: 'ignore' });
1312
+ options.execSync('xdg-open', [normalizedUrl], { stdio: 'ignore' });
1137
1313
  log.success('Login URL opened in browser');
1138
1314
  }
1139
1315
  catch {
1140
- osc52Copy(url);
1316
+ osc52Copy(normalizedUrl);
1141
1317
  log.warn('Could not auto-open browser. Tried clipboard copy via terminal.');
1142
- log.info(`Login URL:\n${url}`);
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
- // Look for the OAuth URL pattern — it may be split across lines.
1165
- const match = buffer.match(/https:\/\/claude\.ai\/oauth\/authorize\?[^\s]*/);
1166
- if (!match)
1340
+ const normalizedUrl = normalizeClaudeOAuthUrl(buffer);
1341
+ if (!normalizedUrl)
1167
1342
  return data;
1168
- // Reassemble wrapped URL by stripping terminal-inserted whitespace.
1169
- const urlStart = buffer.indexOf(match[0]);
1170
- let raw = buffer.slice(urlStart);
1171
- const endPatterns = [/\n\s*\n/, /Paste code/, /\n\s*$/];
1172
- for (const pat of endPatterns) {
1173
- const endMatch = raw.match(pat);
1174
- if (endMatch && endMatch.index) {
1175
- raw = raw.slice(0, endMatch.index);
1176
- }
1177
- }
1178
- const cleanUrl = raw.replace(/\s+/g, '').trim();
1179
- if (cleanUrl.length > 50 && cleanUrl.startsWith('https://')) {
1180
- handled = true;
1181
- handleOAuthUrl(cleanUrl, { isRemote, hostPlatform, execSync });
1182
- }
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. ANTHROPIC_API_KEY in host environment
1315
- * 3. macOS keychain (Claude Code OAuth tokens)
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. Forward ANTHROPIC_API_KEY from host environment
1326
- if (process.env.ANTHROPIC_API_KEY) {
1327
- log.success('Forwarding ANTHROPIC_API_KEY from environment');
1328
- return ['--env', `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`];
1329
- }
1330
- // 3. macOS keychain extraction (local only)
1331
- if ((0, os_1.platform)() === 'darwin' && agent === 'claude') {
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 = (0, child_process_1.execFileSync)('security', [
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 (!token)
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
- // Sync the full credential file into the sandbox home so
1343
- // Claude Code picks it up natively (no OAuth flow needed)
1344
- const sandboxHome = (0, config_js_1.getSandboxHome)();
1345
- const claudeDir = (0, path_1.join)(sandboxHome, '.claude');
1346
- (0, fs_1.mkdirSync)(claudeDir, { recursive: true });
1347
- (0, fs_1.writeFileSync)((0, path_1.join)(claudeDir, '.credentials.json'), raw, { mode: 0o600 });
1348
- log.success('Synced credentials from macOS keychain');
1349
- return ['--env', `ANTHROPIC_API_KEY=${token}`];
1511
+ }
1350
1512
  }
1351
1513
  catch {
1352
- return [];
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, [...tokenEnv, ...(browserHook?.env ?? [])]);
1710
+ args = buildApptainerArgs(session, sifPath, sessionId, runtimeEnvArgs);
1525
1711
  }
1526
1712
  else {
1527
- if (shouldBridgeCodexOauthForPodman(session.agent, session.config.network.mode)) {
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, [...tokenEnv, ...(browserHook?.env ?? [])], { tty: !!(process.stdout.isTTY && process.stdin.isTTY) });
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();