labgate 0.5.27 → 0.5.29

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/dist/lib/ui.js CHANGED
@@ -41,6 +41,7 @@ const os_1 = require("os");
41
41
  const child_process_1 = require("child_process");
42
42
  const util_1 = require("util");
43
43
  const crypto_1 = require("crypto");
44
+ const ws_1 = require("ws");
44
45
  const config_js_1 = require("./config.js");
45
46
  const container_js_1 = require("./container.js");
46
47
  const audit_js_1 = require("./audit.js");
@@ -49,10 +50,15 @@ const slurm_poller_js_1 = require("./slurm-poller.js");
49
50
  const results_store_js_1 = require("./results-store.js");
50
51
  const policy_js_1 = require("./policy.js");
51
52
  const license_js_1 = require("./license.js");
53
+ const web_terminal_js_1 = require("./web-terminal.js");
52
54
  const log = __importStar(require("./log.js"));
53
55
  const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
54
56
  const HTML_PATH = (0, path_1.resolve)(__dirname, '..', 'lib', 'ui.html');
55
57
  const FONTS_DIR = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', 'geist', 'dist', 'fonts');
58
+ const XTERM_CSS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'xterm', 'css', 'xterm.css');
59
+ const XTERM_JS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'xterm', 'lib', 'xterm.js');
60
+ const XTERM_FIT_JS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'addon-fit', 'lib', 'addon-fit.js');
61
+ const XTERM_WEB_LINKS_JS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'addon-web-links', 'lib', 'addon-web-links.js');
56
62
  const WRITE_TOKEN_PLACEHOLDER = '__LABGATE_WRITE_TOKEN__';
57
63
  const UI_WRITE_TOKEN = (0, crypto_1.randomBytes)(24).toString('hex');
58
64
  const UI_AUTH_COOKIE = 'labgate_ui_token';
@@ -60,16 +66,76 @@ const UI_SHORT_LINK_PREFIX = '/s/';
60
66
  const DASHBOARD_LINK_FILE = '.labgate-dashboard-url';
61
67
  const LABGATE_INSTRUCTION_START = '<!-- LABGATE_SESSION_INSTRUCTION_START -->';
62
68
  const LABGATE_INSTRUCTION_END = '<!-- LABGATE_SESSION_INSTRUCTION_END -->';
