labgate 0.5.28 → 0.5.30

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 CHANGED
@@ -1,23 +1,13 @@
1
1
  # LabGate
2
2
 
3
- Secure wrapper around LLM coding agents for HPC clusters.
4
-
5
- LabGate lets institutions adopt AI coding tools without giving agents unrestricted host access. It is designed for shared research environments where HPC admins need policy and audit controls, while researchers need a practical day-to-day workflow for coding, data analysis, and SLURM jobs.
6
-
7
- ## Product Goal
8
-
9
- - Give HPC admins a deployable control layer for agent sessions.
10
- - Make Claude-assisted work practical for researchers on real cluster infrastructure.
11
- - Keep the default path simple and reliable: `labgate claude` + Apptainer + SLURM.
3
+ Policy-controlled sandboxes for AI coding agents. Built for HPC clusters.
12
4
 
13
5
  ## Current Product Focus
14
6
 
15
7
  - Primary workflow: Claude (`labgate claude`)
16
8
  - Primary runtime: Apptainer on HPC
17
- - SLURM integration: enabled by default (`slurm.enabled = true`)
18
- - Secondary targets: other agents/runtimes are best-effort only
19
-
20
- LabGate still contains Podman runtime code for local/non-HPC scenarios, but that is not the primary supported path.
9
+ - macOS runtime: Podman (best-effort fallback path)
10
+ - Secondary targets (best-effort): other agents
21
11
 
22
12
  ## Install
23
13
 
@@ -25,107 +15,50 @@ LabGate still contains Podman runtime code for local/non-HPC scenarios, but that
25
15
  npm i -g labgate
26
16
  ```
27
17
 
28
- Note: LabGate uses `node-pty` only for the optional sticky footer. On minimal Linux installs, that dependency may fail to build without a compiler toolchain. If it fails, install still succeeds and LabGate falls back to non-sticky output.
29
-
30
- ## Quick Start (Researcher)
31
-
32
- ```bash
33
- labgate init
34
- labgate claude
35
- ```
36
-
37
- Typical HPC flow:
38
-
39
- 1. Login node: run `labgate ui`
40
- 2. Compute allocation: `srun --pty bash`, then `labgate claude` in your project directory
41
-
42
- Useful follow-ups for data-heavy work:
43
-
44
- ```bash
45
- labgate dataset list
46
- labgate slurm status
47
- labgate logs --follow
48
- ```
49
-
50
- Example (life science / data analysis workflow):
18
+ Note: LabGate uses `node-pty` only for the optional sticky footer. On minimal Linux installs, that dependency may fail to build without a compiler toolchain. If it fails, the install still works and LabGate falls back to non-sticky output.
51
19
 
52
- ```bash
53
- # 1) register datasets in ~/.labgate/config.json (via UI or config edit)
54
- # 2) initialize dataset stats for discoverability
55
- labgate dataset init rnaseq-cohort
56
-
57
- # 3) start agent and run analysis in project directory
58
- labgate claude
20
+ Note: `tmux` is a host-level dependency for `labgate ui` / web terminals. `npm i -g labgate` does not install `tmux`; install it through your OS or cluster module system.
59
21
 
60
- # 4) submit SLURM job with host-valid output paths (relative preferred)
61
- sbatch --output slurm-%j.out --error slurm-%j.err run_qc.sh
22
+ LabGate prefers Apptainer for sandbox runtime and supports Podman as a fallback (especially on macOS).
62
23
 
63
- # 5) inspect tracked jobs and output
64
- labgate slurm status
65
- labgate slurm output <job-id> --tail 100
66
- ```
24
+ On UI startup, LabGate ensures a bundled sample dataset (`flowers-iris`) is available at `~/.labgate/datasets/flowers-iris` for first-run testing.
67
25
 
68
- ## Quick Start (HPC Admin)
26
+ ## Quick start
69
27
 
70
28
  ```bash
