labgate 0.1.0

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.
@@ -0,0 +1,794 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.imageToSifName = imageToSifName;
7
+ exports.buildEntrypoint = buildEntrypoint;
8
+ exports.startSession = startSession;
9
+ exports.listSessions = listSessions;
10
+ exports.stopSession = stopSession;
11
+ const child_process_1 = require("child_process");
12
+ const fs_1 = require("fs");
13
+ const path_1 = require("path");
14
+ const os_1 = require("os");
15
+ const child_process_2 = require("child_process");
16
+ const crypto_1 = require("crypto");
17
+ const fast_glob_1 = __importDefault(require("fast-glob"));
18
+ const config_js_1 = require("./config.js");
19
+ const runtime_js_1 = require("./runtime.js");
20
+ const audit_js_1 = require("./audit.js");
21
+ // ── SIF image management (Apptainer/Singularity) ─────────
22
+ /**
23
+ * Convert a container image URI to a local SIF filename.
24
+ * e.g. "docker.io/library/ubuntu:22.04" → "docker.io_library_ubuntu_22.04.sif"
25
+ */
26
+ function imageToSifName(image) {
27
+ return image.replace(/[/:]/g, '_') + '.sif';
28
+ }
29
+ /**
30
+ * Ensure a SIF image exists in the cache directory.
31
+ * Pulls from docker:// URI if not already cached.
32
+ */
33
+ function ensureSifImage(runtime, image) {
34
+ const imagesDir = (0, config_js_1.getImagesDir)();
35
+ (0, fs_1.mkdirSync)(imagesDir, { recursive: true });
36
+ const sifPath = (0, path_1.join)(imagesDir, imageToSifName(image));
37
+ if ((0, fs_1.existsSync)(sifPath)) {
38
+ return sifPath;
39
+ }
40
+ console.log(`[labgate] Pulling image to SIF (first run): ${image}`);
41
+ (0, child_process_1.execFileSync)(runtime, ['pull', sifPath, `docker://${image}`], {
42
+ stdio: 'inherit',
43
+ });
44
+ return sifPath;
45
+ }
46
+ // ── OCI image management (Podman/Docker) ─────────────────
47
+ function ensureOciImage(runtime, image) {
48
+ try {
49
+ if (runtime === 'docker') {
50
+ (0, child_process_1.execFileSync)(runtime, ['image', 'inspect', image], { stdio: 'ignore' });
51
+ }
52
+ else {
53
+ (0, child_process_1.execFileSync)(runtime, ['image', 'exists', image], { stdio: 'ignore' });
54
+ }
55
+ }
56
+ catch {
57
+ console.log(`[labgate] Pulling image (first run): ${image}`);
58
+ (0, child_process_1.execFileSync)(runtime, ['pull', image], { stdio: 'inherit' });
59
+ }
60
+ }
61
+ function ensureEmptyDir() {
62
+ const emptyDir = (0, config_js_1.getEmptyDir)();
63
+ (0, fs_1.mkdirSync)(emptyDir, { recursive: true });
64
+ return emptyDir;
65
+ }
66
+ function resolveBlockedMounts(patterns, mounts) {
67
+ const blocked = [];
68
+ const seen = new Set();
69
+ for (const mount of mounts) {
70
+ if (!(0, fs_1.existsSync)(mount.host))
71
+ continue;
72
+ const matches = fast_glob_1.default.sync(patterns, {
73
+ cwd: mount.host,
74
+ dot: true,
75
+ onlyFiles: false,
76
+ followSymbolicLinks: false,
77
+ unique: true,
78
+ suppressErrors: true,
79
+ absolute: true,
80
+ });
81
+ for (const abs of matches) {
82
+ if (!(0, fs_1.existsSync)(abs))
83
+ continue;
84
+ const rel = (0, path_1.relative)(mount.host, abs);
85
+ if (!rel || rel.startsWith('..'))
86
+ continue;
87
+ const containerPath = (0, path_1.join)(mount.container, rel);
88
+ if (seen.has(containerPath))
89
+ continue;
90
+ try {
91
+ const stat = (0, fs_1.statSync)(abs);
92
+ blocked.push({
93
+ hostPath: abs,
94
+ containerPath,
95
+ kind: stat.isDirectory() ? 'dir' : 'file',
96
+ });
97
+ seen.add(containerPath);
98
+ }
99
+ catch {
100
+ // Skip paths that disappeared between glob and stat
101
+ }
102
+ }
103
+ }
104
+ return blocked;
105
+ }
106
+ function getMountRoots(session) {
107
+ const { workdir, config } = session;
108
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
109
+ return [
110
+ { host: sandboxHome, container: '/home/sandbox' },
111
+ { host: workdir, container: '/work' },
112
+ ...config.filesystem.extra_paths.map(({ path: p }) => {
113
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
114
+ const target = `/mnt/${(0, path_1.basename)(resolved)}`;
115
+ return { host: resolved, container: target };
116
+ }),
117
+ ];
118
+ }
119
+ // ── Dry-run runtime fallback ──────────────────────────────
120
+ function getDryRunRuntime(preferred) {
121
+ const check = (0, runtime_js_1.checkRuntime)(preferred);
122
+ if (check.ok)
123
+ return check.runtime;
124
+ // No runtime installed — use the preference for preview, or default to apptainer
125
+ if (preferred && preferred !== 'auto')
126
+ return preferred;
127
+ return 'apptainer';
128
+ }
129
+ // ── Network + policy helpers ──────────────────────────────
130
+ function resolveCommandBlacklist(config) {
131
+ const cmds = config.commands.blacklist.map(c => c.trim()).filter(Boolean);
132
+ return cmds.length ? cmds.join(':') : null;
133
+ }
134
+ function buildFilteredProxyEnv(config) {
135
+ const proxy = process.env.LABGATE_PROXY ??
136
+ process.env.HTTP_PROXY ??
137
+ process.env.HTTPS_PROXY ??
138
+ process.env.http_proxy ??
139
+ process.env.https_proxy;
140
+ const httpProxy = process.env.LABGATE_HTTP_PROXY ??
141
+ process.env.HTTP_PROXY ??
142
+ process.env.http_proxy ??
143
+ proxy ??
144
+ '';
145
+ const httpsProxy = process.env.LABGATE_HTTPS_PROXY ??
146
+ process.env.HTTPS_PROXY ??
147
+ process.env.https_proxy ??
148
+ proxy ??
149
+ '';
150
+ if (!httpProxy && !httpsProxy) {
151
+ throw new Error('[labgate] network.mode=filtered requires a proxy. ' +
152
+ 'Set LABGATE_PROXY, LABGATE_HTTP_PROXY/LABGATE_HTTPS_PROXY, or HTTP_PROXY/HTTPS_PROXY.');
153
+ }
154
+ const env = [];
155
+ if (httpProxy) {
156
+ env.push('--env', `HTTP_PROXY=${httpProxy}`, '--env', `http_proxy=${httpProxy}`);
157
+ }
158
+ if (httpsProxy) {
159
+ env.push('--env', `HTTPS_PROXY=${httpsProxy}`, '--env', `https_proxy=${httpsProxy}`);
160
+ }
161
+ env.push('--env', 'NO_PROXY=localhost,127.0.0.1', '--env', 'no_proxy=localhost,127.0.0.1');
162
+ if (config.network.allowed_domains.length > 0) {
163
+ env.push('--env', `LABGATE_ALLOWED_DOMAINS=${config.network.allowed_domains.join(',')}`);
164
+ }
165
+ return env;
166
+ }
167
+ function buildNetworkArgs(config, runtime) {
168
+ if (config.network.mode === 'none')
169
+ return ['--network=none'];
170
+ if (config.network.mode === 'host')
171
+ return ['--network=host'];
172
+ // filtered
173
+ return runtime === 'podman' ? ['--network=slirp4netns'] : ['--network=bridge'];
174
+ }
175
+ // ── Build Podman/Docker arguments ─────────────────────────
176
+ function buildPodmanArgs(session, runtime, sessionId, tokenEnv = []) {
177
+ const { agent, workdir, config, imageOverride } = session;
178
+ const image = imageOverride ?? config.image;
179
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
180
+ (0, fs_1.mkdirSync)(sandboxHome, { recursive: true });
181
+ const blockedMounts = resolveBlockedMounts(config.filesystem.blocked_patterns, getMountRoots(session));
182
+ const emptyDir = ensureEmptyDir();
183
+ const commandBlacklist = resolveCommandBlacklist(config);
184
+ const proxyEnv = config.network.mode === 'filtered' && !session.dryRun ? buildFilteredProxyEnv(config) : [];
185
+ const args = [
186
+ 'run',
187
+ '--rm',
188
+ '--interactive',
189
+ '--tty',
190
+ '--name', `labgate-${sessionId}`,
191
+ // ── Isolation ──
192
+ '--cap-drop=ALL',
193
+ '--security-opt=no-new-privileges',
194
+ '--pids-limit=512',
195
+ // ── Network ──
196
+ ...buildNetworkArgs(config, runtime),
197
+ // ── Persistent sandbox HOME ──
198
+ '--volume', `${sandboxHome}:/home/sandbox:rw`,
199
+ // ── Working directory ──
200
+ '--volume', `${workdir}:/work:rw`,
201
+ '--workdir', '/work',
202
+ // ── Extra mounts from config ──
203
+ ...config.filesystem.extra_paths.map(({ path: p, mode }) => {
204
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
205
+ const target = `/mnt/${(0, path_1.basename)(resolved)}`;
206
+ return `--volume=${resolved}:${target}:${mode}`;
207
+ }),
208
+ // ── Block sensitive paths ──
209
+ ...blockedMounts.flatMap(({ containerPath, kind }) => {
210
+ const source = kind === 'dir' ? emptyDir : '/dev/null';
211
+ return ['--volume', `${source}:${containerPath}:ro`];
212
+ }),
213
+ // ── Environment ──
214
+ '--env', `LABGATE_AGENT=${agent}`,
215
+ '--env', `LABGATE_SESSION=${sessionId}`,
216
+ '--env', `LABGATE_NETWORK_MODE=${config.network.mode}`,
217
+ '--env', 'HOME=/home/sandbox',
218
+ ...(commandBlacklist ? ['--env', `LABGATE_CMD_BLACKLIST=${commandBlacklist}`] : []),
219
+ ...tokenEnv,
220
+ ...proxyEnv,
221
+ // ── Image ──
222
+ image,
223
+ // ── Entrypoint ──
224
+ 'bash', '-lc', buildEntrypoint(agent, session.statusline ?? true),
225
+ ];
226
+ return args;
227
+ }
228
+ // ── Build Apptainer/Singularity arguments ─────────────────
229
+ function buildApptainerArgs(session, runtime, sifPath, sessionId, tokenEnv = []) {
230
+ const { agent, workdir, config } = session;
231
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
232
+ (0, fs_1.mkdirSync)(sandboxHome, { recursive: true });
233
+ const blockedMounts = resolveBlockedMounts(config.filesystem.blocked_patterns, getMountRoots(session));
234
+ const emptyDir = ensureEmptyDir();
235
+ const commandBlacklist = resolveCommandBlacklist(config);
236
+ const proxyEnv = config.network.mode === 'filtered' && !session.dryRun ? buildFilteredProxyEnv(config) : [];
237
+ const args = [
238
+ 'exec',
239
+ '--containall',
240
+ '--cleanenv',
241
+ // ── Persistent sandbox HOME ──
242
+ '--home', `${sandboxHome}:/home/sandbox`,
243
+ // ── Working directory ──
244
+ '--bind', `${workdir}:/work`,
245
+ '--pwd', '/work',
246
+ // ── Extra mounts from config ──
247
+ ...config.filesystem.extra_paths.flatMap(({ path: p, mode }) => {
248
+ const resolved = p.replace(/^~/, (0, os_1.homedir)());
249
+ const target = `/mnt/${(0, path_1.basename)(resolved)}`;
250
+ const bindSpec = mode === 'ro' ? `${resolved}:${target}:ro` : `${resolved}:${target}`;
251
+ return ['--bind', bindSpec];
252
+ }),
253
+ // ── Block sensitive paths ──
254
+ ...blockedMounts.flatMap(({ containerPath, kind }) => {
255
+ const source = kind === 'dir' ? emptyDir : '/dev/null';
256
+ return ['--bind', `${source}:${containerPath}`];
257
+ }),
258
+ // ── Environment ──
259
+ '--env', `LABGATE_AGENT=${agent}`,
260
+ '--env', `LABGATE_SESSION=${sessionId}`,
261
+ '--env', `LABGATE_NETWORK_MODE=${config.network.mode}`,
262
+ '--env', `HOME=/home/sandbox`,
263
+ ...(commandBlacklist ? ['--env', `LABGATE_CMD_BLACKLIST=${commandBlacklist}`] : []),
264
+ ...tokenEnv,
265
+ ...proxyEnv,
266
+ // ── SIF image ──
267
+ sifPath,
268
+ // ── Entrypoint ──
269
+ 'bash', '-lc', buildEntrypoint(agent, session.statusline ?? true),
270
+ ];
271
+ return args;
272
+ }
273
+ // ── Entrypoint script (inline, same logic for both backends) ──
274
+ function buildEntrypoint(agent, statuslineEnabled = true) {
275
+ const agentSetup = {
276
+ claude: {
277
+ pkg: '@anthropic-ai/claude-code',
278
+ installer: 'npm i -g @anthropic-ai/claude-code 2>&1 | tail -1',
279
+ bin: 'claude',
280
+ },
281
+ codex: {
282
+ pkg: '@openai/codex',
283
+ installer: 'npm i -g @openai/codex 2>&1 | tail -1',
284
+ bin: 'codex',
285
+ },
286
+ };
287
+ const setup = agentSetup[agent];
288
+ if (!setup) {
289
+ throw new Error(`Unknown agent: ${agent}`);
290
+ }
291
+ const lines = [
292
+ 'set -euo pipefail',
293
+ 'export HOME=/home/sandbox',
294
+ 'export PATH="$HOME/.npm-global/bin:$PATH"',
295
+ 'npm config set prefix "$HOME/.npm-global" 2>/dev/null || true',
296
+ 'if [ -n "${LABGATE_CMD_BLACKLIST:-}" ]; then',
297
+ ' mkdir -p "$HOME/.labgate-bin"',
298
+ ' IFS=":" read -r -a _labgate_cmds <<< "$LABGATE_CMD_BLACKLIST"',
299
+ ' for _cmd in "${_labgate_cmds[@]}"; do',
300
+ ' [ -z "$_cmd" ] && continue',
301
+ ' printf \'#!/usr/bin/env bash\\necho "[labgate] Command blocked: %s" >&2\\nexit 126\\n\' "$_cmd" > "$HOME/.labgate-bin/$_cmd"',
302
+ ' chmod +x "$HOME/.labgate-bin/$_cmd"',
303
+ ' done',
304
+ ' export PATH="$HOME/.labgate-bin:$PATH"',
305
+ 'fi',
306
+ '',
307
+ ];
308
+ if (agent === 'claude' && statuslineEnabled) {
309
+ lines.push('mkdir -p "$HOME/.claude"', 'STATUSLINE_PATH="$HOME/.claude/statusline.js"', 'SETTINGS_PATH="$HOME/.claude/settings.json"', 'if [ ! -f "$STATUSLINE_PATH" ] || grep -q "LABGATE_STATUSLINE" "$STATUSLINE_PATH" || grep -q "statusline.js" "$SETTINGS_PATH" 2>/dev/null; then', ' cat > "$STATUSLINE_PATH" <<\'LABGATE_STATUSLINE\'', '#!/usr/bin/env node', '// LABGATE_STATUSLINE', 'const fs = require("fs");', 'const path = require("path");', 'let input = "";', 'try { input = fs.readFileSync(0, "utf8"); } catch {}', 'let data = {};', 'try { data = JSON.parse(input || "{}"); } catch {}', 'const model = (data.model && data.model.display_name) || "unknown";', 'const currentDir = (data.workspace && data.workspace.current_dir) || "";', 'const dirBase = currentDir ? path.basename(currentDir) : "";', 'let branch = "";', 'try {', ' const headPath = path.join(currentDir || process.cwd(), ".git", "HEAD");', ' const head = fs.readFileSync(headPath, "utf8").trim();', ' if (head.startsWith("ref: ")) {', ' branch = head.replace("ref: refs/heads/", "");', ' }', '} catch {}', 'let percentUsed = null;', 'const cw = data.context_window || {};', 'const usage = cw.current_usage || null;', 'if (usage && typeof cw.context_window_size === "number" && cw.context_window_size > 0) {', ' const currentTokens = (usage.input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0);', ' percentUsed = Math.round((currentTokens * 100) / cw.context_window_size);', '}', 'const parts = ["Gated by LabGate"];', 'const agent = process.env.LABGATE_AGENT || "";', 'if (agent) parts.push(agent);', 'const session = process.env.LABGATE_SESSION || "";', 'if (session) parts.push(`id=${session.slice(0, 8)}`);', 'if (process.env.LABGATE_NETWORK_MODE) parts.push(`net=${process.env.LABGATE_NETWORK_MODE}`);', 'parts.push(`model=${model}`);', 'if (dirBase) parts.push(`dir=${dirBase}`);', 'if (branch) parts.push(`git=${branch}`);', 'if (typeof percentUsed === "number") parts.push(`ctx=${percentUsed}%`);', 'process.stdout.write(parts.join(" | "));', 'LABGATE_STATUSLINE', ' chmod +x "$STATUSLINE_PATH"', 'fi', 'node - <<\'LABGATE_STATUSLINE_SETTINGS\'', 'const fs = require("fs");', 'const path = require("path");', 'const home = process.env.HOME || "/home/sandbox";', 'const settingsPath = path.join(home, ".claude", "settings.json");', 'let settings = {};', 'try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); } catch {}', 'const desired = { type: "command", command: path.join(home, ".claude", "statusline.js"), padding: 0 };', 'const existingCmd = settings.statusLine && settings.statusLine.command;', 'if (!settings.statusLine || (typeof existingCmd === "string" && existingCmd.includes("statusline.js"))) {', ' settings.statusLine = desired;', ' fs.mkdirSync(path.dirname(settingsPath), { recursive: true });', ' fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));', '}', 'LABGATE_STATUSLINE_SETTINGS', '');
310
+ }
311
+ 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}`);
312
+ return lines.join('\n');
313
+ }
314
+ // ── Browser-open hook for OAuth (via sandbox home) ────────
315
+ /**
316
+ * Detect whether we can open a browser on this machine.
317
+ * Returns false for SSH sessions, headless servers, etc.
318
+ */
319
+ function canOpenBrowser() {
320
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY)
321
+ return false;
322
+ if ((0, os_1.platform)() === 'darwin')
323
+ return true;
324
+ if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY)
325
+ return true;
326
+ return false;
327
+ }
328
+ /**
329
+ * Sets up OAuth browser handling. Three scenarios:
330
+ *
331
+ * 1. Local macOS + podman: Set BROWSER hook, forward callback port via
332
+ * `podman machine ssh`, open URL with `open`.
333
+ * 2. Local Linux with display: Set BROWSER hook, open with xdg-open.
334
+ * No port forwarding needed (podman runs natively).
335
+ * 3. SSH / headless: Do NOT set BROWSER — Claude Code uses the code-paste
336
+ * flow (redirect to platform.claude.com). Pass COLUMNS=1000 so the URL
337
+ * doesn't get line-wrapped in the terminal.
338
+ */
339
+ function setupBrowserHook() {
340
+ if (!canOpenBrowser()) {
341
+ // SSH or headless: code-paste flow, wide columns to avoid URL wrapping
342
+ console.log('[labgate] No local browser detected (SSH/headless). OAuth will use code-paste flow.');
343
+ return { env: ['--env', 'COLUMNS=1000'], cleanup: () => { } };
344
+ }
345
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
346
+ const labgateDir = (0, path_1.join)(sandboxHome, '.labgate');
347
+ (0, fs_1.mkdirSync)(labgateDir, { recursive: true });
348
+ // Write the browser-open script (runs inside the container)
349
+ const scriptPath = (0, path_1.join)(labgateDir, 'browser-open.sh');
350
+ (0, fs_1.writeFileSync)(scriptPath, '#!/bin/sh\necho "$1" > /home/sandbox/.labgate/browser-url\n', { mode: 0o755 });
351
+ // Remove stale URL file
352
+ const urlFilePath = (0, path_1.join)(labgateDir, 'browser-url');
353
+ try {
354
+ (0, fs_1.unlinkSync)(urlFilePath);
355
+ }
356
+ catch { /* doesn't exist */ }
357
+ let handled = false;
358
+ // Watch for the URL file on the host side
359
+ const watcher = (0, fs_1.watch)(labgateDir, (_eventType, filename) => {
360
+ if (handled || filename !== 'browser-url')
361
+ return;
362
+ handled = true;
363
+ try {
364
+ const { readFileSync } = require('fs');
365
+ const url = readFileSync(urlFilePath, 'utf-8').trim();
366
+ if (!url)
367
+ return;
368
+ // Forward callback port from host → container VM (macOS podman only)
369
+ if ((0, os_1.platform)() === 'darwin') {
370
+ const portMatch = url.match(/localhost%3A(\d+)/);
371
+ if (portMatch) {
372
+ const port = parseInt(portMatch[1], 10);
373
+ if (port > 0 && port < 65536) {
374
+ try {
375
+ (0, child_process_2.execSync)(`podman machine ssh -- -f -N -L ${port}:localhost:${port}`, {
376
+ timeout: 5000,
377
+ stdio: 'ignore',
378
+ });
379
+ console.log(`[labgate] Forwarding callback port ${port} from host to container`);
380
+ }
381
+ catch { /* best effort — might not be podman, or already forwarded */ }
382
+ }
383
+ }
384
+ try {
385
+ (0, child_process_2.execSync)(`printf '%s' ${JSON.stringify(url)} | pbcopy`);
386
+ }
387
+ catch { /* best effort */ }
388
+ try {
389
+ (0, child_process_2.execSync)(`open ${JSON.stringify(url)}`);
390
+ }
391
+ catch { /* best effort */ }
392
+ console.log('\n[labgate] Login URL opened in browser and copied to clipboard.');
393
+ }
394
+ else {
395
+ // Linux with display
396
+ try {
397
+ (0, child_process_2.execSync)(`xdg-open ${JSON.stringify(url)}`);
398
+ }
399
+ catch {
400
+ console.log(`\n[labgate] Login URL:\n${url}`);
401
+ }
402
+ }
403
+ }
404
+ catch { /* best effort */ }
405
+ });
406
+ const cleanup = () => {
407
+ watcher.close();
408
+ try {
409
+ (0, fs_1.unlinkSync)(urlFilePath);
410
+ }
411
+ catch { /* best effort */ }
412
+ };
413
+ return {
414
+ env: ['--env', 'BROWSER=/home/sandbox/.labgate/browser-open.sh'],
415
+ cleanup,
416
+ };
417
+ }
418
+ // ── Pretty-print a command for dry-run ────────────────────
419
+ function prettyPrintCommand(runtime, args) {
420
+ const parts = [];
421
+ for (let i = 0; i < args.length; i++) {
422
+ const a = args[i];
423
+ if (a.startsWith('--') && !a.includes('=') && i + 1 < args.length && !args[i + 1].startsWith('--')) {
424
+ const val = args[i + 1];
425
+ const quotedVal = val.includes(' ') || val.includes('\n') ? `'${val}'` : val;
426
+ parts.push(`${a} ${quotedVal}`);
427
+ i++;
428
+ }
429
+ else {
430
+ parts.push(a.includes(' ') || a.includes('\n') ? `'${a}'` : a);
431
+ }
432
+ }
433
+ console.log(`${runtime} ${parts.join(' \\\n ')}`);
434
+ }
435
+ async function loadPty() {
436
+ try {
437
+ const mod = await import('node-pty');
438
+ return mod?.default ?? mod;
439
+ }
440
+ catch {
441
+ return null;
442
+ }
443
+ }
444
+ function renderStickyFooter(line) {
445
+ if (!process.stdout.isTTY)
446
+ return;
447
+ const cols = process.stdout.columns || 80;
448
+ const rows = process.stdout.rows || 24;
449
+ const trimmed = line.length > cols ? line.slice(0, Math.max(0, cols - 1)) : line;
450
+ const padded = trimmed.padEnd(cols, ' ');
451
+ process.stdout.write(`\x1b7\x1b[${rows};1H\x1b[2K${padded}\x1b8`);
452
+ }
453
+ // ── Agent credential extraction ───────────────────────────
454
+ /**
455
+ * On macOS, Claude Code stores OAuth tokens in the system keychain.
456
+ * We sync the full credential JSON into the sandbox home so Claude Code
457
+ * finds valid OAuth credentials at ~/.claude/.credentials.json inside
458
+ * the container. We also pass the access token as ANTHROPIC_API_KEY
459
+ * as a fallback.
460
+ */
461
+ function getAgentTokenEnv(agent) {
462
+ if ((0, os_1.platform)() !== 'darwin')
463
+ return [];
464
+ if (agent !== 'claude')
465
+ return [];
466
+ try {
467
+ const raw = (0, child_process_1.execFileSync)('security', [
468
+ 'find-generic-password',
469
+ '-s', 'Claude Code-credentials',
470
+ '-w',
471
+ ], { encoding: 'utf-8', timeout: 5000 }).trim();
472
+ const creds = JSON.parse(raw);
473
+ const token = creds.claudeAiOauth?.accessToken;
474
+ if (!token)
475
+ return [];
476
+ // Sync the full credential file into the sandbox home so
477
+ // Claude Code picks it up natively (no OAuth flow needed)
478
+ const sandboxHome = (0, config_js_1.getSandboxHome)();
479
+ const claudeDir = (0, path_1.join)(sandboxHome, '.claude');
480
+ (0, fs_1.mkdirSync)(claudeDir, { recursive: true });
481
+ (0, fs_1.writeFileSync)((0, path_1.join)(claudeDir, '.credentials.json'), raw, { mode: 0o600 });
482
+ console.log('[labgate] Synced Claude credentials from macOS keychain');
483
+ return ['--env', `ANTHROPIC_API_KEY=${token}`];
484
+ }
485
+ catch {
486
+ return [];
487
+ }
488
+ }
489
+ function formatStatusFooter(session, runtime, sessionId, image) {
490
+ const timeout = session.config.session_timeout_hours;
491
+ const timeoutLabel = Number.isFinite(timeout) && timeout > 0 ? `${timeout}h` : 'off';
492
+ const audit = session.config.audit.enabled ? 'on' : 'off';
493
+ const net = session.config.network.mode;
494
+ return `[labgate] Status: session=${sessionId} | runtime=${runtime} | net=${net} | timeout=${timeoutLabel} | audit=${audit} | image=${image}`;
495
+ }
496
+ // ── Session lifecycle ─────────────────────────────────────
497
+ async function startSession(session) {
498
+ const preferred = session.config.runtime;
499
+ const runtime = session.dryRun ? getDryRunRuntime(preferred) : (0, runtime_js_1.getRuntime)(preferred);
500
+ const image = session.imageOverride ?? session.config.image;
501
+ const sessionId = (0, crypto_1.randomBytes)(4).toString('hex');
502
+ const footerMode = session.footerMode ?? 'sticky';
503
+ const footerLine = formatStatusFooter(session, runtime, sessionId, image);
504
+ // Extract agent auth token from host keychain (macOS)
505
+ const tokenEnv = session.dryRun ? [] : getAgentTokenEnv(session.agent);
506
+ // Set up browser hook so OAuth URLs get opened on the host
507
+ const browserHook = session.dryRun ? undefined : setupBrowserHook();
508
+ if ((0, runtime_js_1.isApptainerFamily)(runtime) && session.config.network.mode !== 'host') {
509
+ console.warn(`[labgate] Warning: network mode "${session.config.network.mode}" is not enforced for ${runtime}. ` +
510
+ 'Configure network restrictions externally or use podman/docker.');
511
+ }
512
+ let args;
513
+ if ((0, runtime_js_1.isApptainerFamily)(runtime)) {
514
+ // Apptainer/Singularity path
515
+ let sifPath;
516
+ if (session.dryRun) {
517
+ sifPath = (0, path_1.join)((0, config_js_1.getImagesDir)(), imageToSifName(image));
518
+ }
519
+ else {
520
+ sifPath = ensureSifImage(runtime, image);
521
+ }
522
+ args = buildApptainerArgs(session, runtime, sifPath, sessionId, [...tokenEnv, ...(browserHook?.env ?? [])]);
523
+ }
524
+ else {
525
+ // Podman/Docker path
526
+ if (!session.dryRun) {
527
+ ensureOciImage(runtime, image);
528
+ }
529
+ args = buildPodmanArgs(session, runtime, sessionId, [...tokenEnv, ...(browserHook?.env ?? [])]);
530
+ }
531
+ if (session.dryRun) {
532
+ prettyPrintCommand(runtime, args);
533
+ return;
534
+ }
535
+ // Log session start
536
+ if (session.config.audit.enabled) {
537
+ const event = {
538
+ timestamp: new Date().toISOString(),
539
+ session: sessionId,
540
+ event: 'session_start',
541
+ agent: session.agent,
542
+ workdir: session.workdir,
543
+ network_mode: session.config.network.mode,
544
+ mounts: [
545
+ { path: session.workdir, target: '/work', mode: 'rw' },
546
+ ...session.config.filesystem.extra_paths.map(p => ({
547
+ path: p.path.replace(/^~/, (0, os_1.homedir)()),
548
+ target: `/mnt/${(0, path_1.basename)(p.path)}`,
549
+ mode: p.mode,
550
+ })),
551
+ ],
552
+ };
553
+ (0, audit_js_1.writeAuditEvent)(session.config, event);
554
+ }
555
+ // Print session info
556
+ const mode = session.config.network.mode;
557
+ console.log(`[labgate] Session ${sessionId}`);
558
+ console.log(`[labgate] Runtime: ${runtime}`);
559
+ console.log(`[labgate] Agent: ${session.agent}`);
560
+ console.log(`[labgate] Workdir: ${session.workdir} → /work`);
561
+ console.log(`[labgate] Network: ${mode}`);
562
+ console.log(`[labgate] Blocked: ${session.config.filesystem.blocked_patterns.length} patterns`);
563
+ if (session.config.audit.enabled) {
564
+ console.log(`[labgate] Audit log: ${(0, config_js_1.getLogDir)(session.config)}`);
565
+ }
566
+ if (footerMode === 'once') {
567
+ console.log(footerLine);
568
+ }
569
+ const wantsSticky = footerMode === 'sticky';
570
+ if (wantsSticky) {
571
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
572
+ console.warn('[labgate] Sticky footer needs a TTY; falling back to one-time footer.');
573
+ }
574
+ else {
575
+ const pty = await loadPty();
576
+ if (!pty) {
577
+ console.warn('[labgate] Sticky footer requires node-pty. Install it or use --no-footer.');
578
+ }
579
+ else {
580
+ let runtimePath;
581
+ try {
582
+ runtimePath = (0, child_process_1.execFileSync)('which', [runtime], { encoding: 'utf-8' }).trim();
583
+ }
584
+ catch {
585
+ runtimePath = runtime;
586
+ }
587
+ const cleanEnv = {};
588
+ for (const [k, v] of Object.entries(process.env)) {
589
+ if (v !== undefined)
590
+ cleanEnv[k] = v;
591
+ }
592
+ const cols = process.stdout.columns || 80;
593
+ const rows = process.stdout.rows || 24;
594
+ const child = pty.spawn(runtimePath, args, {
595
+ name: 'xterm-256color',
596
+ cols,
597
+ rows,
598
+ cwd: process.cwd(),
599
+ env: cleanEnv,
600
+ });
601
+ let exited = false;
602
+ const resizeHandler = () => {
603
+ child.resize(process.stdout.columns || 80, process.stdout.rows || 24);
604
+ renderStickyFooter(footerLine);
605
+ };
606
+ process.stdout.on('resize', resizeHandler);
607
+ renderStickyFooter(footerLine);
608
+ child.onData((data) => {
609
+ process.stdout.write(data);
610
+ renderStickyFooter(footerLine);
611
+ });
612
+ if (process.stdin.isTTY) {
613
+ process.stdin.setRawMode(true);
614
+ }
615
+ process.stdin.resume();
616
+ process.stdin.on('data', (data) => {
617
+ child.write(data.toString());
618
+ });
619
+ // Session timeout
620
+ let timeoutHandle;
621
+ const timeoutHours = session.config.session_timeout_hours;
622
+ if (Number.isFinite(timeoutHours) && timeoutHours > 0) {
623
+ const timeoutMs = timeoutHours * 60 * 60 * 1000;
624
+ timeoutHandle = setTimeout(() => {
625
+ if (exited)
626
+ return;
627
+ console.error(`\n[labgate] Session ${sessionId} reached timeout (${timeoutHours}h). Stopping...`);
628
+ if (session.config.audit.enabled) {
629
+ (0, audit_js_1.writeAuditEvent)(session.config, {
630
+ timestamp: new Date().toISOString(),
631
+ session: sessionId,
632
+ event: 'session_timeout',
633
+ timeout_hours: timeoutHours,
634
+ });
635
+ }
636
+ if (!(0, runtime_js_1.isApptainerFamily)(runtime)) {
637
+ try {
638
+ (0, child_process_1.execFileSync)(runtime, ['stop', `labgate-${sessionId}`], { stdio: 'ignore' });
639
+ }
640
+ catch { /* best effort */ }
641
+ }
642
+ child.kill('SIGTERM');
643
+ }, timeoutMs);
644
+ timeoutHandle.unref();
645
+ }
646
+ child.onExit((event) => {
647
+ exited = true;
648
+ if (timeoutHandle)
649
+ clearTimeout(timeoutHandle);
650
+ browserHook?.cleanup();
651
+ if (process.stdin.isTTY) {
652
+ process.stdin.setRawMode(false);
653
+ }
654
+ process.stdout.off('resize', resizeHandler);
655
+ if (session.config.audit.enabled) {
656
+ (0, audit_js_1.writeAuditEvent)(session.config, {
657
+ timestamp: new Date().toISOString(),
658
+ session: sessionId,
659
+ event: 'session_end',
660
+ exit_code: event.exitCode ?? 0,
661
+ });
662
+ }
663
+ process.exit(event.exitCode ?? 0);
664
+ });
665
+ for (const sig of ['SIGINT', 'SIGTERM']) {
666
+ process.on(sig, () => {
667
+ child.kill(sig);
668
+ });
669
+ }
670
+ return;
671
+ }
672
+ }
673
+ }
674
+ if (footerMode === 'sticky') {
675
+ console.log(footerLine);
676
+ }
677
+ console.log('');
678
+ // Spawn container (inherit stdio so it's fully interactive with TTY)
679
+ const child = (0, child_process_1.spawn)(runtime, args, {
680
+ stdio: 'inherit',
681
+ });
682
+ // Session timeout
683
+ let timeoutHandle;
684
+ const timeoutHours = session.config.session_timeout_hours;
685
+ if (Number.isFinite(timeoutHours) && timeoutHours > 0) {
686
+ const timeoutMs = timeoutHours * 60 * 60 * 1000;
687
+ timeoutHandle = setTimeout(() => {
688
+ if (child.exitCode !== null)
689
+ return;
690
+ console.error(`\n[labgate] Session ${sessionId} reached timeout (${timeoutHours}h). Stopping...`);
691
+ if (session.config.audit.enabled) {
692
+ (0, audit_js_1.writeAuditEvent)(session.config, {
693
+ timestamp: new Date().toISOString(),
694
+ session: sessionId,
695
+ event: 'session_timeout',
696
+ timeout_hours: timeoutHours,
697
+ });
698
+ }
699
+ if (!(0, runtime_js_1.isApptainerFamily)(runtime)) {
700
+ try {
701
+ (0, child_process_1.execFileSync)(runtime, ['stop', `labgate-${sessionId}`], { stdio: 'ignore' });
702
+ }
703
+ catch { /* best effort */ }
704
+ }
705
+ child.kill('SIGTERM');
706
+ }, timeoutMs);
707
+ timeoutHandle.unref();
708
+ }
709
+ // Handle exit
710
+ child.on('close', (code) => {
711
+ if (timeoutHandle)
712
+ clearTimeout(timeoutHandle);
713
+ browserHook?.cleanup();
714
+ if (session.config.audit.enabled) {
715
+ (0, audit_js_1.writeAuditEvent)(session.config, {
716
+ timestamp: new Date().toISOString(),
717
+ session: sessionId,
718
+ event: 'session_end',
719
+ exit_code: code ?? 0,
720
+ });
721
+ }
722
+ process.exit(code ?? 0);
723
+ });
724
+ // Forward signals
725
+ for (const sig of ['SIGINT', 'SIGTERM']) {
726
+ process.on(sig, () => {
727
+ child.kill(sig);
728
+ });
729
+ }
730
+ }
731
+ // ── List running sessions ─────────────────────────────────
732
+ async function listSessions() {
733
+ const config = (0, config_js_1.loadConfig)();
734
+ let runtime;
735
+ try {
736
+ runtime = (0, runtime_js_1.getRuntime)(config.runtime);
737
+ }
738
+ catch (err) {
739
+ console.error(err.message ?? String(err));
740
+ process.exit(1);
741
+ }
742
+ if ((0, runtime_js_1.isApptainerFamily)(runtime)) {
743
+ console.log('Apptainer sessions run as regular processes (not daemons).');
744
+ console.log('To find running labgate sessions:');
745
+ console.log(' ps aux | grep apptainer');
746
+ return;
747
+ }
748
+ try {
749
+ const output = (0, child_process_1.execFileSync)(runtime, [
750
+ 'ps',
751
+ '--filter', 'name=labgate-',
752
+ '--format', '{{.Names}}\t{{.Status}}\t{{.RunningFor}}',
753
+ ], { encoding: 'utf-8' }).trim();
754
+ if (!output) {
755
+ console.log('No active labgate sessions.');
756
+ return;
757
+ }
758
+ console.log('Active sessions:');
759
+ console.log('NAME\t\t\tSTATUS\t\tRUNNING');
760
+ console.log(output);
761
+ }
762
+ catch {
763
+ console.log('No active labgate sessions.');
764
+ }
765
+ }
766
+ // ── Stop a session ────────────────────────────────────────
767
+ async function stopSession(id) {
768
+ const config = (0, config_js_1.loadConfig)();
769
+ let runtime;
770
+ try {
771
+ runtime = (0, runtime_js_1.getRuntime)(config.runtime);
772
+ }
773
+ catch (err) {
774
+ console.error(err.message ?? String(err));
775
+ process.exit(1);
776
+ }
777
+ if ((0, runtime_js_1.isApptainerFamily)(runtime)) {
778
+ console.log('Apptainer sessions run as regular processes.');
779
+ console.log('To stop a session, find and kill the process:');
780
+ console.log(' ps aux | grep apptainer');
781
+ console.log(' kill <PID>');
782
+ return;
783
+ }
784
+ const name = id.startsWith('labgate-') ? id : `labgate-${id}`;
785
+ try {
786
+ (0, child_process_1.execFileSync)(runtime, ['stop', name], { stdio: 'inherit' });
787
+ console.log(`Stopped ${name}`);
788
+ }
789
+ catch {
790
+ console.error(`Could not stop ${name}. Is it running?`);
791
+ process.exit(1);
792
+ }
793
+ }
794
+ //# sourceMappingURL=container.js.map