69
+ const IRIS_SAMPLE_DATASET_NAME = 'flowers-iris';
70
+ const IRIS_SAMPLE_SOURCE_URL = 'https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv';
71
+ const IRIS_SAMPLE_README = '# Iris Flowers Dataset (Sample)\n' +
72
+ '\n' +
73
+ 'Source: https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv\n' +
74
+ '\n' +
75
+ 'This dataset is a small sample for testing LabGate dataset registration.\n' +
76
+ 'Rows: 150 iris flower measurements across three classes.\n';
77
+ function resolveIrisSampleSourceUrl() {
78
+ const override = (process.env.LABGATE_IRIS_SAMPLE_SOURCE_URL || '').trim();
79
+ if (/^https?:\/\//i.test(override))
80
+ return override;
81
+ return IRIS_SAMPLE_SOURCE_URL;
82
+ }
63
83
  // ── SLURM module state (initialised in startUI when slurm.enabled) ──
64
84
  let slurmDB = null;
65
85
  let slurmPoller = null;
66
86
  let resultsStore = null;
87
+ const REQUIRED_SLURM_COMMANDS = ['sbatch', 'squeue', 'sacct', 'scancel'];
88
+ const webTerminalBridges = new Map();
67
89
  function getResultsStore() {
68
90
  if (!resultsStore) {
69
91
  resultsStore = new results_store_js_1.ResultsStore((0, config_js_1.getResultsDbPath)());
70
92
  }
71
93
  return resultsStore;
72
94
  }
95
+ function hasCommandInPath(command) {
96
+ const pathValue = (process.env.PATH || '').trim();
97
+ if (!pathValue)
98
+ return false;
99
+ const isWindows = (0, os_1.platform)() === 'win32';
100
+ const directories = pathValue.split(path_1.delimiter).filter(Boolean);
101
+ const hasExplicitExtension = /\.[^/\\]+$/.test(command);
102
+ const windowsExtensions = isWindows
103
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
104
+ .split(';')
105
+ .map((ext) => ext.trim().toLowerCase())
106
+ .filter(Boolean)
107
+ : [''];
108
+ for (const dir of directories) {
109
+ const candidates = isWindows
110
+ ? (hasExplicitExtension ? [command] : windowsExtensions.map((ext) => `${command}${ext}`))
111
+ : [command];
112
+ for (const candidate of candidates) {
113
+ const fullPath = (0, path_1.join)(dir, candidate);
114
+ if (!(0, fs_1.existsSync)(fullPath))
115
+ continue;
116
+ try {
117
+ const st = (0, fs_1.statSync)(fullPath);
118
+ if (!st.isFile())
119
+ continue;
120
+ if (isWindows)
121
+ return true;
122
+ (0, fs_1.accessSync)(fullPath, fs_1.constants.X_OK);
123
+ return true;
124
+ }
125
+ catch {
126
+ // Continue scanning PATH entries.
127
+ }
128
+ }
129
+ }
130
+ return false;
131
+ }
132
+ function getSlurmRuntimeStatus() {
133
+ const missingCommands = REQUIRED_SLURM_COMMANDS.filter((command) => !hasCommandInPath(command));
134
+ return {
135
+ available: missingCommands.length === 0,
136
+ missingCommands,
137
+ };
138
+ }
73
139
  function readBody(req) {
74
140
  return new Promise((resolve, reject) => {
75
141
  const chunks = [];
@@ -82,6 +148,103 @@ function json(res, data, status = 200) {
82
148
  res.writeHead(status, { 'Content-Type': 'application/json' });
83
149
  res.end(JSON.stringify(data));
84
150
  }
151
+ function normalizeEmailCandidate(value) {
152
+ if (typeof value !== 'string')
153
+ return null;
154
+ const match = value.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
155
+ if (!match)
156
+ return null;
157
+ return match[0].toLowerCase();
158
+ }
159
+ function deepFindEmail(value, depth = 0) {
160
+ if (depth > 6 || value === null || value === undefined)
161
+ return null;
162
+ const direct = normalizeEmailCandidate(value);
163
+ if (direct)
164
+ return direct;
165
+ if (Array.isArray(value)) {
166
+ for (const item of value) {
167
+ const found = deepFindEmail(item, depth + 1);
168
+ if (found)
169
+ return found;
170
+ }
171
+ return null;
172
+ }
173
+ if (typeof value !== 'object')
174
+ return null;
175
+ const record = value;
176
+ const keys = Object.keys(record);
177
+ for (const key of keys) {
178
+ if (!/email|mail/i.test(key))
179
+ continue;
180
+ const found = deepFindEmail(record[key], depth + 1);
181
+ if (found)
182
+ return found;
183
+ }
184
+ for (const key of keys) {
185
+ const found = deepFindEmail(record[key], depth + 1);
186
+ if (found)
187
+ return found;
188
+ }
189
+ return null;
190
+ }
191
+ function deepFindAccessToken(value, depth = 0) {
192
+ if (depth > 6 || value === null || value === undefined)
193
+ return '';
194
+ if (typeof value === 'string') {
195
+ const token = value.trim();
196
+ return token.length >= 20 ? token : '';
197
+ }
198
+ if (Array.isArray(value)) {
199
+ for (const item of value) {
200
+ const found = deepFindAccessToken(item, depth + 1);
201
+ if (found)
202
+ return found;
203
+ }
204
+ return '';
205
+ }
206
+ if (typeof value !== 'object')
207
+ return '';
208
+ const record = value;
209
+ const keys = Object.keys(record);
210
+ for (const key of keys) {
211
+ if (!/access.?token|token/i.test(key))
212
+ continue;
213
+ const found = deepFindAccessToken(record[key], depth + 1);
214
+ if (found)
215
+ return found;
216
+ }
217
+ return '';
218
+ }
219
+ function readClaudeCredentialSnapshot() {
220
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
221
+ const credentialPaths = [
222
+ (0, path_1.join)(sandboxHome, '.claude', '.credentials.json'),
223
+ (0, path_1.join)(sandboxHome, '.claude', 'credentials.json'),
224
+ ];
225
+ for (const credPath of credentialPaths) {
226
+ if (!(0, fs_1.existsSync)(credPath))
227
+ continue;
228
+ try {
229
+ const parsed = JSON.parse((0, fs_1.readFileSync)(credPath, 'utf-8'));
230
+ const nestedOauth = parsed.claudeAiOauth ?? parsed.oauth ?? null;
231
+ const token = (typeof nestedOauth?.accessToken === 'string' && nestedOauth.accessToken.trim()) ||
232
+ (typeof parsed.accessToken === 'string' && parsed.accessToken.trim()) ||
233
+ deepFindAccessToken(parsed);
234
+ if (!token)
235
+ continue;
236
+ const email = deepFindEmail(nestedOauth?.email) ||
237
+ deepFindEmail(nestedOauth?.user) ||
238
+ deepFindEmail(nestedOauth?.profile) ||
239
+ deepFindEmail(parsed);
240
+ return { loggedIn: true, email: email || null };
241
+ }
242
+ catch {
243
+ // Ignore malformed credential files and keep searching.
244
+ }
245
+ }
246
+ return { loggedIn: false, email: null };
247
+ }
85
248
  function getHeaderValue(req, name) {
86
249
  const value = req.headers[name.toLowerCase()];
87
250
  if (Array.isArray(value))
@@ -170,6 +333,39 @@ function clearDashboardLink(expectedUrl) {
170
333
  // Best effort cleanup.
171
334
  }
172
335
  }
336
+ function supportsTerminalHyperlinks() {
337
+ if (!(process.stderr.isTTY ?? false))
338
+ return false;
339
+ if (process.env.LABGATE_UI_HYPERLINKS === '0')
340
+ return false;
341
+ if ((process.env.TERM || '').toLowerCase() === 'dumb')
342
+ return false;
343
+ return true;
344
+ }
345
+ function formatTerminalHyperlink(url) {
346
+ if (!supportsTerminalHyperlinks())
347
+ return url;
348
+ const safe = url.replace(/[\u001b\u0007]/g, '');
349
+ return `\u001b]8;;${safe}\u0007${url}\u001b]8;;\u0007`;
350
+ }
351
+ function shouldAutoOpenUiBrowser(standalone) {
352
+ if (!standalone)
353
+ return false;
354
+ if (process.env.LABGATE_UI_AUTO_OPEN === '0')
355
+ return false;
356
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
357
+ return false;
358
+ if (!(process.stdout.isTTY ?? false) && !(process.stderr.isTTY ?? false))
359
+ return false;
360
+ return (0, os_1.platform)() === 'darwin';
361
+ }
362
+ function autoOpenUiBrowser(url) {
363
+ (0, child_process_1.execFile)('open', [url], (err) => {
364
+ if (!err)
365
+ return;
366
+ log.warn('Could not auto-open browser. Use the Settings URL above.');
367
+ });
368
+ }
173
369
  function serveFontFile(url, res) {
174
370
  // Only allow specific font files from the geist package
175
371
  const match = url.match(/^\/fonts\/([\w-]+\.woff2)$/);
@@ -195,6 +391,169 @@ function serveFontFile(url, res) {
195
391
  res.writeHead(404, { 'Content-Type': 'text/plain' });
196
392
  res.end('Font not found');
197
393
  }
394
+ function serveVendorAsset(res, filePath, contentType) {
395
+ try {
396
+ if (!(0, fs_1.existsSync)(filePath)) {
397
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
398
+ res.end('Not found');
399
+ return;
400
+ }
401
+ const data = (0, fs_1.readFileSync)(filePath);
402
+ res.writeHead(200, {
403
+ 'Content-Type': contentType,
404
+ 'Cache-Control': 'public, max-age=86400',
405
+ });
406
+ res.end(data);
407
+ }
408
+ catch {
409
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
410
+ res.end('Could not load asset');
411
+ }
412
+ }
413
+ function normalizeWebTerminalAgent(raw) {
414
+ const agent = (raw || '').trim().toLowerCase();
415
+ if (agent === 'claude' || agent === 'codex')
416
+ return agent;
417
+ return null;
418
+ }
419
+ function serializeWebTerminalSession(record) {
420
+ return {
421
+ id: record.id,
422
+ agent: record.agent,
423
+ workdir: record.workdir,
424
+ node: record.node,
425
+ tmuxSession: record.tmuxSession,
426
+ createdAt: record.createdAt,
427
+ updatedAt: record.updatedAt,
428
+ status: record.status,
429
+ exitCode: record.exitCode,
430
+ error: record.error,
431
+ };
432
+ }
433
+ async function loadNodePtyModule() {
434
+ try {
435
+ const mod = await import('node-pty');
436
+ return mod?.spawn ? mod : (mod?.default?.spawn ? mod.default : null);
437
+ }
438
+ catch {
439
+ return null;
440
+ }
441
+ }
442
+ function appendWebTerminalBuffer(bridge, chunk) {
443
+ bridge.buffer += chunk;
444
+ // Keep recent output bounded to avoid unbounded memory growth.
445
+ if (bridge.buffer.length > 512_000) {
446
+ bridge.buffer = bridge.buffer.slice(bridge.buffer.length - 512_000);
447
+ }
448
+ }
449
+ function sendWebTerminalMessage(ws, payload) {
450
+ if (ws.readyState !== ws_1.WebSocket.OPEN)
451
+ return;
452
+ try {
453
+ ws.send(JSON.stringify(payload));
454
+ }
455
+ catch {
456
+ // Best effort.
457
+ }
458
+ }
459
+ function broadcastWebTerminalMessage(bridge, payload) {
460
+ for (const ws of bridge.clients) {
461
+ if (ws.readyState !== ws_1.WebSocket.OPEN) {
462
+ bridge.clients.delete(ws);
463
+ continue;
464
+ }
465
+ sendWebTerminalMessage(ws, payload);
466
+ }
467
+ }
468
+ async function ensureWebTerminalBridge(record) {
469
+ const existing = webTerminalBridges.get(record.id);
470
+ if (existing && existing.pty)
471
+ return existing;
472
+ const ptyModule = await loadNodePtyModule();
473
+ if (!ptyModule) {
474
+ return null;
475
+ }
476
+ let tmuxBin = 'tmux';
477
+ try {
478
+ tmuxBin = await (0, web_terminal_js_1.getTmuxBinary)();
479
+ }
480
+ catch (err) {
481
+ log.warn(`Could not resolve tmux binary for web terminal bridge ${record.id}: ${err?.message ?? String(err)}`);
482
+ return null;
483
+ }
484
+ const env = {};
485
+ for (const [k, v] of Object.entries(process.env)) {
486
+ if (v !== undefined)
487
+ env[k] = v;
488
+ }
489
+ let ptyProcess;
490
+ const spawnOpts = {
491
+ name: 'xterm-256color',
492
+ cols: 120,
493
+ rows: 32,
494
+ cwd: record.workdir,
495
+ env,
496
+ };
497
+ try {
498
+ ptyProcess = ptyModule.spawn(tmuxBin, ['attach-session', '-t', record.tmuxSession], spawnOpts);
499
+ }
500
+ catch (err) {
501
+ const shell = (process.env.SHELL || '/bin/bash').trim() || '/bin/bash';
502
+ const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
503
+ const launch = `${quote(tmuxBin)} attach-session -t ${quote(record.tmuxSession)}`;
504
+ try {
505
+ ptyProcess = ptyModule.spawn(shell, ['-lc', launch], spawnOpts);
506
+ }
507
+ catch (fallbackErr) {
508
+ log.warn(`Could not create web terminal bridge for ${record.id}. ` +
509
+ `direct=${err?.message ?? String(err)} fallback=${fallbackErr?.message ?? String(fallbackErr)}`);
510
+ return null;
511
+ }
512
+ }
513
+ const bridge = existing || {
514
+ id: record.id,
515
+ buffer: '',
516
+ clients: new Set(),
517
+ pty: ptyProcess,
518
+ };
519
+ bridge.pty = ptyProcess;
520
+ webTerminalBridges.set(record.id, bridge);
521
+ ptyProcess.onData((data) => {
522
+ appendWebTerminalBuffer(bridge, data);
523
+ broadcastWebTerminalMessage(bridge, { type: 'data', id: record.id, data });
524
+ });
525
+ ptyProcess.onExit(async () => {
526
+ bridge.pty = null;
527
+ const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
528
+ if (!alive) {
529
+ const exitInfo = (0, web_terminal_js_1.readWebTerminalExitInfo)(record.id);
530
+ const finalCode = exitInfo?.exitCode ?? (record.exitCode ?? 0);
531
+ const finalStatus = finalCode === 0 ? 'exited' : 'failed';
532
+ const updated = (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, {
533
+ status: finalStatus,
534
+ exitCode: finalCode,
535
+ error: finalCode === 0 ? null : (record.error || `Exited with code ${finalCode}`),
536
+ });
537
+ broadcastWebTerminalMessage(bridge, {
538
+ type: 'status',
539
+ id: record.id,
540
+ status: updated?.status ?? finalStatus,
541
+ exitCode: updated?.exitCode ?? finalCode,
542
+ });
543
+ }
544
+ });
545
+ return bridge;
546
+ }
547
+ function stopWebTerminalBridge(bridge) {
548
+ if (!bridge.pty)
549
+ return;
550
+ try {
551
+ bridge.pty.kill('SIGTERM');
552
+ }
553
+ catch {
554
+ // Best effort.
555
+ }
556
+ }
198
557
  function serveHTML(res) {
199
558
  try {
200
559
  const html = (0, fs_1.readFileSync)(HTML_PATH, 'utf-8').replaceAll(WRITE_TOKEN_PLACEHOLDER, UI_WRITE_TOKEN);
@@ -209,6 +568,12 @@ function serveHTML(res) {
209
568
  function handleGetConfig(_req, res) {
210
569
  const effective = (0, config_js_1.loadEffectiveConfig)();
211
570
  const response = { ...effective.config };
571
+ const slurmRuntime = getSlurmRuntimeStatus();
572
+ response._slurm = {
573
+ available: slurmRuntime.available,
574
+ missing_commands: slurmRuntime.missingCommands,
575
+ required_commands: [...REQUIRED_SLURM_COMMANDS],
576
+ };
212
577
  // Attach enterprise metadata so the frontend knows which fields to lock
213
578
  if (effective.enterprise) {
214
579
  response._enterprise = {
@@ -251,7 +616,8 @@ async function handlePostConfig(req, res) {
251
616
  if (effective.lockedFields.has('audit.log_dir') && incoming.audit?.log_dir !== orig.audit.log_dir) {
252
617
  violations.push('audit.log_dir');
253
618
  }
254
- if (effective.lockedFields.has('slurm.enabled') && incoming.slurm?.enabled !== orig.slurm.enabled) {
619
+ const incomingSlurmEnabled = incoming.slurm?.enabled ?? orig.slurm.enabled;
620
+ if (effective.lockedFields.has('slurm.enabled') && incomingSlurmEnabled !== orig.slurm.enabled) {
255
621
  violations.push('slurm.enabled');
256
622
  }
257
623
  if (violations.length > 0) {
@@ -1382,6 +1748,16 @@ function handleGetSecurity(_req, res) {
1382
1748
  },
1383
1749
  });
1384
1750
  }
1751
+ function handleGetClaudeAuthStatus(_req, res) {
1752
+ const auth = readClaudeCredentialSnapshot();
1753
+ json(res, {
1754
+ ok: true,
1755
+ provider: 'claude',
1756
+ loggedIn: auth.loggedIn,
1757
+ email: auth.email,
1758
+ checkedAt: new Date().toISOString(),
1759
+ });
1760
+ }
1385
1761
  async function handleStopSession(req, res) {
1386
1762
  try {
1387
1763
  const body = await readBody(req);
@@ -1507,6 +1883,149 @@ async function handleRestartSession(req, res) {
1507
1883
  json(res, { ok: false, error: err.message ?? String(err) }, 500);
1508
1884
  }
1509
1885
  }
1886
+ async function handlePostWebTerminalStart(req, res) {
1887
+ try {
1888
+ const body = await readBody(req);
1889
+ const parsed = JSON.parse(body || '{}');
1890
+ const agent = normalizeWebTerminalAgent(parsed.agent || 'claude');
1891
+ const rawWorkdir = String(parsed.workdir || '').trim();
1892
+ if (!agent) {
1893
+ json(res, { ok: false, error: 'agent must be "claude" or "codex"' }, 400);
1894
+ return;
1895
+ }
1896
+ if (!rawWorkdir) {
1897
+ json(res, { ok: false, error: 'workdir is required' }, 400);
1898
+ return;
1899
+ }
1900
+ const resolvedWorkdir = (0, path_1.resolve)(rawWorkdir.replace(/^~/, (0, os_1.homedir)()));
1901
+ if (!(0, fs_1.existsSync)(resolvedWorkdir)) {
1902
+ json(res, { ok: false, error: `workdir does not exist: ${resolvedWorkdir}` }, 400);
1903
+ return;
1904
+ }
1905
+ let st;
1906
+ try {
1907
+ st = (0, fs_1.statSync)(resolvedWorkdir);
1908
+ }
1909
+ catch {
1910
+ json(res, { ok: false, error: `Could not access workdir: ${resolvedWorkdir}` }, 400);
1911
+ return;
1912
+ }
1913
+ if (!st.isDirectory()) {
1914
+ json(res, { ok: false, error: `workdir is not a directory: ${resolvedWorkdir}` }, 400);
1915
+ return;
1916
+ }
1917
+ const tmuxAvailable = await (0, web_terminal_js_1.ensureTmuxAvailable)();
1918
+ if (!tmuxAvailable.ok) {
1919
+ json(res, { ok: false, error: tmuxAvailable.error }, 500);
1920
+ return;
1921
+ }
1922
+ const cliEntrypoint = resolveCliEntrypoint();
1923
+ const id = `wt-${Date.now().toString(36)}-${(0, crypto_1.randomBytes)(2).toString('hex')}`;
1924
+ const record = (0, web_terminal_js_1.createWebTerminalRecord)({
1925
+ id,
1926
+ agent,
1927
+ workdir: resolvedWorkdir,
1928
+ });
1929
+ (0, web_terminal_js_1.writeWebTerminalRecord)(record);
1930
+ try {
1931
+ await (0, web_terminal_js_1.startTmuxWebTerminalSession)(record, cliEntrypoint);
1932
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'running', exitCode: null, error: null });
1933
+ }
1934
+ catch (err) {
1935
+ const message = err?.message ?? String(err);
1936
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'failed', exitCode: 1, error: message });
1937
+ json(res, { ok: false, error: `Could not start tmux session: ${message}` }, 500);
1938
+ return;
1939
+ }
1940
+ const bridge = await ensureWebTerminalBridge(record);
1941
+ if (!bridge) {
1942
+ try {
1943
+ await (0, web_terminal_js_1.killTmuxSession)(record.tmuxSession);
1944
+ }
1945
+ catch { /* best effort */ }
1946
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, {
1947
+ status: 'failed',
1948
+ exitCode: 1,
1949
+ error: 'node-pty bridge unavailable',
1950
+ });
1951
+ json(res, {
1952
+ ok: false,
1953
+ error: 'Started tmux session but could not create terminal bridge (node-pty unavailable).',
1954
+ }, 500);
1955
+ return;
1956
+ }
1957
+ json(res, {
1958
+ ok: true,
1959
+ session: serializeWebTerminalSession(record),
1960
+ });
1961
+ }
1962
+ catch (err) {
1963
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
1964
+ }
1965
+ }
1966
+ async function handleGetWebTerminalSessions(res) {
1967
+ const records = (0, web_terminal_js_1.listWebTerminalRecords)();
1968
+ const localNode = (0, os_1.hostname)();
1969
+ for (const record of records) {
1970
+ // Shared home directories may contain records from other nodes.
1971
+ // Only mutate runtime status for sessions that belong to this node.
1972
+ if (record.node !== localNode)
1973
+ continue;
1974
+ const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
1975
+ if (alive && record.status !== 'running') {
1976
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, { status: 'running', exitCode: null, error: null });
1977
+ }
1978
+ else if (!alive && record.status === 'running') {
1979
+ const exitInfo = (0, web_terminal_js_1.readWebTerminalExitInfo)(record.id);
1980
+ const finalCode = exitInfo?.exitCode ?? (record.exitCode ?? 0);
1981
+ const finalStatus = finalCode === 0 ? 'exited' : 'failed';
1982
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, {
1983
+ status: finalStatus,
1984
+ exitCode: finalCode,
1985
+ error: finalCode === 0 ? null : (record.error || `Exited with code ${finalCode}`),
1986
+ });
1987
+ }
1988
+ }
1989
+ const sessions = (0, web_terminal_js_1.listWebTerminalRecords)().map(serializeWebTerminalSession);
1990
+ json(res, { ok: true, sessions });
1991
+ }
1992
+ async function handlePostWebTerminalStop(req, res) {
1993
+ try {
1994
+ const body = await readBody(req);
1995
+ const parsed = JSON.parse(body || '{}');
1996
+ const id = String(parsed.id || '').trim();
1997
+ if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
1998
+ json(res, { ok: false, error: 'Invalid terminal session id' }, 400);
1999
+ return;
2000
+ }
2001
+ const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
2002
+ if (!record) {
2003
+ json(res, { ok: false, error: 'Terminal session not found' }, 404);
2004
+ return;
2005
+ }
2006
+ if (record.node !== (0, os_1.hostname)()) {
2007
+ json(res, { ok: false, error: `Terminal session is on a different node (${record.node})` }, 400);
2008
+ return;
2009
+ }
2010
+ try {
2011
+ await (0, web_terminal_js_1.killTmuxSession)(record.tmuxSession);
2012
+ }
2013
+ catch (err) {
2014
+ const message = err?.message ?? String(err);
2015
+ json(res, { ok: false, error: `Failed to stop tmux session: ${message}` }, 500);
2016
+ return;
2017
+ }
2018
+ (0, web_terminal_js_1.updateWebTerminalRecordStatus)(id, { status: 'exited', exitCode: 0, error: null });
2019
+ (0, web_terminal_js_1.clearWebTerminalExitInfo)(id);
2020
+ const bridge = webTerminalBridges.get(id);
2021
+ if (bridge)
2022
+ stopWebTerminalBridge(bridge);
2023
+ json(res, { ok: true });
2024
+ }
2025
+ catch (err) {
2026
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
2027
+ }
2028
+ }
1510
2029
  /**
1511
2030
  * Validate a host path: exists, is a directory, and is readable.
1512
2031
  * Returns { valid: true/false, error?, path? } — advisory only, does not block.
@@ -1542,6 +2061,43 @@ async function handleValidatePath(req, res) {
1542
2061
  json(res, { valid: false, error: err.message ?? String(err) }, 400);
1543
2062
  }
1544
2063
  }
2064
+ // ── Directory browse helper ─────────────────────────────
2065
+ async function handleBrowseDir(req, res) {
2066
+ try {
2067
+ const body = await readBody(req);
2068
+ const { path: rawPath } = JSON.parse(body);
2069
+ const resolved = (rawPath && typeof rawPath === 'string' ? rawPath : '~').replace(/^~/, (0, os_1.homedir)());
2070
+ if (!(0, fs_1.existsSync)(resolved) || !(0, fs_1.statSync)(resolved).isDirectory()) {
2071
+ json(res, { ok: false, error: 'Not a directory', path: resolved });
2072
+ return;
2073
+ }
2074
+ let entries;
2075
+ try {
2076
+ entries = (0, fs_1.readdirSync)(resolved);
2077
+ }
2078
+ catch {
2079
+ json(res, { ok: false, error: 'Cannot read directory', path: resolved });
2080
+ return;
2081
+ }
2082
+ const dirs = [];
2083
+ for (const entry of entries) {
2084
+ if (entry.startsWith('.'))
2085
+ continue; // skip dotfiles
2086
+ const full = (0, path_1.join)(resolved, entry);
2087
+ try {
2088
+ if ((0, fs_1.statSync)(full).isDirectory()) {
2089
+ dirs.push({ name: entry, path: full });
2090
+ }
2091
+ }
2092
+ catch { /* skip inaccessible */ }
2093
+ }
2094
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
2095
+ json(res, { ok: true, path: resolved, dirs });
2096
+ }
2097
+ catch (err) {
2098
+ json(res, { ok: false, error: err.message ?? String(err) }, 400);
2099
+ }
2100
+ }
1545
2101
  // ── Dataset stats helpers ───────────────────────────────