71
- # Install license (enterprise mode)
72
- labgate license install <key-or-file> --system
73
-
74
- # Create baseline policy
75
- labgate policy init --path /etc/labgate/policy.json --admin <hpc-admin-username>
76
- labgate policy validate
77
-
78
- # Validate default runtime behavior
79
- labgate config get runtime
29
+ labgate init # optional: pre-create ~/.labgate/config.json
30
+ labgate claude # launch Claude Code in current dir
31
+ labgate codex /projects/my-analysis # launch Codex in a specific dir
80
32
  ```
81
33
 
82
- Admin controls can force/lock runtime, network mode, audit settings, and SLURM behavior through policy.
83
-
84
- ## Why HPC Admins Deploy It
85
-
86
- - Scoped filesystem mounts instead of full host exposure
87
- - Default blocking of common credential and key material paths (`.ssh`, `.aws`, `.env`, `.gnupg`, key files)
88
- - Network policy modes (`host`, `filtered`, `none`)
89
- - Command blacklist inside sandbox (`ssh`, `curl`, `wget`, etc.)
90
- - Session/audit logging for operational traceability
91
- - Enterprise policy and lock semantics for institution-level governance
92
- - SLURM-aware behavior designed for shared cluster operations
34
+ ## What it does
93
35
 
94
- ## Why Researchers Keep Using It (Life Science / Data Analysis)
36
+ LabGate runs your AI coding agent inside a sandboxed container with:
95
37
 
96
- - Works in existing project folders and scheduler workflows
97
- - Named dataset mounts under `/datasets/<name>` reduce path confusion in collaborative analysis
98
- - Auto-injected session context gives the agent correct path + cluster constraints
99
- - SLURM tracking + MCP tools help inspect jobs and output without leaving the coding workflow
100
- - Results registry MCP lets teams record findings, artifacts, and summaries across sessions
101
-
102
- ## What LabGate Enforces
103
-
104
- LabGate runs AI coding agents in a sandboxed container with:
105
-
106
- - **Scoped filesystem**: only workdir + configured mounts are visible
107
- - **Credential blocking**: sensitive paths hidden by default
108
- - **Network policy**: configurable network mode
109
- - **Command blocking**: risky commands blocked by default
110
- - **Audit logging**: session lifecycle + key security events in `~/.labgate/logs/`
111
- - **Instruction management**: temporary LabGate context blocks in `CLAUDE.md` / `AGENTS.md`
112
- - **HPC integration**: Apptainer-first runtime behavior and SLURM support
38
+ - **Scoped filesystem** only your working directory and configured paths are visible
39
+ - **Credential blocking** `.ssh`, `.aws`, `.env`, `.gnupg`, and other sensitive paths are hidden by default
40
+ - **Network policy** configurable network modes (`host`, `filtered`, `none`)
41
+ - **Command blocking** `ssh`, `curl`, `wget`, and other commands are blocked by default
42
+ - **Audit logging** session start/stop and mount configuration logged to `~/.labgate/logs/`
43
+ - **Dashboard instructions editor** — view and update per-session `AGENTS.md` / `CLAUDE.md` from the UI
44
+ - **Session context injection** — LabGate prepends a temporary sandbox-mapping instruction block during active sessions
45
+ - **HPC ready** — first-class Apptainer support for shared clusters
113
46
 
114
47
  ## Configuration
115
48
 
116
- Edit config:
49
+ Edit `~/.labgate/config.json` to customize:
117
50
 
118
51
  ```bash
119
52
  $EDITOR ~/.labgate/config.json
120
53
  ```
121
54
 
122
- Reset full config:
55
+ Or start fresh:
123
56
 
124
57
  ```bash
125
58
  labgate init --force
126
59
  ```
127
60
 
128
- Reset a single setting to defaults:
61
+ Or reset a single setting back to defaults:
129
62
 
130
63
  ```bash
131
64
  labgate config reset image
@@ -135,101 +68,73 @@ labgate config reset image
135
68
 
136
69
  | Setting | Default | What it does |
137
70
  |---------|---------|-------------|
138
- | `runtime` | `auto` | Runtime preference (`auto`, `apptainer`, `podman`) |
71
+ | `runtime` | `auto` | `auto`, `apptainer`, or `podman` |
139
72
  | `image` | `docker.io/library/node:20-bookworm` | Container image |
140
73
  | `session_timeout_hours` | `8` | Max session length |
141
74
  | `filesystem.blocked_patterns` | `.ssh, .aws, .env, ...` | Hidden from sandbox |
142
75
  | `filesystem.extra_paths` | `[]` | Additional mounts |
143
- | `datasets` | `[]` | Named dataset mounts under `/datasets/*` |
144
76
  | `network.mode` | `host` | `none`, `filtered`, or `host` |
