labgate 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -3
- package/dist/cli.js +322 -19
- package/dist/cli.js.map +1 -1
- package/dist/lib/audit.d.ts +5 -1
- package/dist/lib/audit.js +19 -3
- package/dist/lib/audit.js.map +1 -1
- package/dist/lib/config.d.ts +71 -2
- package/dist/lib/config.js +192 -8
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/container.d.ts +54 -0
- package/dist/lib/container.js +650 -178
- package/dist/lib/container.js.map +1 -1
- package/dist/lib/init.js +22 -9
- package/dist/lib/init.js.map +1 -1
- package/dist/lib/license.d.ts +44 -0
- package/dist/lib/license.js +164 -0
- package/dist/lib/license.js.map +1 -0
- package/dist/lib/policy.d.ts +85 -0
- package/dist/lib/policy.js +321 -0
- package/dist/lib/policy.js.map +1 -0
- package/dist/lib/runtime.d.ts +2 -2
- package/dist/lib/runtime.js +19 -36
- package/dist/lib/runtime.js.map +1 -1
- package/dist/lib/slurm-db.d.ts +51 -0
- package/dist/lib/slurm-db.js +179 -0
- package/dist/lib/slurm-db.js.map +1 -0
- package/dist/lib/slurm-mcp.d.ts +12 -0
- package/dist/lib/slurm-mcp.js +347 -0
- package/dist/lib/slurm-mcp.js.map +1 -0
- package/dist/lib/slurm-poller.d.ts +36 -0
- package/dist/lib/slurm-poller.js +423 -0
- package/dist/lib/slurm-poller.js.map +1 -0
- package/dist/lib/test/integration-harness.d.ts +44 -0
- package/dist/lib/test/integration-harness.js +260 -0
- package/dist/lib/test/integration-harness.js.map +1 -0
- package/dist/lib/ui.d.ts +34 -1
- package/dist/lib/ui.html +3081 -356
- package/dist/lib/ui.js +2123 -108
- package/dist/lib/ui.js.map +1 -1
- package/package.json +11 -3
package/dist/lib/container.js
CHANGED
|
@@ -36,11 +36,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.computeMountFingerprint = computeMountFingerprint;
|
|
39
40
|
exports.imageToSifName = imageToSifName;
|
|
40
41
|
exports.buildEntrypoint = buildEntrypoint;
|
|
42
|
+
exports.setupBrowserHook = setupBrowserHook;
|
|
41
43
|
exports.startSession = startSession;
|
|
42
44
|
exports.listSessions = listSessions;
|
|
43
45
|
exports.stopSession = stopSession;
|
|
46
|
+
exports.restartSession = restartSession;
|
|
44
47
|
const child_process_1 = require("child_process");
|
|
45
48
|
const fs_1 = require("fs");
|
|
46
49
|
const path_1 = require("path");
|
|
@@ -50,8 +53,245 @@ const fast_glob_1 = __importDefault(require("fast-glob"));
|
|
|
50
53
|
const config_js_1 = require("./config.js");
|
|
51
54
|
const runtime_js_1 = require("./runtime.js");
|
|
52
55
|
const audit_js_1 = require("./audit.js");
|
|
56
|
+
const slurm_db_js_1 = require("./slurm-db.js");
|
|
57
|
+
const slurm_poller_js_1 = require("./slurm-poller.js");
|
|
53
58
|
const log = __importStar(require("./log.js"));
|
|
54
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Compute a deterministic fingerprint of mount-affecting config fields.
|
|
61
|
+
* Used to detect when running sessions need a restart after config changes.
|
|
62
|
+
* Only includes fields that are baked into container args at launch time
|
|
63
|
+
* and cannot be hot-reloaded (--bind, --volume, --network, image, runtime).
|
|
64
|
+
*/
|
|
65
|
+
function computeMountFingerprint(config, imageOverride) {
|
|
66
|
+
const input = JSON.stringify({
|
|
67
|
+
datasets: (config.datasets || []).map(d => ({ path: d.path, name: d.name, mode: d.mode })),
|
|
68
|
+
extra_paths: (config.filesystem.extra_paths || []).map(p => ({ path: p.path, mode: p.mode })),
|
|
69
|
+
image: imageOverride ?? config.image,
|
|
70
|
+
network_mode: config.network.mode,
|
|
71
|
+
runtime: config.runtime,
|
|
72
|
+
});
|
|
73
|
+
return (0, crypto_1.createHash)('sha256').update(input).digest('hex').slice(0, 16);
|
|
74
|
+
}
|
|
75
|
+
function writeSessionFile(sessionId, session, image) {
|
|
76
|
+
const dir = (0, config_js_1.getSessionsDir)();
|
|
77
|
+
(0, config_js_1.ensurePrivateDir)(dir);
|
|
78
|
+
const data = {
|
|
79
|
+
id: sessionId,
|
|
80
|
+
agent: session.agent,
|
|
81
|
+
workdir: session.workdir,
|
|
82
|
+
node: (0, os_1.hostname)(),
|
|
83
|
+
pid: process.pid,
|
|
84
|
+
started: new Date().toISOString(),
|
|
85
|
+
network: session.config.network.mode,
|
|
86
|
+
image,
|
|
87
|
+
user: (0, os_1.userInfo)().username,
|
|
88
|
+
configFingerprint: computeMountFingerprint(session.config, session.imageOverride),
|
|
89
|
+
};
|
|
90
|
+
const content = JSON.stringify(data, null, 2) + '\n';
|
|
91
|
+
const sessionFile = (0, path_1.join)(dir, `${sessionId}.json`);
|
|
92
|
+
(0, fs_1.writeFileSync)(sessionFile, content, { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
|
|
93
|
+
(0, config_js_1.ensurePrivateFile)(sessionFile);
|
|
94
|
+
// Enterprise: also write to shared sessions dir for admin visibility
|
|
95
|
+
if (session.sharedSessionsDir) {
|
|
96
|
+
try {
|
|
97
|
+
const sharedFile = (0, path_1.join)(session.sharedSessionsDir, `${sessionId}.json`);
|
|
98
|
+
(0, fs_1.writeFileSync)(sharedFile, content, { encoding: 'utf-8', mode: 0o664 });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Best effort; shared dir may not be writable
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function removeSessionFile(sessionId, sharedSessionsDir) {
|
|
106
|
+
try {
|
|
107
|
+
(0, fs_1.unlinkSync)((0, path_1.join)((0, config_js_1.getSessionsDir)(), `${sessionId}.json`));
|
|
108
|
+
}
|
|
109
|
+
catch { /* already removed */ }
|
|
110
|
+
if (sharedSessionsDir) {
|
|
111
|
+
try {
|
|
112
|
+
(0, fs_1.unlinkSync)((0, path_1.join)(sharedSessionsDir, `${sessionId}.json`));
|
|
113
|
+
}
|
|
114
|
+
catch { /* best effort */ }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const LABGATE_INSTRUCTION_START = '<!-- LABGATE_SESSION_INSTRUCTION_START -->';
|
|
118
|
+
const LABGATE_INSTRUCTION_END = '<!-- LABGATE_SESSION_INSTRUCTION_END -->';
|
|
119
|
+
function getInstructionFileForAgent(agent) {
|
|
120
|
+
return agent.toLowerCase() === 'claude' ? 'CLAUDE.md' : 'AGENTS.md';
|
|
121
|
+
}
|
|
122
|
+
function escapeRegExp(value) {
|
|
123
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
124
|
+
}
|
|
125
|
+
function stripLabgateInstructionBlock(content) {
|
|
126
|
+
const pattern = new RegExp(`${escapeRegExp(LABGATE_INSTRUCTION_START)}[\\s\\S]*?${escapeRegExp(LABGATE_INSTRUCTION_END)}\\n?`, 'g');
|
|
127
|
+
return content.replace(pattern, '').replace(/^\n+/, '');
|
|
128
|
+
}
|
|
129
|
+
function hasOtherActiveSessionForInstructionFile(workdir, filename) {
|
|
130
|
+
const dir = (0, config_js_1.getSessionsDir)();
|
|
131
|
+
if (!(0, fs_1.existsSync)(dir))
|
|
132
|
+
return false;
|
|
133
|
+
const localHost = (0, os_1.hostname)();
|
|
134
|
+
try {
|
|
135
|
+
const files = (0, fs_1.readdirSync)(dir).filter(f => f.endsWith('.json'));
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
try {
|
|
138
|
+
const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
|
|
139
|
+
if (data.workdir !== workdir)
|
|
140
|
+
continue;
|
|
141
|
+
if (getInstructionFileForAgent(data.agent) !== filename)
|
|
142
|
+
continue;
|
|
143
|
+
if (data.node === localHost) {
|
|
144
|
+
try {
|
|
145
|
+
process.kill(data.pid, 0);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
try {
|
|
149
|
+
(0, fs_1.unlinkSync)((0, path_1.join)(dir, file));
|
|
150
|
+
}
|
|
151
|
+
catch { /* best effort */ }
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch { /* skip bad session files */ }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* skip unreadable dir */ }
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
function cleanupLabgateInstructionFile(workdir, filename, deleteWhenEmpty) {
|
|
164
|
+
if (hasOtherActiveSessionForInstructionFile(workdir, filename))
|
|
165
|
+
return;
|
|
166
|
+
const targetPath = (0, path_1.join)(workdir, filename);
|
|
167
|
+
try {
|
|
168
|
+
if (!(0, fs_1.existsSync)(targetPath))
|
|
169
|
+
return;
|
|
170
|
+
const current = (0, fs_1.readFileSync)(targetPath, 'utf-8');
|
|
171
|
+
const stripped = stripLabgateInstructionBlock(current);
|
|
172
|
+
if (stripped.trim().length === 0) {
|
|
173
|
+
if (deleteWhenEmpty) {
|
|
174
|
+
try {
|
|
175
|
+
(0, fs_1.unlinkSync)(targetPath);
|
|
176
|
+
}
|
|
177
|
+
catch { /* best effort */ }
|
|
178
|
+
}
|
|
179
|
+
else if (stripped !== current) {
|
|
180
|
+
(0, fs_1.writeFileSync)(targetPath, '', 'utf-8');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (stripped !== current) {
|
|
184
|
+
(0, fs_1.writeFileSync)(targetPath, stripped, 'utf-8');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
log.warn(`Could not clean up ${filename}: ${err.message ?? String(err)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function buildLabgateInstructionBlock(session) {
|
|
192
|
+
const lines = [
|
|
193
|
+
LABGATE_INSTRUCTION_START,
|
|
194
|
+
'## LabGate Sandbox Context (Auto-Managed)',
|
|
195
|
+
'- You are running inside a LabGate sandbox container.',
|
|
196
|
+
'- Path mapping:',
|
|
197
|
+
` - Container \`/work\` maps to host \`${session.workdir}\``,
|
|
198
|
+
` - Container \`/home/sandbox\` maps to host \`${(0, config_js_1.getSandboxHome)()}\``,
|
|
199
|
+
];
|
|
200
|
+
for (const mount of session.config.filesystem.extra_paths) {
|
|
201
|
+
const hostPath = mount.path.replace(/^~/, (0, os_1.homedir)());
|
|
202
|
+
lines.push(` - Container \`/mnt/${(0, path_1.basename)(hostPath)}\` maps to host \`${hostPath}\` (${mount.mode})`);
|
|
203
|
+
}
|
|
204
|
+
const datasets = session.config.datasets || [];
|
|
205
|
+
for (const ds of datasets) {
|
|
206
|
+
const hostPath = ds.path.replace(/^~/, (0, os_1.homedir)());
|
|
207
|
+
lines.push(` - Container \`/datasets/${ds.name}\` maps to host \`${hostPath}\` (${ds.mode})`);
|
|
208
|
+
}
|
|
209
|
+
lines.push('- Treat other host paths as unavailable unless explicitly mounted.');
|
|
210
|
+
lines.push('- When reporting file paths to the user, prefer showing both container and host paths when helpful.');
|
|
211
|
+
if (datasets.length > 0) {
|
|
212
|
+
lines.push('');
|
|
213
|
+
lines.push('### Available Datasets');
|
|
214
|
+
lines.push('The following named datasets are mounted and available for analysis:');
|
|
215
|
+
for (const ds of datasets) {
|
|
216
|
+
const desc = ds.description ? ` — ${ds.description}` : '';
|
|
217
|
+
lines.push(`- **${ds.name}** at \`/datasets/${ds.name}\` (${ds.mode})${desc}`);
|
|
218
|
+
}
|
|
219
|
+
lines.push('');
|
|
220
|
+
lines.push('Use these dataset paths directly when the user references data by name.');
|
|
221
|
+
}
|
|
222
|
+
if (session.config.slurm.enabled) {
|
|
223
|
+
lines.push('');
|
|
224
|
+
lines.push('### SLURM Integration');
|
|
225
|
+
lines.push('- SLURM commands (srun, sbatch, squeue, scancel) are available.');
|
|
226
|
+
lines.push('- LabGate tracks your SLURM jobs automatically via squeue polling.');
|
|
227
|
+
if (session.config.slurm.mcp_server && session.agent.toLowerCase() === 'claude') {
|
|
228
|
+
lines.push('- Use the `labgate-slurm` MCP tools to check job status and read output files.');
|
|
229
|
+
lines.push('- Available tools: `list_slurm_jobs`, `get_slurm_job`, `get_slurm_output`, `cancel_slurm_job`, `set_slurm_job_notes`');
|
|
230
|
+
lines.push('- Jobs may have user-annotated notes visible via `get_slurm_job`. Use `set_slurm_job_notes` to add your own observations.');
|
|
231
|
+
}
|
|
232
|
+
lines.push('- When submitting jobs, prefer using `--output` and `--error` flags to set explicit paths.');
|
|
233
|
+
}
|
|
234
|
+
lines.push(LABGATE_INSTRUCTION_END);
|
|
235
|
+
return lines.join('\n');
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Inject a LabGate-managed instruction block into the agent-specific instruction file.
|
|
239
|
+
* The block is removed when the session ends unless another active session
|
|
240
|
+
* still manages the same instruction file in that workdir.
|
|
241
|
+
*/
|
|
242
|
+
function installLabgateInstruction(session) {
|
|
243
|
+
const filename = getInstructionFileForAgent(session.agent);
|
|
244
|
+
const targetPath = (0, path_1.join)(session.workdir, filename);
|
|
245
|
+
const existedBefore = (0, fs_1.existsSync)(targetPath);
|
|
246
|
+
try {
|
|
247
|
+
const current = existedBefore ? (0, fs_1.readFileSync)(targetPath, 'utf-8') : '';
|
|
248
|
+
const stripped = stripLabgateInstructionBlock(current);
|
|
249
|
+
const block = buildLabgateInstructionBlock(session);
|
|
250
|
+
const next = stripped.trim().length === 0
|
|
251
|
+
? `${block}\n`
|
|
252
|
+
: `${block}\n\n${stripped}`;
|
|
253
|
+
if (next !== current) {
|
|
254
|
+
(0, fs_1.writeFileSync)(targetPath, next, 'utf-8');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
log.warn(`Could not write ${filename}: ${err.message ?? String(err)}`);
|
|
259
|
+
return () => { };
|
|
260
|
+
}
|
|
261
|
+
return () => cleanupLabgateInstructionFile(session.workdir, filename, !existedBefore);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Clean up stale session files from dead local processes.
|
|
265
|
+
* Called on session start to tidy up after crashes.
|
|
266
|
+
*/
|
|
267
|
+
function cleanStaleSessionFiles() {
|
|
268
|
+
const dir = (0, config_js_1.getSessionsDir)();
|
|
269
|
+
if (!(0, fs_1.existsSync)(dir))
|
|
270
|
+
return;
|
|
271
|
+
const localHost = (0, os_1.hostname)();
|
|
272
|
+
try {
|
|
273
|
+
const files = (0, fs_1.readdirSync)(dir).filter(f => f.endsWith('.json'));
|
|
274
|
+
for (const file of files) {
|
|
275
|
+
try {
|
|
276
|
+
const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
|
|
277
|
+
// Only clean up sessions from this node
|
|
278
|
+
if (data.node !== localHost)
|
|
279
|
+
continue;
|
|
280
|
+
// Check if the process is still alive
|
|
281
|
+
try {
|
|
282
|
+
process.kill(data.pid, 0);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Process is dead — remove stale file
|
|
286
|
+
(0, fs_1.unlinkSync)((0, path_1.join)(dir, file));
|
|
287
|
+
cleanupLabgateInstructionFile(data.workdir, getInstructionFileForAgent(data.agent), false);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch { /* skip unparseable files */ }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch { /* dir read error */ }
|
|
294
|
+
}
|
|
55
295
|
// ── SIF image management (Apptainer/Singularity) ─────────
|
|
56
296
|
/**
|
|
57
297
|
* Convert a container image URI to a local SIF filename.
|
|
@@ -85,7 +325,7 @@ function ensureSifImage(runtime, image) {
|
|
|
85
325
|
}
|
|
86
326
|
return sifPath;
|
|
87
327
|
}
|
|
88
|
-
// ── OCI image management (
|
|
328
|
+
// ── OCI image management (Docker) ────────────────────────
|
|
89
329
|
function ensureOciImage(runtime, image) {
|
|
90
330
|
try {
|
|
91
331
|
if (runtime === 'docker') {
|
|
@@ -107,7 +347,6 @@ function ensureOciImage(runtime, image) {
|
|
|
107
347
|
if (runtime === 'docker') {
|
|
108
348
|
log.step('Fix: sudo usermod -aG docker $USER && newgrp docker');
|
|
109
349
|
}
|
|
110
|
-
log.step('Or use podman (rootless): labgate config set runtime podman');
|
|
111
350
|
}
|
|
112
351
|
else {
|
|
113
352
|
log.error(`Failed to pull image "${image}" with ${runtime}.`);
|
|
@@ -173,6 +412,11 @@ function getMountRoots(session) {
|
|
|
173
412
|
const target = `/mnt/${(0, path_1.basename)(resolved)}`;
|
|
174
413
|
return { host: resolved, container: target };
|
|
175
414
|
}),
|
|
415
|
+
// Datasets mounted under /datasets/{name}
|
|
416
|
+
...(config.datasets || []).map(({ path: p, name }) => {
|
|
417
|
+
const resolved = p.replace(/^~/, (0, os_1.homedir)());
|
|
418
|
+
return { host: resolved, container: `/datasets/${name}` };
|
|
419
|
+
}),
|
|
176
420
|
];
|
|
177
421
|
}
|
|
178
422
|
// ── Dry-run runtime fallback ──────────────────────────────
|
|
@@ -223,13 +467,13 @@ function buildFilteredProxyEnv(config) {
|
|
|
223
467
|
}
|
|
224
468
|
return env;
|
|
225
469
|
}
|
|
226
|
-
function buildNetworkArgs(config
|
|
470
|
+
function buildNetworkArgs(config) {
|
|
227
471
|
if (config.network.mode === 'none')
|
|
228
472
|
return ['--network=none'];
|
|
229
473
|
if (config.network.mode === 'host')
|
|
230
474
|
return ['--network=host'];
|
|
231
475
|
// filtered
|
|
232
|
-
return
|
|
476
|
+
return ['--network=bridge'];
|
|
233
477
|
}
|
|
234
478
|
function prepareCommonArgs(session, sessionId, tokenEnv) {
|
|
235
479
|
const { agent, config } = session;
|
|
@@ -250,8 +494,8 @@ function prepareCommonArgs(session, sessionId, tokenEnv) {
|
|
|
250
494
|
];
|
|
251
495
|
return { blockedMounts, emptyDir, envArgs };
|
|
252
496
|
}
|
|
253
|
-
// ── Build
|
|
254
|
-
function
|
|
497
|
+
// ── Build Docker arguments ────────────────────────────────
|
|
498
|
+
function buildDockerArgs(session, sessionId, tokenEnv = []) {
|
|
255
499
|
const { agent, workdir, config, imageOverride } = session;
|
|
256
500
|
const image = imageOverride ?? config.image;
|
|
257
501
|
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
@@ -267,7 +511,7 @@ function buildPodmanArgs(session, runtime, sessionId, tokenEnv = []) {
|
|
|
267
511
|
'--security-opt=no-new-privileges',
|
|
268
512
|
'--pids-limit=512',
|
|
269
513
|
// ── Network ──
|
|
270
|
-
...buildNetworkArgs(config
|
|
514
|
+
...buildNetworkArgs(config),
|
|
271
515
|
// ── Persistent sandbox HOME ──
|
|
272
516
|
'--volume', `${sandboxHome}:/home/sandbox:rw`,
|
|
273
517
|
// ── Working directory ──
|
|
@@ -279,6 +523,11 @@ function buildPodmanArgs(session, runtime, sessionId, tokenEnv = []) {
|
|
|
279
523
|
const target = `/mnt/${(0, path_1.basename)(resolved)}`;
|
|
280
524
|
return `--volume=${resolved}:${target}:${mode}`;
|
|
281
525
|
}),
|
|
526
|
+
// ── Dataset mounts ──
|
|
527
|
+
...(config.datasets || []).map(({ path: p, name, mode }) => {
|
|
528
|
+
const resolved = p.replace(/^~/, (0, os_1.homedir)());
|
|
529
|
+
return `--volume=${resolved}:/datasets/${name}:${mode}`;
|
|
530
|
+
}),
|
|
282
531
|
// ── Block sensitive paths ──
|
|
283
532
|
...blockedMounts.flatMap(({ containerPath, kind }) => {
|
|
284
533
|
const source = kind === 'dir' ? emptyDir : '/dev/null';
|
|
@@ -313,6 +562,12 @@ function buildApptainerArgs(session, runtime, sifPath, sessionId, tokenEnv = [])
|
|
|
313
562
|
const bindSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
|
|
314
563
|
return ['--bind', bindSpec];
|
|
315
564
|
}),
|
|
565
|
+
// ── Dataset mounts ──
|
|
566
|
+
...(config.datasets || []).flatMap(({ path: p, name, mode }) => {
|
|
567
|
+
const resolved = p.replace(/^~/, (0, os_1.homedir)());
|
|
568
|
+
const bindSpec = mode === 'ro' ? `${resolved}:/datasets/${name}:ro` : `${resolved}:/datasets/${name}`;
|
|
569
|
+
return ['--bind', bindSpec];
|
|
570
|
+
}),
|
|
316
571
|
// ── Block sensitive paths ──
|
|
317
572
|
...blockedMounts.flatMap(({ containerPath, kind }) => {
|
|
318
573
|
const source = kind === 'dir' ? emptyDir : '/dev/null';
|
|
@@ -367,38 +622,101 @@ function buildEntrypoint(agent) {
|
|
|
367
622
|
lines.push(`if ! command -v ${setup.bin} >/dev/null 2>&1; then`, ` echo "[labgate] Installing ${setup.pkg}..."`, ` ${setup.installer}`, 'fi', `echo "[labgate] Starting ${setup.bin} in /work"`, `exec ${setup.bin}`);
|
|
368
623
|
return lines.join('\n');
|
|
369
624
|
}
|
|
370
|
-
// ──
|
|
625
|
+
// ── Browser-open hook for OAuth (via sandbox home) ────────
|
|
626
|
+
/**
|
|
627
|
+
* Detect whether we can open a browser on this machine.
|
|
628
|
+
* Returns false for SSH sessions, headless servers, etc.
|
|
629
|
+
*/
|
|
630
|
+
function canOpenBrowser() {
|
|
631
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
|
|
632
|
+
return false;
|
|
633
|
+
if ((0, os_1.platform)() === 'darwin')
|
|
634
|
+
return true;
|
|
635
|
+
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY)
|
|
636
|
+
return true;
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Copy text to the user's local clipboard via OSC 52 escape sequence.
|
|
641
|
+
* Works over SSH in terminals that support it: iTerm2, kitty, alacritty,
|
|
642
|
+
* Windows Terminal, tmux (with set-clipboard on), foot, WezTerm, etc.
|
|
643
|
+
*/
|
|
644
|
+
function osc52Copy(text) {
|
|
645
|
+
const encoded = Buffer.from(text).toString('base64');
|
|
646
|
+
// Write directly to the TTY to bypass any stdout buffering/piping
|
|
647
|
+
const tty = process.stderr.isTTY ? process.stderr : process.stdout;
|
|
648
|
+
tty.write(`\x1b]52;c;${encoded}\x07`);
|
|
649
|
+
}
|
|
650
|
+
const OAUTH_URL_DEDUPE_WINDOW_MS = 20_000;
|
|
651
|
+
let lastHandledOAuthUrl = '';
|
|
652
|
+
let lastHandledOAuthAt = 0;
|
|
653
|
+
function handleOAuthUrl(url, options) {
|
|
654
|
+
const now = Date.now();
|
|
655
|
+
if (url === lastHandledOAuthUrl && (now - lastHandledOAuthAt) < OAUTH_URL_DEDUPE_WINDOW_MS) {
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
lastHandledOAuthUrl = url;
|
|
659
|
+
lastHandledOAuthAt = now;
|
|
660
|
+
if (options.isRemote) {
|
|
661
|
+
// SSH / headless: push URL to local clipboard via OSC 52 if supported.
|
|
662
|
+
osc52Copy(url);
|
|
663
|
+
log.info(`Login URL:\n${url}`);
|
|
664
|
+
log.info('Tried to copy URL via terminal clipboard (OSC 52).');
|
|
665
|
+
log.info('Open it in your local browser, then paste the code back here.');
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (options.hostPlatform === 'darwin') {
|
|
669
|
+
try {
|
|
670
|
+
options.execSync('pbcopy', [], { input: url, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
671
|
+
}
|
|
672
|
+
catch { /* best effort */ }
|
|
673
|
+
try {
|
|
674
|
+
options.execSync('open', [url], { stdio: 'ignore' });
|
|
675
|
+
log.success('Login URL opened in browser');
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
log.warn('Could not auto-open browser. URL copied to clipboard (if available).');
|
|
679
|
+
log.info(`Login URL:\n${url}`);
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
// Local Linux with display
|
|
684
|
+
try {
|
|
685
|
+
options.execSync('xdg-open', [url], { stdio: 'ignore' });
|
|
686
|
+
log.success('Login URL opened in browser');
|
|
687
|
+
}
|
|
688
|
+
catch {
|
|
689
|
+
osc52Copy(url);
|
|
690
|
+
log.warn('Could not auto-open browser. Tried clipboard copy via terminal.');
|
|
691
|
+
log.info(`Login URL:\n${url}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
371
694
|
/**
|
|
372
695
|
* Creates a function that buffers container output and watches for OAuth
|
|
373
|
-
* URLs.
|
|
374
|
-
* copies it to the local clipboard via OSC 52.
|
|
375
|
-
*
|
|
376
|
-
* Works regardless of whether the BROWSER hook fires — this is a safety
|
|
377
|
-
* net that catches the URL directly from Claude Code's terminal output.
|
|
696
|
+
* URLs. Works as a fallback if the BROWSER hook isn't triggered.
|
|
378
697
|
*/
|
|
379
|
-
function createOAuthInterceptor() {
|
|
698
|
+
function createOAuthInterceptor(options = {}) {
|
|
380
699
|
let buffer = '';
|
|
381
700
|
let handled = false;
|
|
701
|
+
const isRemote = options.forceRemote ?? !canOpenBrowser();
|
|
702
|
+
const hostPlatform = options.platformOverride ?? (0, os_1.platform)();
|
|
703
|
+
const execSync = options.execSync ?? child_process_1.execFileSync;
|
|
382
704
|
return {
|
|
383
705
|
feed(data) {
|
|
384
706
|
if (handled)
|
|
385
707
|
return data;
|
|
386
708
|
buffer += data;
|
|
387
|
-
// Keep buffer from growing unbounded — only need to look at recent output
|
|
709
|
+
// Keep buffer from growing unbounded — only need to look at recent output.
|
|
388
710
|
if (buffer.length > 8000) {
|
|
389
711
|
buffer = buffer.slice(-6000);
|
|
390
712
|
}
|
|
391
|
-
// Look for the OAuth URL pattern — it may be split across lines
|
|
392
|
-
// Claude Code wraps long URLs at terminal width boundaries
|
|
713
|
+
// Look for the OAuth URL pattern — it may be split across lines.
|
|
393
714
|
const match = buffer.match(/https:\/\/claude\.ai\/oauth\/authorize\?[^\s]*/);
|
|
394
715
|
if (!match)
|
|
395
716
|
return data;
|
|
396
|
-
//
|
|
397
|
-
// that Claude Code's terminal wrapping inserted.
|
|
717
|
+
// Reassemble wrapped URL by stripping terminal-inserted whitespace.
|
|
398
718
|
const urlStart = buffer.indexOf(match[0]);
|
|
399
719
|
let raw = buffer.slice(urlStart);
|
|
400
|
-
// The URL ends at "Paste code" or double newline or a line that
|
|
401
|
-
// doesn't look like URL continuation
|
|
402
720
|
const endPatterns = [/\n\s*\n/, /Paste code/, /\n\s*$/];
|
|
403
721
|
for (const pat of endPatterns) {
|
|
404
722
|
const endMatch = raw.match(pat);
|
|
@@ -406,63 +724,39 @@ function createOAuthInterceptor() {
|
|
|
406
724
|
raw = raw.slice(0, endMatch.index);
|
|
407
725
|
}
|
|
408
726
|
}
|
|
409
|
-
// Strip whitespace/newlines that the terminal wrapping inserted
|
|
410
727
|
const cleanUrl = raw.replace(/\s+/g, '').trim();
|
|
411
728
|
if (cleanUrl.length > 50 && cleanUrl.startsWith('https://')) {
|
|
412
729
|
handled = true;
|
|
413
|
-
|
|
414
|
-
osc52Copy(cleanUrl);
|
|
415
|
-
// Print to stderr so it doesn't mix with the PTY output
|
|
416
|
-
log.success('Login URL copied to your clipboard — paste it in your browser');
|
|
730
|
+
handleOAuthUrl(cleanUrl, { isRemote, hostPlatform, execSync });
|
|
417
731
|
}
|
|
418
732
|
return data;
|
|
419
733
|
},
|
|
420
734
|
};
|
|
421
735
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
function canOpenBrowser() {
|
|
428
|
-
if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
|
|
429
|
-
return false;
|
|
430
|
-
if ((0, os_1.platform)() === 'darwin')
|
|
431
|
-
return true;
|
|
432
|
-
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY)
|
|
433
|
-
return true;
|
|
434
|
-
return false;
|
|
435
|
-
}
|
|
436
|
-
/**
|
|
437
|
-
* Copy text to the user's local clipboard via OSC 52 escape sequence.
|
|
438
|
-
* Works over SSH in terminals that support it: iTerm2, kitty, alacritty,
|
|
439
|
-
* Windows Terminal, tmux (with set-clipboard on), foot, WezTerm, etc.
|
|
440
|
-
*/
|
|
441
|
-
function osc52Copy(text) {
|
|
442
|
-
const encoded = Buffer.from(text).toString('base64');
|
|
443
|
-
// Write directly to the TTY to bypass any stdout buffering/piping
|
|
444
|
-
const tty = process.stderr.isTTY ? process.stderr : process.stdout;
|
|
445
|
-
tty.write(`\x1b]52;c;${encoded}\x07`);
|
|
446
|
-
}
|
|
447
|
-
/**
|
|
448
|
-
* Sets up OAuth browser handling. Always intercepts the URL via BROWSER
|
|
449
|
-
* hook. Behaviour depends on environment:
|
|
450
|
-
*
|
|
451
|
-
* 1. Local macOS + podman: Open URL with `open`, copy with `pbcopy`,
|
|
452
|
-
* forward callback port via `podman machine ssh`.
|
|
453
|
-
* 2. Local Linux with display: Open with `xdg-open`.
|
|
454
|
-
* 3. SSH / headless: Copy URL to local clipboard via OSC 52 terminal
|
|
455
|
-
* escape sequence. Works in iTerm2, kitty, Windows Terminal, tmux, etc.
|
|
456
|
-
* Falls back to displaying the URL if OSC 52 is not supported.
|
|
457
|
-
*/
|
|
458
|
-
function setupBrowserHook() {
|
|
459
|
-
const isRemote = !canOpenBrowser();
|
|
460
|
-
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
736
|
+
function setupBrowserHook(options = {}) {
|
|
737
|
+
const isRemote = options.forceRemote ?? !canOpenBrowser();
|
|
738
|
+
const hostPlatform = options.platformOverride ?? (0, os_1.platform)();
|
|
739
|
+
const execSync = options.execSync ?? child_process_1.execFileSync;
|
|
740
|
+
const sandboxHome = options.sandboxHomeOverride ?? (0, config_js_1.getSandboxHome)();
|
|
461
741
|
const labgateDir = (0, path_1.join)(sandboxHome, '.labgate');
|
|
462
742
|
(0, fs_1.mkdirSync)(labgateDir, { recursive: true });
|
|
463
743
|
// Write the browser-open script (runs inside the container)
|
|
464
744
|
const scriptPath = (0, path_1.join)(labgateDir, 'browser-open.sh');
|
|
465
|
-
(0, fs_1.writeFileSync)(scriptPath,
|
|
745
|
+
(0, fs_1.writeFileSync)(scriptPath, [
|
|
746
|
+
'#!/bin/sh',
|
|
747
|
+
'set -eu',
|
|
748
|
+
'url=""',
|
|
749
|
+
'for arg in "$@"; do',
|
|
750
|
+
' case "$arg" in',
|
|
751
|
+
' http://*|https://*) url="$arg" ;;',
|
|
752
|
+
' esac',
|
|
753
|
+
'done',
|
|
754
|
+
'[ -n "$url" ] || url="${1:-}"',
|
|
755
|
+
'mkdir -p /home/sandbox/.labgate',
|
|
756
|
+
'printf \'%s\\n\' "$url" > /home/sandbox/.labgate/browser-url',
|
|
757
|
+
'exit 0',
|
|
758
|
+
'',
|
|
759
|
+
].join('\n'), { mode: 0o755 });
|
|
466
760
|
// Remove stale URL file
|
|
467
761
|
const urlFilePath = (0, path_1.join)(labgateDir, 'browser-url');
|
|
468
762
|
try {
|
|
@@ -470,62 +764,48 @@ function setupBrowserHook() {
|
|
|
470
764
|
}
|
|
471
765
|
catch { /* doesn't exist */ }
|
|
472
766
|
let handled = false;
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
return;
|
|
477
|
-
handled = true;
|
|
767
|
+
const processUrlFile = () => {
|
|
768
|
+
if (handled)
|
|
769
|
+
return true;
|
|
478
770
|
try {
|
|
479
771
|
const url = (0, fs_1.readFileSync)(urlFilePath, 'utf-8').trim();
|
|
480
772
|
if (!url)
|
|
481
|
-
return;
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
log.success('Login URL copied to your local clipboard via terminal');
|
|
486
|
-
log.info('Paste it in your browser to sign in, then paste the code back here');
|
|
487
|
-
}
|
|
488
|
-
else if ((0, os_1.platform)() === 'darwin') {
|
|
489
|
-
// Local macOS: open browser + copy to clipboard
|
|
490
|
-
const portMatch = url.match(/localhost%3A(\d+)/);
|
|
491
|
-
if (portMatch) {
|
|
492
|
-
const port = parseInt(portMatch[1], 10);
|
|
493
|
-
if (port > 0 && port < 65536) {
|
|
494
|
-
try {
|
|
495
|
-
(0, child_process_1.execFileSync)('podman', ['machine', 'ssh', '--', '-f', '-N', '-L', `${port}:localhost:${port}`], {
|
|
496
|
-
timeout: 5000,
|
|
497
|
-
stdio: 'ignore',
|
|
498
|
-
});
|
|
499
|
-
log.step(`Forwarding callback port ${port}`);
|
|
500
|
-
}
|
|
501
|
-
catch { /* best effort */ }
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
try {
|
|
505
|
-
(0, child_process_1.execFileSync)('pbcopy', [], { input: url, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
506
|
-
}
|
|
507
|
-
catch { /* best effort */ }
|
|
508
|
-
try {
|
|
509
|
-
(0, child_process_1.execFileSync)('open', [url]);
|
|
510
|
-
}
|
|
511
|
-
catch { /* best effort */ }
|
|
512
|
-
log.success('Login URL opened in browser and copied to clipboard');
|
|
513
|
-
}
|
|
514
|
-
else {
|
|
515
|
-
// Local Linux with display
|
|
516
|
-
try {
|
|
517
|
-
(0, child_process_1.execFileSync)('xdg-open', [url]);
|
|
518
|
-
}
|
|
519
|
-
catch {
|
|
520
|
-
osc52Copy(url);
|
|
521
|
-
log.info(`Login URL:\n${url}`);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
773
|
+
return false;
|
|
774
|
+
handled = true;
|
|
775
|
+
handleOAuthUrl(url, { isRemote, hostPlatform, execSync });
|
|
776
|
+
return true;
|
|
524
777
|
}
|
|
525
|
-
catch {
|
|
778
|
+
catch {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
// Watch for the URL file on the host side
|
|
783
|
+
const watcher = (0, fs_1.watch)(labgateDir, (_eventType, filename) => {
|
|
784
|
+
if (handled)
|
|
785
|
+
return;
|
|
786
|
+
const name = filename || '';
|
|
787
|
+
if (name !== 'browser-url')
|
|
788
|
+
return;
|
|
789
|
+
if (processUrlFile())
|
|
790
|
+
return;
|
|
791
|
+
// File may exist but not be fully flushed yet. Retry shortly.
|
|
792
|
+
setTimeout(() => { processUrlFile(); }, 60);
|
|
526
793
|
});
|
|
794
|
+
// Safety net: if fs.watch misses events, poll for a short window.
|
|
795
|
+
const poll = setInterval(() => {
|
|
796
|
+
if (handled) {
|
|
797
|
+
clearInterval(poll);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
processUrlFile();
|
|
801
|
+
}, 200);
|
|
802
|
+
const pollTimeout = setTimeout(() => clearInterval(poll), 60_000);
|
|
803
|
+
poll.unref?.();
|
|
804
|
+
pollTimeout.unref?.();
|
|
527
805
|
const cleanup = () => {
|
|
528
806
|
watcher.close();
|
|
807
|
+
clearInterval(poll);
|
|
808
|
+
clearTimeout(pollTimeout);
|
|
529
809
|
try {
|
|
530
810
|
(0, fs_1.unlinkSync)(urlFilePath);
|
|
531
811
|
}
|
|
@@ -633,6 +913,13 @@ function formatStatusFooter(session, runtime, sessionId, image) {
|
|
|
633
913
|
function logSessionStart(session, sessionId) {
|
|
634
914
|
if (!session.config.audit.enabled)
|
|
635
915
|
return;
|
|
916
|
+
const datasets = (session.config.datasets || []).map(ds => ({
|
|
917
|
+
path: ds.path.replace(/^~/, (0, os_1.homedir)()),
|
|
918
|
+
name: ds.name,
|
|
919
|
+
target: `/datasets/${ds.name}`,
|
|
920
|
+
mode: ds.mode,
|
|
921
|
+
...(ds.description ? { description: ds.description } : {}),
|
|
922
|
+
}));
|
|
636
923
|
const event = {
|
|
637
924
|
timestamp: new Date().toISOString(),
|
|
638
925
|
session: sessionId,
|
|
@@ -648,8 +935,9 @@ function logSessionStart(session, sessionId) {
|
|
|
648
935
|
mode: p.mode,
|
|
649
936
|
})),
|
|
650
937
|
],
|
|
938
|
+
...(datasets.length > 0 ? { datasets } : {}),
|
|
651
939
|
};
|
|
652
|
-
(0, audit_js_1.writeAuditEvent)(session.config, event);
|
|
940
|
+
(0, audit_js_1.writeAuditEvent)(session.config, event, { sharedAuditDir: session.sharedAuditDir });
|
|
653
941
|
}
|
|
654
942
|
function logSessionEnd(session, sessionId, exitCode) {
|
|
655
943
|
if (!session.config.audit.enabled)
|
|
@@ -659,7 +947,7 @@ function logSessionEnd(session, sessionId, exitCode) {
|
|
|
659
947
|
session: sessionId,
|
|
660
948
|
event: 'session_end',
|
|
661
949
|
exit_code: exitCode,
|
|
662
|
-
});
|
|
950
|
+
}, { sharedAuditDir: session.sharedAuditDir });
|
|
663
951
|
}
|
|
664
952
|
function setupSessionTimeout(session, sessionId, runtime, isExited, killChild) {
|
|
665
953
|
const timeoutHours = session.config.session_timeout_hours;
|
|
@@ -676,7 +964,7 @@ function setupSessionTimeout(session, sessionId, runtime, isExited, killChild) {
|
|
|
676
964
|
session: sessionId,
|
|
677
965
|
event: 'session_timeout',
|
|
678
966
|
timeout_hours: timeoutHours,
|
|
679
|
-
});
|
|
967
|
+
}, { sharedAuditDir: session.sharedAuditDir });
|
|
680
968
|
}
|
|
681
969
|
if (!(0, runtime_js_1.isApptainerFamily)(runtime)) {
|
|
682
970
|
try {
|
|
@@ -703,12 +991,12 @@ function printSessionInfo(session, sessionId, runtime) {
|
|
|
703
991
|
['Network', mode],
|
|
704
992
|
['Timeout', timeoutLabel],
|
|
705
993
|
['Blocked', `${session.config.filesystem.blocked_patterns.length} patterns`],
|
|
994
|
+
...((session.config.datasets || []).length > 0 ? [['Datasets', `${session.config.datasets.length} mounted`]] : []),
|
|
706
995
|
...(session.config.audit.enabled ? [['Audit', (0, config_js_1.getLogDir)(session.config)]] : []),
|
|
707
|
-
['Settings', `http://${(0, os_1.hostname)()}:7700`],
|
|
708
996
|
]);
|
|
709
997
|
console.error('');
|
|
710
998
|
if ((0, runtime_js_1.isApptainerFamily)(runtime) && mode !== 'host') {
|
|
711
|
-
log.warn(`Apptainer does not enforce network=${mode}. Use
|
|
999
|
+
log.warn(`Apptainer does not enforce network=${mode}. Use network policy on the host for strict isolation.`);
|
|
712
1000
|
}
|
|
713
1001
|
if (mode === 'filtered') {
|
|
714
1002
|
log.warn('Filtered mode relies on your proxy. Set LABGATE_PROXY or HTTP_PROXY.');
|
|
@@ -742,65 +1030,154 @@ async function startSession(session) {
|
|
|
742
1030
|
if (!session.dryRun) {
|
|
743
1031
|
ensureOciImage(runtime, image);
|
|
744
1032
|
}
|
|
745
|
-
args =
|
|
1033
|
+
args = buildDockerArgs(session, sessionId, [...tokenEnv, ...(browserHook?.env ?? [])]);
|
|
746
1034
|
}
|
|
747
1035
|
if (session.dryRun) {
|
|
748
1036
|
prettyPrintCommand(runtime, args);
|
|
749
1037
|
return;
|
|
750
1038
|
}
|
|
751
|
-
// Create OAuth URL interceptor
|
|
752
|
-
|
|
1039
|
+
// Create OAuth URL interceptor as a fallback when BROWSER hook does not fire.
|
|
1040
|
+
// This parses Claude output and handles wrapped OAuth URLs.
|
|
1041
|
+
const oauthInterceptor = (session.agent === 'claude' && tokenEnv.length === 0)
|
|
1042
|
+
? createOAuthInterceptor()
|
|
1043
|
+
: null;
|
|
1044
|
+
// Clean up stale session files from crashed processes, then register this session
|
|
1045
|
+
cleanStaleSessionFiles();
|
|
1046
|
+
writeSessionFile(sessionId, session, image);
|
|
1047
|
+
const cleanupLabgateInstruction = installLabgateInstruction(session);
|
|
1048
|
+
// Start SLURM job tracking if enabled
|
|
1049
|
+
let sessionSlurmDB = null;
|
|
1050
|
+
let sessionSlurmPoller = null;
|
|
1051
|
+
const cleanupSlurm = () => {
|
|
1052
|
+
if (sessionSlurmPoller) {
|
|
1053
|
+
sessionSlurmPoller.stop();
|
|
1054
|
+
sessionSlurmPoller = null;
|
|
1055
|
+
}
|
|
1056
|
+
if (sessionSlurmDB) {
|
|
1057
|
+
try {
|
|
1058
|
+
sessionSlurmDB.close();
|
|
1059
|
+
}
|
|
1060
|
+
catch { }
|
|
1061
|
+
sessionSlurmDB = null;
|
|
1062
|
+
}
|
|
1063
|
+
};
|
|
1064
|
+
if (session.config.slurm.enabled) {
|
|
1065
|
+
try {
|
|
1066
|
+
sessionSlurmDB = new slurm_db_js_1.SlurmJobDB((0, config_js_1.getSlurmDbPath)());
|
|
1067
|
+
sessionSlurmPoller = new slurm_poller_js_1.SlurmPoller({
|
|
1068
|
+
db: sessionSlurmDB,
|
|
1069
|
+
pollIntervalMs: session.config.slurm.poll_interval_seconds * 1000,
|
|
1070
|
+
sacctLookbackHours: session.config.slurm.sacct_lookback_hours,
|
|
1071
|
+
});
|
|
1072
|
+
sessionSlurmPoller.start();
|
|
1073
|
+
}
|
|
1074
|
+
catch {
|
|
1075
|
+
// SLURM tracking unavailable (better-sqlite3 not installed)
|
|
1076
|
+
cleanupSlurm();
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
// Auto-register SLURM MCP server for Claude Code
|
|
1080
|
+
if (session.config.slurm.enabled && session.config.slurm.mcp_server && session.agent.toLowerCase() === 'claude') {
|
|
1081
|
+
try {
|
|
1082
|
+
const sandboxHome = (0, config_js_1.getSandboxHome)();
|
|
1083
|
+
const claudeDir = (0, path_1.join)(sandboxHome, '.claude');
|
|
1084
|
+
(0, config_js_1.ensurePrivateDir)(claudeDir);
|
|
1085
|
+
// The MCP server script path (compiled dist)
|
|
1086
|
+
const mcpServerPath = (0, path_1.resolve)(__dirname, 'slurm-mcp.js');
|
|
1087
|
+
const dbPath = (0, config_js_1.getSlurmDbPath)();
|
|
1088
|
+
// Read existing mcp.json or start fresh
|
|
1089
|
+
const mcpConfigPath = (0, path_1.join)(claudeDir, 'mcp.json');
|
|
1090
|
+
let mcpConfig = {};
|
|
1091
|
+
try {
|
|
1092
|
+
if ((0, fs_1.existsSync)(mcpConfigPath)) {
|
|
1093
|
+
mcpConfig = JSON.parse((0, fs_1.readFileSync)(mcpConfigPath, 'utf-8'));
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
catch { /* start fresh */ }
|
|
1097
|
+
if (!mcpConfig.mcpServers)
|
|
1098
|
+
mcpConfig.mcpServers = {};
|
|
1099
|
+
mcpConfig.mcpServers['labgate-slurm'] = {
|
|
1100
|
+
command: 'node',
|
|
1101
|
+
args: [mcpServerPath, '--db', dbPath],
|
|
1102
|
+
};
|
|
1103
|
+
(0, fs_1.writeFileSync)(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
|
|
1104
|
+
(0, config_js_1.ensurePrivateFile)(mcpConfigPath);
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
// Best effort — MCP registration failure is non-fatal
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
753
1110
|
logSessionStart(session, sessionId);
|
|
754
1111
|
printSessionInfo(session, sessionId, runtime);
|
|
755
|
-
// Start settings UI server alongside the session
|
|
756
|
-
const uiServer = (0, ui_js_1.startUI)(7700, false);
|
|
757
1112
|
if (footerMode === 'once') {
|
|
758
1113
|
console.log(footerLine);
|
|
759
1114
|
}
|
|
760
1115
|
const wantsSticky = footerMode === 'sticky';
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1116
|
+
const needsOAuthPtyFallback = !!oauthInterceptor;
|
|
1117
|
+
const hasTty = !!(process.stdout.isTTY && process.stdin.isTTY);
|
|
1118
|
+
const shouldUsePty = hasTty && (wantsSticky || needsOAuthPtyFallback);
|
|
1119
|
+
if (shouldUsePty) {
|
|
1120
|
+
const pty = await loadPty();
|
|
1121
|
+
if (!pty) {
|
|
1122
|
+
if (wantsSticky) {
|
|
1123
|
+
log.step('Sticky footer requires node-pty. Using one-time footer.');
|
|
1124
|
+
}
|
|
1125
|
+
else if (needsOAuthPtyFallback) {
|
|
1126
|
+
log.step('OAuth URL fallback interceptor unavailable (node-pty missing).');
|
|
1127
|
+
}
|
|
764
1128
|
}
|
|
765
1129
|
else {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1130
|
+
let runtimePath;
|
|
1131
|
+
try {
|
|
1132
|
+
runtimePath = (0, child_process_1.execFileSync)('which', [runtime], { encoding: 'utf-8' }).trim();
|
|
769
1133
|
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const cols = process.stdout.columns || 80;
|
|
784
|
-
const rows = process.stdout.rows || 24;
|
|
785
|
-
const child = pty.spawn(runtimePath, args, {
|
|
1134
|
+
catch {
|
|
1135
|
+
runtimePath = runtime;
|
|
1136
|
+
}
|
|
1137
|
+
const cleanEnv = {};
|
|
1138
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
1139
|
+
if (v !== undefined)
|
|
1140
|
+
cleanEnv[k] = v;
|
|
1141
|
+
}
|
|
1142
|
+
const cols = process.stdout.columns || 80;
|
|
1143
|
+
const rows = process.stdout.rows || 24;
|
|
1144
|
+
let child;
|
|
1145
|
+
try {
|
|
1146
|
+
child = pty.spawn(runtimePath, args, {
|
|
786
1147
|
name: 'xterm-256color',
|
|
787
1148
|
cols,
|
|
788
1149
|
rows,
|
|
789
1150
|
cwd: process.cwd(),
|
|
790
1151
|
env: cleanEnv,
|
|
791
1152
|
});
|
|
1153
|
+
}
|
|
1154
|
+
catch (err) {
|
|
1155
|
+
log.step(`PTY spawn failed (${err?.message ?? String(err)}). Falling back to standard spawn.`);
|
|
1156
|
+
// Fall through to standard spawn path below.
|
|
1157
|
+
child = null;
|
|
1158
|
+
}
|
|
1159
|
+
if (!child) {
|
|
1160
|
+
// Continue below with non-PTY spawn.
|
|
1161
|
+
}
|
|
1162
|
+
else {
|
|
792
1163
|
let exited = false;
|
|
793
1164
|
const resizeHandler = () => {
|
|
794
1165
|
child.resize(process.stdout.columns || 80, process.stdout.rows || 24);
|
|
795
|
-
|
|
1166
|
+
if (wantsSticky) {
|
|
1167
|
+
renderStickyFooter(footerLine);
|
|
1168
|
+
}
|
|
796
1169
|
};
|
|
797
1170
|
process.stdout.on('resize', resizeHandler);
|
|
798
|
-
|
|
1171
|
+
if (wantsSticky) {
|
|
1172
|
+
renderStickyFooter(footerLine);
|
|
1173
|
+
}
|
|
799
1174
|
child.onData((data) => {
|
|
800
1175
|
if (oauthInterceptor)
|
|
801
1176
|
oauthInterceptor.feed(data);
|
|
802
1177
|
process.stdout.write(data);
|
|
803
|
-
|
|
1178
|
+
if (wantsSticky) {
|
|
1179
|
+
renderStickyFooter(footerLine);
|
|
1180
|
+
}
|
|
804
1181
|
});
|
|
805
1182
|
if (process.stdin.isTTY) {
|
|
806
1183
|
process.stdin.setRawMode(true);
|
|
@@ -815,7 +1192,9 @@ async function startSession(session) {
|
|
|
815
1192
|
if (timeoutHandle)
|
|
816
1193
|
clearTimeout(timeoutHandle);
|
|
817
1194
|
browserHook?.cleanup();
|
|
818
|
-
|
|
1195
|
+
cleanupSlurm();
|
|
1196
|
+
removeSessionFile(sessionId, session.sharedSessionsDir);
|
|
1197
|
+
cleanupLabgateInstruction();
|
|
819
1198
|
if (process.stdin.isTTY) {
|
|
820
1199
|
process.stdin.setRawMode(false);
|
|
821
1200
|
}
|
|
@@ -830,6 +1209,12 @@ async function startSession(session) {
|
|
|
830
1209
|
}
|
|
831
1210
|
}
|
|
832
1211
|
}
|
|
1212
|
+
else if (wantsSticky) {
|
|
1213
|
+
log.step('Sticky footer needs a TTY; using one-time footer.');
|
|
1214
|
+
}
|
|
1215
|
+
else if (needsOAuthPtyFallback && !hasTty) {
|
|
1216
|
+
log.step('OAuth URL fallback interceptor requires a TTY; relying on BROWSER hook only.');
|
|
1217
|
+
}
|
|
833
1218
|
if (footerMode === 'sticky') {
|
|
834
1219
|
console.log(footerLine);
|
|
835
1220
|
}
|
|
@@ -843,7 +1228,9 @@ async function startSession(session) {
|
|
|
843
1228
|
if (timeoutHandle)
|
|
844
1229
|
clearTimeout(timeoutHandle);
|
|
845
1230
|
browserHook?.cleanup();
|
|
846
|
-
|
|
1231
|
+
cleanupSlurm();
|
|
1232
|
+
removeSessionFile(sessionId, session.sharedSessionsDir);
|
|
1233
|
+
cleanupLabgateInstruction();
|
|
847
1234
|
logSessionEnd(session, sessionId, code ?? 0);
|
|
848
1235
|
process.exit(code ?? 0);
|
|
849
1236
|
});
|
|
@@ -853,36 +1240,40 @@ async function startSession(session) {
|
|
|
853
1240
|
}
|
|
854
1241
|
// ── List running sessions ─────────────────────────────────
|
|
855
1242
|
async function listSessions() {
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
runtime = (0, runtime_js_1.getRuntime)(config.runtime);
|
|
860
|
-
}
|
|
861
|
-
catch (err) {
|
|
862
|
-
console.error(err.message ?? String(err));
|
|
863
|
-
process.exit(1);
|
|
864
|
-
}
|
|
865
|
-
if ((0, runtime_js_1.isApptainerFamily)(runtime)) {
|
|
866
|
-
log.info('Apptainer sessions run as processes. Use:');
|
|
867
|
-
log.step('ps aux | grep apptainer');
|
|
1243
|
+
const dir = (0, config_js_1.getSessionsDir)();
|
|
1244
|
+
if (!(0, fs_1.existsSync)(dir)) {
|
|
1245
|
+
log.info('No active sessions.');
|
|
868
1246
|
return;
|
|
869
1247
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1248
|
+
const localHost = (0, os_1.hostname)();
|
|
1249
|
+
const files = (0, fs_1.readdirSync)(dir).filter(f => f.endsWith('.json'));
|
|
1250
|
+
const sessions = [];
|
|
1251
|
+
for (const file of files) {
|
|
1252
|
+
try {
|
|
1253
|
+
const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
|
|
1254
|
+
// Check if process is still alive (local node only)
|
|
1255
|
+
if (data.node === localHost) {
|
|
1256
|
+
try {
|
|
1257
|
+
process.kill(data.pid, 0);
|
|
1258
|
+
}
|
|
1259
|
+
catch {
|
|
1260
|
+
(0, fs_1.unlinkSync)((0, path_1.join)(dir, file));
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
sessions.push(data);
|
|
879
1265
|
}
|
|
880
|
-
|
|
881
|
-
console.log('NAME\t\t\tSTATUS\t\tRUNNING');
|
|
882
|
-
console.log(output);
|
|
1266
|
+
catch { /* skip unparseable */ }
|
|
883
1267
|
}
|
|
884
|
-
|
|
1268
|
+
if (sessions.length === 0) {
|
|
885
1269
|
log.info('No active sessions.');
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
log.header('Active sessions');
|
|
1273
|
+
console.log('ID\t\tAGENT\tNODE\t\tWORKDIR\t\t\tSTARTED');
|
|
1274
|
+
for (const s of sessions) {
|
|
1275
|
+
const started = s.started?.slice(11, 19) ?? '';
|
|
1276
|
+
console.log(`${s.id}\t${s.agent}\t${s.node}\t\t${s.workdir}\t${started}`);
|
|
886
1277
|
}
|
|
887
1278
|
}
|
|
888
1279
|
// ── Stop a session ────────────────────────────────────────
|
|
@@ -912,4 +1303,85 @@ async function stopSession(id) {
|
|
|
912
1303
|
process.exit(1);
|
|
913
1304
|
}
|
|
914
1305
|
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Restart a running session with fresh config.
|
|
1308
|
+
* Stops the old process (via SIGTERM from session file), waits for cleanup,
|
|
1309
|
+
* then relaunches the same agent/workdir with the current config.
|
|
1310
|
+
*/
|
|
1311
|
+
async function restartSession(id, opts) {
|
|
1312
|
+
const dir = (0, config_js_1.getSessionsDir)();
|
|
1313
|
+
const localHost = (0, os_1.hostname)();
|
|
1314
|
+
if (!(0, fs_1.existsSync)(dir)) {
|
|
1315
|
+
log.error(`No sessions directory found.`);
|
|
1316
|
+
process.exit(1);
|
|
1317
|
+
}
|
|
1318
|
+
// Find session by ID or prefix
|
|
1319
|
+
const files = (0, fs_1.readdirSync)(dir).filter(f => f.endsWith('.json'));
|
|
1320
|
+
let sessionData = null;
|
|
1321
|
+
let sessionFile = null;
|
|
1322
|
+
for (const file of files) {
|
|
1323
|
+
try {
|
|
1324
|
+
const data = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf-8'));
|
|
1325
|
+
if (data.id === id || data.id.startsWith(id)) {
|
|
1326
|
+
sessionData = data;
|
|
1327
|
+
sessionFile = (0, path_1.join)(dir, file);
|
|
1328
|
+
break;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
catch { /* skip unparseable */ }
|
|
1332
|
+
}
|
|
1333
|
+
if (!sessionData || !sessionFile) {
|
|
1334
|
+
log.error(`Session "${id}" not found.`);
|
|
1335
|
+
process.exit(1);
|
|
1336
|
+
}
|
|
1337
|
+
if (sessionData.node !== localHost) {
|
|
1338
|
+
log.error(`Session is on node "${sessionData.node}", not this host ("${localHost}").`);
|
|
1339
|
+
process.exit(1);
|
|
1340
|
+
}
|
|
1341
|
+
const { agent, workdir } = sessionData;
|
|
1342
|
+
if (!agent || !workdir) {
|
|
1343
|
+
log.error('Session file is missing agent or workdir.');
|
|
1344
|
+
process.exit(1);
|
|
1345
|
+
}
|
|
1346
|
+
log.info(`Restarting session ${sessionData.id} (${agent} in ${workdir})`);
|
|
1347
|
+
// 1. Stop the old session
|
|
1348
|
+
log.step('Stopping old session...');
|
|
1349
|
+
try {
|
|
1350
|
+
process.kill(sessionData.pid, 'SIGTERM');
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
log.warn('Process already stopped.');
|
|
1354
|
+
}
|
|
1355
|
+
// 2. Wait for cleanup (up to 10 seconds)
|
|
1356
|
+
const deadline = Date.now() + 10_000;
|
|
1357
|
+
while ((0, fs_1.existsSync)(sessionFile) && Date.now() < deadline) {
|
|
1358
|
+
await new Promise(r => setTimeout(r, 200));
|
|
1359
|
+
}
|
|
1360
|
+
if ((0, fs_1.existsSync)(sessionFile)) {
|
|
1361
|
+
try {
|
|
1362
|
+
(0, fs_1.unlinkSync)(sessionFile);
|
|
1363
|
+
}
|
|
1364
|
+
catch { /* best effort */ }
|
|
1365
|
+
}
|
|
1366
|
+
log.success('Old session stopped.');
|
|
1367
|
+
if (opts.dryRun) {
|
|
1368
|
+
log.info('Dry run — would start new session with fresh config.');
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
// 3. Load fresh config and start new session
|
|
1372
|
+
// Use dynamic import to avoid circular dependency
|
|
1373
|
+
const { loadEffectiveConfig } = await import('./config.js');
|
|
1374
|
+
const effective = loadEffectiveConfig();
|
|
1375
|
+
const config = effective.config;
|
|
1376
|
+
log.step('Starting new session with fresh config...');
|
|
1377
|
+
await startSession({
|
|
1378
|
+
agent,
|
|
1379
|
+
workdir,
|
|
1380
|
+
config,
|
|
1381
|
+
dryRun: false,
|
|
1382
|
+
footerMode: 'once',
|
|
1383
|
+
sharedSessionsDir: effective.sharedSessionsDir,
|
|
1384
|
+
sharedAuditDir: effective.sharedAuditDir,
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
915
1387
|
//# sourceMappingURL=container.js.map
|