1546
2102
  function formatBytesUI(bytes) {
1547
2103
  if (bytes === 0)
@@ -1665,6 +2221,129 @@ async function handlePostDatasetScan(req, res) {
1665
2221
  json(res, { ok: false, error: err.message ?? String(err) }, 500);
1666
2222
  }
1667
2223
  }
2224
+ async function handlePostDatasetExampleInstall(req, res) {
2225
+ try {
2226
+ const body = await readBody(req);
2227
+ let parsed = {};
2228
+ if (body.trim()) {
2229
+ try {
2230
+ parsed = JSON.parse(body);
2231
+ }
2232
+ catch {
2233
+ json(res, { ok: false, error: 'Invalid JSON body' }, 400);
2234
+ return;
2235
+ }
2236
+ }
2237
+ const requestedNameRaw = typeof parsed.name === 'string' ? parsed.name.trim() : '';
2238
+ const datasetName = requestedNameRaw || IRIS_SAMPLE_DATASET_NAME;
2239
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(datasetName)) {
2240
+ json(res, {
2241
+ ok: false,
2242
+ error: 'Invalid dataset name. Use alphanumerics, hyphens, dots, underscores.',
2243
+ }, 400);
2244
+ return;
2245
+ }
2246
+ const datasetMode = parsed.mode === 'rw' ? 'rw' : 'ro';
2247
+ const sourceUrl = resolveIrisSampleSourceUrl();
2248
+ const configPath = (0, config_js_1.getConfigPath)();
2249
+ let obj = {};
2250
+ if ((0, fs_1.existsSync)(configPath)) {
2251
+ const rawText = (0, fs_1.readFileSync)(configPath, 'utf-8');
2252
+ const stripped = rawText
2253
+ .split('\n')
2254
+ .filter((line) => !line.trimStart().startsWith('//'))
2255
+ .join('\n');
2256
+ try {
2257
+ obj = JSON.parse(stripped);
2258
+ }
2259
+ catch {
2260
+ obj = {};
2261
+ }
2262
+ }
2263
+ const datasets = Array.isArray(obj.datasets) ? obj.datasets : [];
2264
+ const byNameConflict = datasets.find((d) => {
2265
+ const n = typeof d.name === 'string' ? d.name : '';
2266
+ return n.toLowerCase() === datasetName.toLowerCase();
2267
+ });
2268
+ if (byNameConflict) {
2269
+ json(res, { ok: false, error: `Dataset "${datasetName}" is already registered.` }, 409);
2270
+ return;
2271
+ }
2272
+ const datasetDir = (0, path_1.join)(config_js_1.LABGATE_DIR, 'datasets', datasetName);
2273
+ const byPathConflict = datasets.find((d) => {
2274
+ if (typeof d.path !== 'string')
2275
+ return false;
2276
+ return (0, path_1.resolve)(d.path.replace(/^~/, (0, os_1.homedir)())) === (0, path_1.resolve)(datasetDir);
2277
+ });
2278
+ if (byPathConflict) {
2279
+ json(res, { ok: false, error: `Dataset path is already registered: ${datasetDir}` }, 409);
2280
+ return;
2281
+ }
2282
+ const ctrl = new AbortController();
2283
+ const timeout = setTimeout(() => ctrl.abort(), 20_000);
2284
+ let csvText = '';
2285
+ try {
2286
+ const download = await fetch(sourceUrl, { signal: ctrl.signal });
2287
+ if (!download.ok) {
2288
+ json(res, { ok: false, error: `Failed to download sample dataset (HTTP ${download.status}).` }, 502);
2289
+ return;
2290
+ }
2291
+ csvText = await download.text();
2292
+ }
2293
+ finally {
2294
+ clearTimeout(timeout);
2295
+ }
2296
+ if (!csvText.trim()) {
2297
+ json(res, { ok: false, error: 'Downloaded sample dataset is empty.' }, 502);
2298
+ return;
2299
+ }
2300
+ if (csvText.length > 5_000_000) {
2301
+ json(res, { ok: false, error: 'Downloaded sample dataset is unexpectedly large.' }, 502);
2302
+ return;
2303
+ }
2304
+ (0, fs_1.mkdirSync)(datasetDir, { recursive: true, mode: config_js_1.PRIVATE_DIR_MODE });
2305
+ const csvPath = (0, path_1.join)(datasetDir, 'iris.csv');
2306
+ const readmePath = (0, path_1.join)(datasetDir, 'README.md');
2307
+ (0, fs_1.writeFileSync)(csvPath, csvText, { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
2308
+ (0, fs_1.writeFileSync)(readmePath, IRIS_SAMPLE_README, { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
2309
+ const totalSize = (0, fs_1.statSync)(csvPath).size + (0, fs_1.statSync)(readmePath).size;
2310
+ const lineCount = csvText
2311
+ .split('\n')
2312
+ .map((line) => line.trim())
2313
+ .filter((line) => line.length > 0)
2314
+ .length;
2315
+ const rowCount = Math.max(0, lineCount - 1);
2316
+ const entry = {
2317
+ path: datasetDir,
2318
+ name: datasetName,
2319
+ mode: datasetMode,
2320
+ description: `Iris flower measurements sample dataset (${rowCount} rows).`,
2321
+ stats: {
2322
+ file_count: 2,
2323
+ total_size: totalSize,
2324
+ total_size_formatted: formatBytesUI(totalSize),
2325
+ scanned_at: new Date().toISOString(),
2326
+ },
2327
+ };
2328
+ datasets.push(entry);
2329
+ obj.datasets = datasets;
2330
+ (0, fs_1.writeFileSync)(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
2331
+ (0, config_js_1.ensurePrivateFile)(configPath);
2332
+ json(res, {
2333
+ ok: true,
2334
+ dataset: entry,
2335
+ downloaded_from: sourceUrl,
2336
+ files: ['iris.csv', 'README.md'],
2337
+ }, 201);
2338
+ }
2339
+ catch (err) {
2340
+ if (err?.name === 'AbortError') {
2341
+ json(res, { ok: false, error: 'Timed out downloading sample dataset.' }, 504);
2342
+ return;
2343
+ }
2344
+ json(res, { ok: false, error: err?.message ?? String(err) }, 500);
2345
+ }
2346
+ }
1668
2347
  function handleGetLogs(_req, res) {
1669
2348
  const config = (0, config_js_1.loadConfig)();
1670
2349
  if (!config.audit.enabled) {
@@ -2788,6 +3467,34 @@ function handleGetAdminLicense(_req, res) {
2788
3467
  const status = (0, license_js_1.validateLicense)();
2789
3468
  json(res, { ok: true, license: status });
2790
3469
  }
3470
+ function upgradeUnauthorized(socket) {
3471
+ try {
3472
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
3473
+ }
3474
+ catch {
3475
+ // Best effort.
3476
+ }
3477
+ try {
3478
+ socket.destroy();
3479
+ }
3480
+ catch {
3481
+ // Best effort.
3482
+ }
3483
+ }
3484
+ function upgradeBadRequest(socket) {
3485
+ try {
3486
+ socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
3487
+ }
3488
+ catch {
3489
+ // Best effort.
3490
+ }
3491
+ try {
3492
+ socket.destroy();
3493
+ }
3494
+ catch {
3495
+ // Best effort.
3496
+ }
3497
+ }
2791
3498
  /**
2792
3499
  * Start the settings UI server.
2793
3500
  * Returns the HTTP server so callers can close it when done.
@@ -2812,6 +3519,71 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
2812
3519
  if (!useTcp) {
2813
3520
  (0, config_js_1.ensurePrivateDir)((0, path_1.dirname)(socketPath));
2814
3521
  }
3522
+ const wsServer = new ws_1.WebSocketServer({ noServer: true });
3523
+ wsServer.on('connection', async (ws, req) => {
3524
+ const reqUrl = new URL(req.url || '/', 'http://localhost');
3525
+ const id = reqUrl.searchParams.get('id') || '';
3526
+ const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
3527
+ if (!record) {
3528
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Terminal session not found' });
3529
+ ws.close();
3530
+ return;
3531
+ }
3532
+ const bridge = await ensureWebTerminalBridge(record);
3533
+ if (!bridge) {
3534
+ sendWebTerminalMessage(ws, { type: 'error', error: 'Could not open terminal bridge' });
3535
+ ws.close();
3536
+ return;
3537
+ }
3538
+ bridge.clients.add(ws);
3539
+ sendWebTerminalMessage(ws, {
3540
+ type: 'status',
3541
+ id: record.id,
3542
+ status: record.status,
3543
+ exitCode: record.exitCode,
3544
+ error: record.error,
3545
+ });
3546
+ if (bridge.buffer) {
3547
+ sendWebTerminalMessage(ws, { type: 'data', id: record.id, data: bridge.buffer });
3548
+ }
3549
+ ws.on('message', (raw) => {
3550
+ let parsed;
3551
+ try {
3552
+ parsed = JSON.parse(raw.toString('utf-8'));
3553
+ }
3554
+ catch {
3555
+ return;
3556
+ }
3557
+ const current = webTerminalBridges.get(id);
3558
+ if (!current || !current.pty)
3559
+ return;
3560
+ if (parsed?.type === 'input' && typeof parsed.data === 'string') {
3561
+ try {
3562
+ current.pty.write(parsed.data);
3563
+ }
3564
+ catch { /* best effort */ }
3565
+ return;
3566
+ }
3567
+ if (parsed?.type === 'resize') {
3568
+ const cols = Number(parsed.cols);
3569
+ const rows = Number(parsed.rows);
3570
+ if (!Number.isFinite(cols) || !Number.isFinite(rows))
3571
+ return;
3572
+ try {
3573
+ current.pty.resize(Math.max(40, Math.min(Math.floor(cols), 300)), Math.max(10, Math.min(Math.floor(rows), 120)));
3574
+ }
3575
+ catch {
3576
+ // Best effort.
3577
+ }
3578
+ }
3579
+ });
3580
+ ws.on('close', () => {
3581
+ bridge.clients.delete(ws);
3582
+ if (bridge.clients.size === 0) {
3583
+ stopWebTerminalBridge(bridge);
3584
+ }
3585
+ });
3586
+ });
2815
3587
  const server = (0, http_1.createServer)(async (req, res) => {
2816
3588
  const url = req.url ?? '/';
2817
3589
  const reqUrl = new URL(url, 'http://localhost');
@@ -2892,12 +3664,18 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
2892
3664
  else if (pathname === '/api/validate-path' && method === 'POST') {
2893
3665
  await handleValidatePath(req, res);
2894
3666
  }
3667
+ else if (pathname === '/api/browse-dir' && method === 'POST') {
3668
+ await handleBrowseDir(req, res);
3669
+ }
2895
3670
  else if (pathname === '/api/dataset-stats' && method === 'GET') {
2896
3671
  handleGetDatasetStats(req, res);
2897
3672
  }
2898
3673
  else if (pathname === '/api/dataset-scan' && method === 'POST') {
2899
3674
  await handlePostDatasetScan(req, res);
2900
3675
  }
3676
+ else if (pathname === '/api/dataset-example/install' && method === 'POST') {
3677
+ await handlePostDatasetExampleInstall(req, res);
3678
+ }
2901
3679
  else if (/^\/api\/sessions\/[^/]+\/instructions$/.test(pathname) && method === 'GET') {
2902
3680
  handleGetSessionInstructions(pathname, reqUrl, res);
2903
3681
  }
@@ -2910,6 +3688,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
2910
3688
  else if (pathname === '/api/security' && method === 'GET') {
2911
3689
  handleGetSecurity(req, res);
2912
3690
  }
3691
+ else if (pathname === '/api/claude/auth' && method === 'GET') {
3692
+ handleGetClaudeAuthStatus(req, res);
3693
+ }
2913
3694
  else if (pathname === '/api/results' && method === 'GET') {
2914
3695
  handleGetResults(reqUrl, res);
2915
3696
  }
@@ -2925,6 +3706,15 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
2925
3706
  else if (/^\/api\/results\/([^/]+)$/.test(pathname) && method === 'DELETE') {
2926
3707
  await handleDeleteResult(pathname, res);
2927
3708
  }
3709
+ else if (pathname === '/api/terminal/sessions' && method === 'GET') {
3710
+ await handleGetWebTerminalSessions(res);
3711
+ }
3712
+ else if (pathname === '/api/terminal/start' && method === 'POST') {
3713
+ await handlePostWebTerminalStart(req, res);
3714
+ }
3715
+ else if (pathname === '/api/terminal/stop' && method === 'POST') {
3716
+ await handlePostWebTerminalStop(req, res);
3717
+ }
2928
3718
  else if (pathname === '/api/mcp' && method === 'GET') {
2929
3719
  handleGetMcp(req, res);
2930
3720
  }
@@ -2981,6 +3771,18 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
2981
3771
  else if (pathname.startsWith('/fonts/') && method === 'GET') {
2982
3772
  serveFontFile(pathname, res);
2983
3773
  }
3774
+ else if (pathname === '/vendor/xterm/xterm.css' && method === 'GET') {
3775
+ serveVendorAsset(res, XTERM_CSS_PATH, 'text/css; charset=utf-8');
3776
+ }
3777
+ else if (pathname === '/vendor/xterm/xterm.js' && method === 'GET') {
3778
+ serveVendorAsset(res, XTERM_JS_PATH, 'application/javascript; charset=utf-8');
3779
+ }
3780
+ else if (pathname === '/vendor/xterm/addon-fit.js' && method === 'GET') {
3781
+ serveVendorAsset(res, XTERM_FIT_JS_PATH, 'application/javascript; charset=utf-8');
3782
+ }
3783
+ else if (pathname === '/vendor/xterm/addon-web-links.js' && method === 'GET') {
3784
+ serveVendorAsset(res, XTERM_WEB_LINKS_JS_PATH, 'application/javascript; charset=utf-8');
3785
+ }
2984
3786
  else {
2985
3787
  if (pathname.startsWith('/api/')) {
2986
3788
  json(res, { ok: false, error: 'Not found' }, 404);
@@ -3001,6 +3803,42 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3001
3803
  }
3002
3804
  }
3003
3805
  });
