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/container.d.ts +2 -0
- package/dist/lib/container.js +37 -1
- package/dist/lib/container.js.map +1 -1
- package/dist/lib/doctor.d.ts +31 -0
- package/dist/lib/doctor.js +336 -0
- package/dist/lib/doctor.js.map +1 -0
- package/dist/lib/init.js +56 -1
- package/dist/lib/init.js.map +1 -1
- package/dist/lib/test/integration-harness.d.ts +2 -1
- package/dist/lib/test/integration-harness.js +3 -2
- package/dist/lib/test/integration-harness.js.map +1 -1
- package/dist/lib/ui.d.ts +1 -1
- package/dist/lib/ui.html +4300 -1116
- package/dist/lib/ui.js +854 -3
- package/dist/lib/ui.js.map +1 -1
- package/dist/lib/web-terminal.d.ts +54 -0
- package/dist/lib/web-terminal.js +327 -0
- package/dist/lib/web-terminal.js.map +1 -0
- package/package.json +10 -5
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
|
-
|
|
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
|
|
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') {
|