labgate 0.5.28 → 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
@@ -58,6 +58,7 @@ const FONTS_DIR = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', 'ge
58
58
  const XTERM_CSS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'xterm', 'css', 'xterm.css');
59
59
  const XTERM_JS_PATH = (0, path_1.resolve)(__dirname, '..', '..', 'node_modules', '@xterm', 'xterm', 'lib', 'xterm.js');
60
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');
61
62
  const WRITE_TOKEN_PLACEHOLDER = '__LABGATE_WRITE_TOKEN__';
62
63
  const UI_WRITE_TOKEN = (0, crypto_1.randomBytes)(24).toString('hex');
63
64
  const UI_AUTH_COOKIE = 'labgate_ui_token';
@@ -65,10 +66,25 @@ const UI_SHORT_LINK_PREFIX = '/s/';
65
66
  const DASHBOARD_LINK_FILE = '.labgate-dashboard-url';
66
67
  const LABGATE_INSTRUCTION_START = '<!-- LABGATE_SESSION_INSTRUCTION_START -->';
67
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
+ }
68
83
  // ── SLURM module state (initialised in startUI when slurm.enabled) ──
69
84
  let slurmDB = null;
70
85
  let slurmPoller = null;
71
86
  let resultsStore = null;
87
+ const REQUIRED_SLURM_COMMANDS = ['sbatch', 'squeue', 'sacct', 'scancel'];
72
88
  const webTerminalBridges = new Map();