3806
+ server.on('upgrade', (req, socket, head) => {
3807
+ const reqUrl = new URL(req.url || '/', 'http://localhost');
3808
+ if (reqUrl.pathname !== '/api/terminal/ws') {
3809
+ upgradeBadRequest(socket);
3810
+ return;
3811
+ }
3812
+ if (useTcp) {
3813
+ const auth = isAuthorizedRequest(req, reqUrl, uiAccessToken);
3814
+ if (!auth.ok) {
3815
+ upgradeUnauthorized(socket);
3816
+ return;
3817
+ }
3818
+ }
3819
+ const writeToken = (reqUrl.searchParams.get('writeToken') || '').trim();
3820
+ if (!writeToken || writeToken !== UI_WRITE_TOKEN) {
3821
+ upgradeUnauthorized(socket);
3822
+ return;
3823
+ }
3824
+ const id = (reqUrl.searchParams.get('id') || '').trim();
3825
+ if (!(0, web_terminal_js_1.isValidWebTerminalId)(id)) {
3826
+ upgradeBadRequest(socket);
3827
+ return;
3828
+ }
3829
+ const record = (0, web_terminal_js_1.readWebTerminalRecord)(id);
3830
+ if (!record) {
3831
+ upgradeBadRequest(socket);
3832
+ return;
3833
+ }
3834
+ if (record.node !== (0, os_1.hostname)()) {
3835
+ upgradeBadRequest(socket);
3836
+ return;
3837
+ }
3838
+ wsServer.handleUpgrade(req, socket, head, (ws) => {
3839
+ wsServer.emit('connection', ws, req);
3840
+ });
3841
+ });
3004
3842
  const bindTcp = (nextPort) => {
3005
3843
  listenPort = nextPort;
3006
3844
  server.listen(listenPort, '127.0.0.1');
@@ -3021,15 +3859,17 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3021
3859
  started = true;
3022
3860
  if (useTcp) {
3023
3861
  const actualPort = server.address()?.port ?? listenPort;
3024
- log.step(`Settings: http://localhost:${actualPort}/?token=${uiAccessToken}`);
3025
3862
  dashboardQuickLink = `http://localhost:${actualPort}${UI_SHORT_LINK_PREFIX}${uiShortCode}`;
3026
- log.step(`Settings quick link: ${dashboardQuickLink}`);
3863
+ log.step(`Settings: ${formatTerminalHyperlink(dashboardQuickLink)}`);
3027
3864
  try {
3028
3865
  writeDashboardLink(dashboardQuickLink);
3029
3866
  }
3030
3867
  catch {
3031
3868
  // Best effort: statusline can still fall back to LABGATE_DASHBOARD_URL/default URL.
3032
3869
  }
3870
+ if (shouldAutoOpenUiBrowser(standalone)) {
3871
+ autoOpenUiBrowser(dashboardQuickLink);
3872
+ }
3033
3873
  }
3034
3874
  else {
3035
3875
  try {
@@ -3066,6 +3906,17 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3066
3906
  server.on('close', () => {
3067
3907
  if (dashboardQuickLink)
3068
3908
  clearDashboardLink(dashboardQuickLink);
3909
+ for (const bridge of webTerminalBridges.values()) {
3910
+ stopWebTerminalBridge(bridge);
3911
+ for (const ws of bridge.clients) {
3912
+ try {
3913
+ ws.close();
3914
+ }
3915
+ catch { /* best effort */ }
3916
+ }
3917
+ }
3918
+ webTerminalBridges.clear();
3919
+ wsServer.close();
3069
3920
  });
3070
3921
  server.on('error', (err) => {
3071
3922
  if (!useTcp && err.code === 'EADDRINUSE') {