145
77
  | `commands.blacklist` | `ssh, curl, wget, ...` | Blocked commands |
146
- | `slurm.enabled` | `true` | Enable SLURM tracking + passthrough |
147
- | `slurm.mcp_server` | `true` | Enable SLURM MCP server integration |
148
- | `audit.enabled` | `true` | Enable audit logging |
78
+ | `slurm.enabled` | `true` | Enable SLURM CLI passthrough (`sbatch`, `squeue`, etc.) and job tracking |
149
79
 
150
80
  ## Commands
151
81
 
152
82
  ```bash
153
- # Agent sessions
154
- labgate claude [workdir]
155
- labgate codex [workdir] # secondary/best-effort path
156
-
157
- # Session lifecycle
158
- labgate status
159
- labgate stop <id>
160
- labgate restart <id>
161
- labgate continue [web-terminal-id] [--latest]
162
-
163
- # UI + logs
164
- labgate ui
165
- labgate logs [-n 20]
166
- labgate logs --follow
167
- labgate doctor
168
-
169
- # Config + setup
170
- labgate init [--force]
171
- labgate config get <key>
172
- labgate config set <key> <value>
173
- labgate config reset <key>
174
-
175
- # Dataset workflow
176
- labgate dataset list
177
- labgate dataset init <name>
178
-
179
- # SLURM workflow
180
- labgate slurm status
181
- labgate slurm job <id>
182
- labgate slurm output <id> [--stderr] [--tail <lines>]
183
- labgate slurm cancel <id>
184
- labgate slurm mcp
185
-
186
- # Enterprise
187
- labgate license
188
- labgate license install <key-or-file> [--system|--user|--path]
189
- labgate register <activation-key> [--server <url>]
190
- labgate policy init [--institution ... --admin ...]
191
- labgate policy validate [file]
83
+ labgate claude [workdir] # launch Claude Code
84
+ labgate codex [workdir] # launch Codex
85
+ labgate feedback # submit feedback (interactive or piped)
86
+ labgate status # list running sessions
87
+ labgate stop <id> # stop a session
88
+ labgate ui # start dashboard server on localhost:7700 (auth token required, tmux required)
89
+ labgate register <activation-key> [--server <url>] # activate + install enterprise license
90
+ labgate license # show enterprise license status
91
+ labgate license install <key-or-file> [--system|--user|--path] # install enterprise license key
92
+ labgate policy init [--institution ... --admin ...] # create policy template
93
+ labgate policy validate [file] # validate policy JSON
94
+ labgate logs [-n 20] # view recent audit events
95
+ labgate logs --follow # stream new audit events
96
+ labgate init [--force] # create/reset config
192
97
  ```
193
98
 
194
- ### Common options
99
+ ### Options
195
100
 
196
101
  ```bash
197
- labgate claude --dry-run
198
- labgate claude --image my-image:tag
199
- labgate claude --no-footer
200
- labgate claude --api-key "$ANTHROPIC_API_KEY"
201
- labgate ui --socket ~/.labgate/ui.sock
202
- labgate logs --lines 50 --follow
102
+ labgate claude --dry-run # print the sandbox command without running
103
+ labgate claude --image my-image:tag # use a different container image
104
+ labgate claude --no-footer # disable the status footer line
105
+ labgate ui # localhost UI on 7700, logs full token URL + short /s/<code> quick link
106
+ labgate ui --socket ~/.labgate/ui.sock # custom Unix socket path
107
+ labgate logs --lines 50 --follow # tail last 50 lines and keep following
203
108
  ```
204
109
 
205
110
  `labgate claude` auto-starts `labgate ui` when missing in local (non-SSH/non-SLURM) shells.
206
111
 
207
112
  ### SLURM inside sandboxes (`sbatch` / `squeue`)
208
113
 
209
- For Apptainer sessions, LabGate attempts SLURM CLI passthrough automatically.
210
- If host `sbatch`/`squeue` are available, they are staged into the sandbox so
211
- `labgate claude` works in common HPC setups without extra config.
114
+ For Apptainer sessions, LabGate now attempts SLURM CLI passthrough automatically.
115
+ If host `sbatch`/`squeue` are available, they are staged into the sandbox, so
116
+ `labgate claude` should work without extra config in the common HPC path.
212
117
 