73
89
  function getResultsStore() {
74
90
  if (!resultsStore) {
@@ -76,6 +92,50 @@ function getResultsStore() {
76
92
  }
77
93
  return resultsStore;
78
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
+ }
79
139
  function readBody(req) {
80
140
  return new Promise((resolve, reject) => {
81
141
  const chunks = [];
@@ -88,6 +148,103 @@ function json(res, data, status = 200) {
88
148
  res.writeHead(status, { 'Content-Type': 'application/json' });
89
149
  res.end(JSON.stringify(data));
90
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
+ }
91
248
  function getHeaderValue(req, name) {
92
249
  const value = req.headers[name.toLowerCase()];
93
250
  if (Array.isArray(value))
@@ -176,6 +333,39 @@ function clearDashboardLink(expectedUrl) {
176
333
  // Best effort cleanup.
177
334
  }
178
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
+ }
179
369
  function serveFontFile(url, res) {
180
370
  // Only allow specific font files from the geist package
181
371
  const match = url.match(/^\/fonts\/([\w-]+\.woff2)$/);
@@ -378,6 +568,12 @@ function serveHTML(res) {
378
568
  function handleGetConfig(_req, res) {
379
569
  const effective = (0, config_js_1.loadEffectiveConfig)();
380
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
+ };
381
577
  // Attach enterprise metadata so the frontend knows which fields to lock
382
578
  if (effective.enterprise) {
383
579
  response._enterprise = {
@@ -420,7 +616,8 @@ async function handlePostConfig(req, res) {
420
616
  if (effective.lockedFields.has('audit.log_dir') && incoming.audit?.log_dir !== orig.audit.log_dir) {
421
617
  violations.push('audit.log_dir');
422
618
  }
423
- 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) {
424
621
  violations.push('slurm.enabled');
425
622
  }
426
623
  if (violations.length > 0) {
@@ -1551,6 +1748,16 @@ function handleGetSecurity(_req, res) {
1551
1748
  },
1552
1749
  });
1553
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
+ }
1554
1761
  async function handleStopSession(req, res) {
1555
1762
  try {
1556
1763
  const body = await readBody(req);
@@ -1758,7 +1965,12 @@ async function handlePostWebTerminalStart(req, res) {
1758
1965
  }
1759
1966
  async function handleGetWebTerminalSessions(res) {
1760
1967
  const records = (0, web_terminal_js_1.listWebTerminalRecords)();
1968
+ const localNode = (0, os_1.hostname)();
1761
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;
1762
1974
  const alive = await (0, web_terminal_js_1.hasTmuxSession)(record.tmuxSession);
1763
1975
  if (alive && record.status !== 'running') {
1764
1976
  (0, web_terminal_js_1.updateWebTerminalRecordStatus)(record.id, { status: 'running', exitCode: null, error: null });
@@ -1791,6 +2003,10 @@ async function handlePostWebTerminalStop(req, res) {
1791
2003
  json(res, { ok: false, error: 'Terminal session not found' }, 404);
1792
2004
  return;
1793
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
+ }
1794
2010
  try {
1795
2011
  await (0, web_terminal_js_1.killTmuxSession)(record.tmuxSession);
1796
2012
  }
@@ -1845,6 +2061,43 @@ async function handleValidatePath(req, res) {
1845
2061
  json(res, { valid: false, error: err.message ?? String(err) }, 400);
1846
2062
  }
1847
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
+ }
1848
2101
  // ── Dataset stats helpers ───────────────────────────────
1849
2102
  function formatBytesUI(bytes) {
1850
2103
  if (bytes === 0)
@@ -1968,6 +2221,129 @@ async function handlePostDatasetScan(req, res) {
1968
2221
  json(res, { ok: false, error: err.message ?? String(err) }, 500);
1969
2222
  }
1970
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
+ }
1971
2347
  function handleGetLogs(_req, res) {
1972
2348
  const config = (0, config_js_1.loadConfig)();
1973
2349
  if (!config.audit.enabled) {
@@ -3288,12 +3664,18 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3288
3664
  else if (pathname === '/api/validate-path' && method === 'POST') {
3289
3665
  await handleValidatePath(req, res);
3290
3666
  }
3667
+ else if (pathname === '/api/browse-dir' && method === 'POST') {
3668
+ await handleBrowseDir(req, res);
3669
+ }
3291
3670
  else if (pathname === '/api/dataset-stats' && method === 'GET') {
3292
3671
  handleGetDatasetStats(req, res);
3293
3672
  }
3294
3673
  else if (pathname === '/api/dataset-scan' && method === 'POST') {
3295
3674
  await handlePostDatasetScan(req, res);
3296
3675
  }
3676
+ else if (pathname === '/api/dataset-example/install' && method === 'POST') {
3677
+ await handlePostDatasetExampleInstall(req, res);
3678
+ }
3297
3679
  else if (/^\/api\/sessions\/[^/]+\/instructions$/.test(pathname) && method === 'GET') {
3298
3680
  handleGetSessionInstructions(pathname, reqUrl, res);
3299
3681
  }
@@ -3306,6 +3688,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3306
3688
  else if (pathname === '/api/security' && method === 'GET') {
3307
3689
  handleGetSecurity(req, res);
3308
3690
  }
3691
+ else if (pathname === '/api/claude/auth' && method === 'GET') {
3692
+ handleGetClaudeAuthStatus(req, res);
3693
+ }
3309
3694
  else if (pathname === '/api/results' && method === 'GET') {
3310
3695
  handleGetResults(reqUrl, res);
3311
3696
  }
@@ -3395,6 +3780,9 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3395
3780
  else if (pathname === '/vendor/xterm/addon-fit.js' && method === 'GET') {
3396
3781
  serveVendorAsset(res, XTERM_FIT_JS_PATH, 'application/javascript; charset=utf-8');
3397
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
+ }
3398
3786
  else {
3399
3787
  if (pathname.startsWith('/api/')) {
3400
3788
  json(res, { ok: false, error: 'Not found' }, 404);
@@ -3472,13 +3860,16 @@ function startUI(optsOrPort = {}, standaloneArg = true) {
3472
3860
  if (useTcp) {
3473
3861
  const actualPort = server.address()?.port ?? listenPort;
3474
3862
  dashboardQuickLink = `http://localhost:${actualPort}${UI_SHORT_LINK_PREFIX}${uiShortCode}`;
3475
- log.step(`Settings: ${dashboardQuickLink}`);
3863
+ log.step(`Settings: ${formatTerminalHyperlink(dashboardQuickLink)}`);
3476
3864
  try {
3477
3865
  writeDashboardLink(dashboardQuickLink);
3478
3866
  }
3479
3867
  catch {
3480
3868
  // Best effort: statusline can still fall back to LABGATE_DASHBOARD_URL/default URL.
3481
3869
  }
3870
+ if (shouldAutoOpenUiBrowser(standalone)) {
3871
+ autoOpenUiBrowser(dashboardQuickLink);
3872
+ }
3482
3873
  }
3483
3874
  else {
3484
3875
  try {