213
- SLURM tracking and MCP tools are enabled by default (`slurm.enabled = true`).
118
+ SLURM tracking and MCP tools are enabled by default (`slurm.enabled=true`).
214
119
  If native SQLite (`better-sqlite3`) is unavailable on a host, LabGate falls back
215
120
  to a JSON tracking store automatically.
216
121
 
217
122
  Requirements for automatic `sbatch` in sandbox:
218
123
 
219
124
  1. Runtime is Apptainer
220
- 2. Host shell can resolve SLURM CLI tools before launching LabGate
125
+ 2. The host can resolve SLURM CLI tools when launching LabGate
221
126
 
222
- If `sbatch` is missing inside the sandbox:
127
+ If `sbatch` is missing inside the sandbox, run:
223
128
 
224
129
  ```bash
225
- which sbatch
130
+ which sbatch # on host, before launching labgate
226
131
  labgate claude
227
132
  ```
228
133
 
229
- If your cluster uses environment modules, load SLURM first:
134
+ If your cluster uses environment modules, load SLURM first (host shell), then launch LabGate:
230
135
 
231
136
  ```bash
232
- module load slurm
137
+ module load slurm # or your site-specific module name
233
138
  labgate claude
234
139
  ```
235
140
 
@@ -343,20 +248,20 @@ Coverage:
343
248
  3. Verifies host browser-open hook is triggered
344
249
  4. Optional override: `LABGATE_REAL_E2E_IMAGE`
345
250
 
346
- ## How It Works
251
+ ## How it works
347
252
 
348
253
  LabGate builds a sandboxed container from your config:
349
254
 
350
- 1. Detects Apptainer first (primary HPC path), with secondary fallback runtimes when configured
255
+ 1. Detects Apptainer first, then Podman (or uses explicit runtime)
351
256
  2. Mounts your working directory at `/work`
352
257
  3. Mounts persistent sandbox HOME at `/home/sandbox` (for npm cache, agent config)
353
258
  4. Overlays blocked paths (`.ssh`, `.aws`, etc.) with empty mounts
354
- 5. Applies network isolation and command controls
259
+ 5. Applies network isolation and capability restrictions
355
260
  6. Installs the agent (if not cached) and runs it interactively
356
261
 
357
- On macOS, LabGate can sync Claude credentials from the system keychain so the agent can authenticate automatically.
262
+ On macOS, LabGate syncs your Claude credentials from the system keychain so the agent can authenticate automatically.
358
263
 
359
- ## Audit Logs
264
+ ## Audit logs
360
265
 
361
266
  Session events are logged to `~/.labgate/logs/YYYY-MM-DD.jsonl`:
362
267
 
@@ -364,6 +269,14 @@ Session events are logged to `~/.labgate/logs/YYYY-MM-DD.jsonl`:
364
269
  cat ~/.labgate/logs/2025-02-05.jsonl | jq .
365
270
  ```
366
271
 
272
+ ## Roadmap
273
+
274
+ - **M0** CLI + sandbox engine + config + audit (this release)
275
+ - **M1** Mount allowlists, network filtering, project-level config
276
+ - **M2** SLURM proxy (submit/status/cancel from inside sandbox)
277
+ - **M3** Web UI for config + audit viewer
278
+ - **M4** Institutional mode (/etc/labgate/ policies, admin locks)
279
+
367
280
  ## License
368
281
 
369
282
  MIT
package/dist/cli.js CHANGED
@@ -39,7 +39,6 @@ const fs_1 = require("fs");
39
39
  const os_1 = require("os");
40
40
  const net_1 = require("net");
41
41
  const readline_1 = require("readline");
42
- const child_process_1 = require("child_process");
43
42
  const config_js_1 = require("./lib/config.js");
44
43
  const init_js_1 = require("./lib/init.js");
45
44
  const container_js_1 = require("./lib/container.js");
@@ -340,49 +339,49 @@ program
340
339
  });
341
340
  }
342
341
  });
343
- // ── labgate doctor ───────────────────────────────────────
344
- program
345
- .command('doctor')
346
- .description('Run preflight checks for LabGate HPC usage')
347
- .option('--json', 'Print full report as JSON')
348
- .action(async (opts) => {
349
- const { runDoctor, renderDoctorReport } = await import('./lib/doctor.js');
350
- const report = runDoctor();
351
- if (opts.json) {
352
- console.log(JSON.stringify(report, null, 2));
353
- }
354
- else {
355
- console.log(renderDoctorReport(report));
356
- }
357
- if (!report.success) {
358
- process.exit(1);
359
- }
360
- });
361
342
  // ── labgate ui ───────────────────────────────────────────
362
343
  program
363
344
  .command('ui')
364
345
  .description('Open settings dashboard in browser')
365
346
  .option('--port <number>', 'Listen on TCP port (default: 7700; requires auth token)', '')
347
+ .option('--listen-address <address>', 'Listen on specific IP address (default: 127.0.0.1)', '')
366
348
  .option('--socket <path>', 'Unix socket path (owner-only)')
367
349
  .action(async (opts) => {
368
350
  const portInput = typeof opts.port === 'string' ? opts.port.trim() : '';
351
+ const listenAddressInput = typeof opts.listenAddress === 'string' ? opts.listenAddress.trim() : '';
369
352
  const socketInput = typeof opts.socket === 'string' ? opts.socket.trim() : '';
370
353
  const hasPort = portInput.length > 0;
354
+ const hasListenAddress = listenAddressInput.length > 0;
371
355
  const hasSocket = socketInput.length > 0;
372
356
  if (hasPort && hasSocket) {
373
357
  console.error('Error: use either --port or --socket, not both.');
374
358
  process.exit(1);
375
359
  }
360
+ if (hasListenAddress && hasSocket) {
361
+ console.error('Error: --listen-address can only be used with --port, not --socket.');
362
+ process.exit(1);
363
+ }
376
364
  const effectivePortInput = hasPort ? portInput : (hasSocket ? '' : String(DEFAULT_UI_PORT));
377
365
  const parsedPort = effectivePortInput ? parseInt(effectivePortInput, 10) : NaN;
378
366
  if (effectivePortInput && (!Number.isFinite(parsedPort) || parsedPort <= 0 || parsedPort > 65535)) {
379
367
  console.error('Error: --port must be an integer between 1 and 65535.');
380
368
  process.exit(1);
381
369
  }
370
+ const cfgPath = (0, config_js_1.getConfigPath)();
371
+ if (!(0, fs_1.existsSync)(cfgPath)) {
372
+ await (0, init_js_1.initConfig)({ force: false, quiet: true });
373
+ }
374
+ const { ensureTmuxAvailable } = await import('./lib/web-terminal.js');
375
+ const tmuxAvailable = await ensureTmuxAvailable();
376
+ if (!tmuxAvailable.ok) {
377
+ console.error(tmuxAvailable.error);
378
+ process.exit(1);
379
+ }
382
380
  const { startUI } = await import('./lib/ui.js');
383
381
  const server = startUI({
384
382
  port: Number.isFinite(parsedPort) ? parsedPort : undefined,
385
383
  socketPath: hasSocket ? socketInput : undefined,
384
+ listenAddress: hasListenAddress ? listenAddressInput : undefined,
386
385
  standalone: true,
387
386
  });
388
387
  // In standalone mode, keep the process alive
@@ -415,129 +414,6 @@ program
415
414
  const { restartSession } = await import('./lib/container.js');
416
415
  await restartSession(id, { dryRun: opts.dryRun ?? false });
417
416
  });
418
- // ── labgate continue <id> ─────────────────────────────────
419
- program
420
- .command('continue')
421
- .description('Attach to a tmux-backed web terminal session')
422
- .argument('[id]', 'Web terminal session ID/prefix (e.g. wt-abc123...)')
423
- .option('--latest', 'Attach to the newest runnable local web-terminal session')
424
- .action(async (id, opts) => {
425
- const web = await import('./lib/web-terminal.js');
426
- if (opts.latest && id && id.trim()) {
427
- console.error('Use either an ID/prefix or --latest, not both.');
428
- process.exit(1);
429
- }
430
- const localHost = (0, os_1.hostname)();
431
- const all = web.listWebTerminalRecords();
432
- const ensureTmux = async () => {
433
- const tmux = await web.ensureTmuxAvailable();
434
- if (!tmux.ok) {
435
- console.error(`Error: ${tmux.error}`);
436
- process.exit(1);
437
- }
438
- };
439
- const pickLatestRunnableLocal = async () => {
440
- await ensureTmux();
441
- for (const item of all) {
442
- if (item.node !== localHost)
443
- continue;
444
- if (await web.hasTmuxSession(item.tmuxSession))
445
- return item;
446
- }
447
- return null;
448
- };
449
- const pickInteractive = async () => {
450
- const candidates = all.slice(0, 20);
451
- if (candidates.length === 0)
452
- return null;
453
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
454
- console.error('No session id provided in non-interactive mode. Use `labgate continue <id>` or `--latest`.');
455
- process.exit(1);
456
- }
457
- await ensureTmux();
458
- console.error('Select a web terminal session to continue:');
459
- for (let i = 0; i < candidates.length; i++) {
460
- const item = candidates[i];
461
- const alive = item.node === localHost ? await web.hasTmuxSession(item.tmuxSession) : false;
462
- const availability = item.node === localHost ? (alive ? 'attachable' : 'not running') : `remote:${item.node}`;
463
- console.error(` ${i + 1}. ${item.id} ${item.agent} ${item.status} ${availability} ${item.workdir}`);
464
- }
465
- const rl = (0, readline_1.createInterface)({ input: process.stdin, output: process.stderr });
466
- const answer = await new Promise((resolve) => {
467
- rl.question('Enter number (or q to cancel): ', (value) => {
468
- rl.close();
469
- resolve((value || '').trim());
470
- });
471
- });
472
- if (!answer || answer.toLowerCase() === 'q') {
473
- console.error('Cancelled.');
474
- process.exit(1);
475
- }
476
- const idx = parseInt(answer, 10);
477
- if (!Number.isFinite(idx) || idx < 1 || idx > candidates.length) {
478
- console.error(`Invalid selection: ${answer}`);
479
- process.exit(1);
480
- }
481
- return candidates[idx - 1];
482
- };
483
- let record = null;
484
- if (opts.latest) {
485
- record = await pickLatestRunnableLocal();
486
- if (!record) {
487
- console.error('No runnable local web terminal session found.');
488
- process.exit(1);
489
- }
490
- }
491
- else if (id && id.trim()) {
492
- const resolved = web.resolveWebTerminalRecord(id);
493
- if (!resolved.record) {
494
- if (resolved.matches.length > 1) {
495
- console.error(`Ambiguous session prefix "${id}". Matches:`);
496
- for (const item of resolved.matches.slice(0, 20)) {
497
- console.error(` - ${item.id} (${item.agent}, ${item.workdir})`);
498
- }
499
- process.exit(1);
500
- }
501
- console.error(`Session not found: ${id}`);
502
- process.exit(1);
503
- }
504
- record = resolved.record;
505
- }
506
- else {
507
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
508
- console.error('No session id provided in non-interactive mode. Use `labgate continue <id>` or `--latest`.');
509
- process.exit(1);
510
- }
511
- record = await pickInteractive();
512
- if (!record) {
513
- console.error('No web terminal sessions found.');
514
- process.exit(1);
515
- }
516
- }
517
- if (record.node !== localHost) {
518
- console.error(`Session "${record.id}" is running on node "${record.node}", not "${localHost}".`);
519
- console.error(`Attach there: ssh ${record.node} "labgate continue ${record.id}"`);
520
- process.exit(1);
521
- }
522
- await ensureTmux();
523
- const alive = await web.hasTmuxSession(record.tmuxSession);
524
- if (!alive) {
525
- console.error(`Session "${record.id}" is not running anymore (tmux session missing).`);
526
- process.exit(1);
527
- }
528
- let tmuxBin = 'tmux';
529
- try {
530
- tmuxBin = await web.getTmuxBinary();
531
- }
532
- catch (err) {
533
- console.error(`Error resolving tmux binary: ${err?.message ?? String(err)}`);
534
- process.exit(1);
535
- }
536
- const child = (0, child_process_1.spawn)(tmuxBin, ['attach-session', '-t', record.tmuxSession], { stdio: 'inherit' });
537
- child.on('exit', (code) => {
538
- process.exit(code ?? 0);
539
- });
540
- });
541
417
  // ── labgate slurm ────────────────────────────────────────
542
418
  const slurmCmd = program
543
419
  .command('slurm')