labgate 0.5.10 → 0.5.12

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.
Files changed (40) hide show
  1. package/README.md +59 -4
  2. package/dist/cli.js +425 -27
  3. package/dist/cli.js.map +1 -1
  4. package/dist/lib/cluster-mcp.d.ts +33 -0
  5. package/dist/lib/cluster-mcp.js +313 -0
  6. package/dist/lib/cluster-mcp.js.map +1 -0
  7. package/dist/lib/config.d.ts +1 -0
  8. package/dist/lib/config.js +11 -4
  9. package/dist/lib/config.js.map +1 -1
  10. package/dist/lib/container.d.ts +10 -0
  11. package/dist/lib/container.js +195 -32
  12. package/dist/lib/container.js.map +1 -1
  13. package/dist/lib/dataset-mcp.d.ts +20 -0
  14. package/dist/lib/dataset-mcp.js +809 -0
  15. package/dist/lib/dataset-mcp.js.map +1 -0
  16. package/dist/lib/feedback.js +15 -3
  17. package/dist/lib/feedback.js.map +1 -1
  18. package/dist/lib/init.js +2 -2
  19. package/dist/lib/results-mcp.d.ts +9 -0
  20. package/dist/lib/results-mcp.js +205 -0
  21. package/dist/lib/results-mcp.js.map +1 -0
  22. package/dist/lib/results-store.d.ts +61 -0
  23. package/dist/lib/results-store.js +319 -0
  24. package/dist/lib/results-store.js.map +1 -0
  25. package/dist/lib/slurm-cli-passthrough.d.ts +25 -0
  26. package/dist/lib/slurm-cli-passthrough.js +330 -0
  27. package/dist/lib/slurm-cli-passthrough.js.map +1 -0
  28. package/dist/lib/slurm-mcp.js +1 -1
  29. package/dist/lib/slurm-mcp.js.map +1 -1
  30. package/dist/lib/test/integration-harness.d.ts +4 -0
  31. package/dist/lib/test/integration-harness.js +14 -2
  32. package/dist/lib/test/integration-harness.js.map +1 -1
  33. package/dist/lib/ui.html +2068 -351
  34. package/dist/lib/ui.js +701 -0
  35. package/dist/lib/ui.js.map +1 -1
  36. package/dist/mcp-bundles/cluster-mcp.bundle.mjs +30235 -0
  37. package/dist/mcp-bundles/dataset-mcp.bundle.mjs +30971 -0
  38. package/dist/mcp-bundles/results-mcp.bundle.mjs +30449 -0
  39. package/dist/mcp-bundles/slurm-mcp.bundle.mjs +30501 -0
  40. package/package.json +4 -2
package/README.md CHANGED
@@ -33,7 +33,7 @@ LabGate runs your AI coding agent inside a sandboxed container with:
33
33
 
34
34
  - **Scoped filesystem** — only your working directory and configured paths are visible
35
35
  - **Credential blocking** — `.ssh`, `.aws`, `.env`, `.gnupg`, and other sensitive paths are hidden by default
36
- - **Network isolation** — no outbound network by default (configurable)
36
+ - **Network policy** — configurable network modes (`host`, `filtered`, `none`)
37
37
  - **Command blocking** — `ssh`, `curl`, `wget`, and other commands are blocked by default
38
38
  - **Audit logging** — session start/stop and mount configuration logged to `~/.labgate/logs/`
39
39
  - **Dashboard instructions editor** — view and update per-session `AGENTS.md` / `CLAUDE.md` from the UI
@@ -54,16 +54,22 @@ Or start fresh:
54
54
  labgate init --force
55
55
  ```
56
56
 
57
+ Or reset a single setting back to defaults:
58
+
59
+ ```bash
60
+ labgate config reset image
61
+ ```
62
+
57
63
  ### Key settings
58
64
 
59
65
  | Setting | Default | What it does |
60
66
  |---------|---------|-------------|
61
67
  | `runtime` | `auto` | `auto`, `apptainer`, or `podman` |
62
- | `image` | `node:20-slim` | Container image |
68
+ | `image` | `docker.io/library/node:20-bookworm` | Container image |
63
69
  | `session_timeout_hours` | `8` | Max session length |
64
70
  | `filesystem.blocked_patterns` | `.ssh, .aws, .env, ...` | Hidden from sandbox |
65
71
  | `filesystem.extra_paths` | `[]` | Additional mounts |
66
- | `network.mode` | `none` | `none`, `filtered`, or `host` |
72
+ | `network.mode` | `host` | `none`, `filtered`, or `host` |
67
73
  | `commands.blacklist` | `ssh, curl, wget, ...` | Blocked commands |
68
74
 
69
75
  ## Commands
@@ -75,6 +81,11 @@ labgate feedback # submit feedback (interactive or piped)
75
81
  labgate status # list running sessions
76
82
  labgate stop <id> # stop a session
77
83
  labgate ui # start dashboard server (owner-only Unix socket by default)
84
+ labgate register <activation-key> [--server <url>] # activate + install enterprise license
85
+ labgate license # show enterprise license status
86
+ labgate license install <key-or-file> [--system|--user|--path] # install enterprise license key
87
+ labgate policy init [--institution ... --admin ...] # create policy template
88
+ labgate policy validate [file] # validate policy JSON
78
89
  labgate logs [-n 20] # view recent audit events
79
90
  labgate logs --follow # stream new audit events
80
91
  labgate init [--force] # create/reset config
@@ -103,7 +114,8 @@ labgate feedback "Short feedback message"
103
114
 
104
115
  If `LABGATE_FEEDBACK_URL` is set, LabGate will `POST` feedback JSON to that URL.
105
116
  If `LABGATE_FEEDBACK_TOKEN` is set, it will be sent as a Bearer token.
106
- If no URL is configured or the request fails, feedback is saved locally at `~/.labgate/feedback.jsonl`.
117
+ If no URL is configured, LabGate defaults to `https://labgate.dev/api/feedback`.
118
+ If the request fails (or `LABGATE_FEEDBACK_DISABLE=1`), feedback is saved locally at `~/.labgate/feedback.jsonl`.
107
119
 
108
120
  ## Testing
109
121
 
@@ -121,6 +133,49 @@ npm test # unit + integration
121
133
  npm run release:check # verify + npm pack --dry-run
122
134
  ```
123
135
 
136
+ ### Corporate mode smoke test (local or HPC)
137
+
138
+ ```bash
139
+ # 1) Create a signed enterprise key (issuer side)
140
+ export LABGATE_LICENSE_SECRET='replace-with-your-signing-secret'
141
+ LICENSE_KEY="$(npx tsx scripts/generate-license.ts \
142
+ --institution 'Example University' \
143
+ --tier pro \
144
+ --expires 2099-12-31 2>/dev/null)"
145
+
146
+ # 2) Install key on target host (admin side)
147
+ labgate license install "$LICENSE_KEY" --path /tmp/labgate/license.key --overwrite
148
+ # HPC system-wide install (root):
149
+ # sudo labgate license install "$LICENSE_KEY" --system --overwrite
150
+
151
+ # Optional online activation (instead of direct install):
152
+ # Uses default endpoint: https://labgate.dev/api/license/activate
153
+ # labgate register '<activation-key-from-vendor>' --path /tmp/labgate/license.key --overwrite
154
+ # Optional custom endpoint:
155
+ # export LABGATE_ACTIVATION_URL='https://your-control-plane.example.com/api/license/activate'
156
+
157
+ # 3) Bootstrap and validate policy
158
+ labgate policy init --path /tmp/labgate/policy.json --admin "$(whoami)" --force
159
+ labgate policy validate /tmp/labgate/policy.json
160
+
161
+ # 4) Verify forced settings are locked for users
162
+ LABGATE_LICENSE_PATH=/tmp/labgate/license.key \
163
+ LABGATE_POLICY_PATH=/tmp/labgate/policy.json \
164
+ labgate config set runtime auto
165
+ # expected: error about admin-locked field
166
+
167
+ # 5) Open dashboard and verify UI lock labels ("set by admin")
168
+ LABGATE_LICENSE_PATH=/tmp/labgate/license.key \
169
+ LABGATE_POLICY_PATH=/tmp/labgate/policy.json \
170
+ labgate ui --port 7700
171
+ ```
172
+
173
+ Automated enterprise coverage:
174
+
175
+ ```bash
176
+ npx vitest run -c vitest.integration.config.ts src/lib/cli.enterprise.integration.test.ts src/lib/ui.integration.test.ts
177
+ ```
178
+
124
179
  CI automation runs `npm run verify` on every push to `main` and on pull requests (`.github/workflows/ci.yml`).
125
180
  `npm run test:integration` automatically rebuilds `better-sqlite3` first to avoid Node ABI mismatch errors after Node upgrades.
126
181
 
package/dist/cli.js CHANGED
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  const commander_1 = require("commander");
37
37
  const path_1 = require("path");
38
38
  const fs_1 = require("fs");
39
+ const os_1 = require("os");
39
40
  const config_js_1 = require("./lib/config.js");
40
41
  const init_js_1 = require("./lib/init.js");
41
42
  const container_js_1 = require("./lib/container.js");
@@ -43,6 +44,7 @@ const runtime_js_1 = require("./lib/runtime.js");
43
44
  const feedback_js_1 = require("./lib/feedback.js");
44
45
  const log = __importStar(require("./lib/log.js"));
45
46
  const AGENTS = ['claude', 'codex'];
47
+ const DEFAULT_ACTIVATION_URL = 'https://labgate.dev/api/license/activate';
46
48
  // Read version from package.json so it stays in sync
47
49
  const pkgPath = (0, path_1.resolve)(__dirname, '..', 'package.json');
48
50
  const PKG_VERSION = JSON.parse((0, fs_1.readFileSync)(pkgPath, 'utf-8')).version;
@@ -181,6 +183,39 @@ configCmd
181
183
  process.exit(1);
182
184
  }
183
185
  });
186
+ configCmd
187
+ .command('reset')
188
+ .description('Reset (unset) a config value so defaults apply')
189
+ .argument('<key>', 'Config key (e.g. image, network.mode, slurm.enabled)')
190
+ .action((key) => {
191
+ const defaultValue = getNestedValue(config_js_1.DEFAULT_CONFIG, key);
192
+ if (defaultValue === undefined) {
193
+ console.error(`Unknown config key: ${key}`);
194
+ process.exit(1);
195
+ }
196
+ const configPath = (0, config_js_1.getConfigPath)();
197
+ if (!(0, fs_1.existsSync)(configPath)) {
198
+ console.error('No config file found. Run "labgate init" first.');
199
+ process.exit(1);
200
+ }
201
+ try {
202
+ const rawText = (0, fs_1.readFileSync)(configPath, 'utf-8');
203
+ const stripped = stripJsonComments(rawText).trim();
204
+ const obj = stripped ? JSON.parse(stripped) : {};
205
+ const deleted = deleteNestedValue(obj, key);
206
+ if (!deleted) {
207
+ console.log(`No user override for ${key} (already using defaults / policy).`);
208
+ return;
209
+ }
210
+ (0, fs_1.writeFileSync)(configPath, JSON.stringify(obj, null, 2) + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
211
+ (0, config_js_1.ensurePrivateFile)(configPath);
212
+ console.log(`Reset ${key} (unset from user config).`);
213
+ }
214
+ catch (err) {
215
+ console.error(`Error: ${err.message}`);
216
+ process.exit(1);
217
+ }
218
+ });
184
219
  configCmd
185
220
  .command('show')
186
221
  .description('Show the full resolved config')
@@ -528,39 +563,178 @@ slurmCmd
528
563
  await main();
529
564
  });
530
565
  // ── labgate license ──────────────────────────────────────
531
- program
566
+ const licenseCmd = program
532
567
  .command('license')
533
- .description('Show enterprise license status')
568
+ .description('Manage enterprise license');
569
+ licenseCmd
534
570
  .action(async () => {
535
- const { validateLicense } = await import('./lib/license.js');
536
- const status = validateLicense();
537
- if (!status.filePath && !status.error) {
538
- console.log('No enterprise license found.');
539
- console.log('License file locations (checked in order):');
540
- console.log(' 1. $LABGATE_LICENSE_PATH (env var)');
541
- console.log(' 2. /etc/labgate/license.key');
542
- console.log(' 3. ~/.labgate/license.key');
543
- return;
571
+ await printLicenseStatus();
572
+ });
573
+ licenseCmd
574
+ .command('install')
575
+ .description('Install an enterprise license key (raw key or file path)')
576
+ .argument('<keyOrFile>', 'License key string or path to a file containing the key')
577
+ .option('--system', 'Install to /etc/labgate/license.key')
578
+ .option('--user', `Install to ${(0, path_1.join)((0, os_1.homedir)(), '.labgate', 'license.key')}`)
579
+ .option('--path <path>', 'Install to a custom path')
580
+ .option('--overwrite', 'Overwrite existing target file')
581
+ .action(async (keyOrFile, opts) => {
582
+ try {
583
+ const sourcePath = (0, path_1.resolve)(keyOrFile);
584
+ const raw = (0, fs_1.existsSync)(sourcePath) ? (0, fs_1.readFileSync)(sourcePath, 'utf-8') : keyOrFile;
585
+ const targetPath = resolveLicenseTargetPath(opts);
586
+ await installLicenseKeyAtPath(raw, targetPath, !!opts.overwrite);
587
+ console.log(`License installed: ${targetPath}`);
588
+ await printLicenseStatus(targetPath);
544
589
  }
545
- if (status.filePath) {
546
- console.log(`License file: ${status.filePath}`);
590
+ catch (err) {
591
+ console.error(`Error: ${err.message}`);
592
+ process.exit(1);
547
593
  }
548
- if (status.error) {
549
- console.log(`Status: INVALID ${status.error}`);
550
- if (status.payload) {
551
- console.log(`Institution: ${status.payload.institution}`);
552
- console.log(`Expires: ${status.payload.expires}`);
594
+ });
595
+ // ── labgate register ─────────────────────────────────────
596
+ program
597
+ .command('register')
598
+ .description('Activate enterprise license with an activation key and install it locally')
599
+ .argument('<activationKey>', 'Activation key issued by LabGate')
600
+ .option('--server <url>', `Activation endpoint URL override (default: ${DEFAULT_ACTIVATION_URL})`)
601
+ .option('--token <token>', 'Optional bearer token for activation API (or LABGATE_ACTIVATION_TOKEN)')
602
+ .option('--timeout <ms>', 'Activation request timeout in milliseconds', '15000')
603
+ .option('--system', 'Install to /etc/labgate/license.key')
604
+ .option('--user', `Install to ${(0, path_1.join)((0, os_1.homedir)(), '.labgate', 'license.key')}`)
605
+ .option('--path <path>', 'Install to a custom path')
606
+ .option('--overwrite', 'Overwrite existing target file')
607
+ .action(async (activationKey, opts) => {
608
+ try {
609
+ const serverUrl = (opts.server || process.env.LABGATE_ACTIVATION_URL || DEFAULT_ACTIVATION_URL).trim();
610
+ const key = (activationKey || '').trim();
611
+ if (!key) {
612
+ console.error('Error: activation key is empty.');
613
+ process.exit(1);
553
614
  }
554
- return;
615
+ const timeoutMs = parseInt(opts.timeout, 10);
616
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1000 || timeoutMs > 300000) {
617
+ console.error('Error: --timeout must be an integer between 1000 and 300000.');
618
+ process.exit(1);
619
+ }
620
+ const token = (opts.token || process.env.LABGATE_ACTIVATION_TOKEN || '').trim() || undefined;
621
+ const targetPath = resolveLicenseTargetPath(opts);
622
+ console.log(`Activating enterprise license via ${serverUrl} ...`);
623
+ const activation = await activateEnterpriseLicense({
624
+ serverUrl,
625
+ activationKey: key,
626
+ timeoutMs,
627
+ token,
628
+ });
629
+ await installLicenseKeyAtPath(activation.licenseKey, targetPath, !!opts.overwrite);
630
+ console.log(`License installed: ${targetPath}`);
631
+ await printLicenseStatus(targetPath);
632
+ if (activation.message) {
633
+ console.log(`Activation: ${activation.message}`);
634
+ }
635
+ console.log('Next: run "labgate ui" and open Admin > Policy to enforce institution settings.');
555
636
  }
556
- if (status.valid && status.payload) {
557
- console.log(`Status: VALID`);
558
- console.log(`Institution: ${status.payload.institution}`);
559
- console.log(`Tier: ${status.payload.tier}`);
560
- console.log(`License ID: ${status.payload.id}`);
561
- console.log(`Issued: ${status.payload.issued}`);
562
- console.log(`Expires: ${status.payload.expires}`);
563
- console.log(`Days remaining: ${status.daysRemaining}`);
637
+ catch (err) {
638
+ console.error(`Error: ${err.message}`);
639
+ process.exit(1);
640
+ }
641
+ });
642
+ // ── labgate policy ───────────────────────────────────────
643
+ const policyCmd = program
644
+ .command('policy')
645
+ .description('Manage enterprise admin policy');
646
+ policyCmd
647
+ .command('validate')
648
+ .description('Validate a policy JSON file')
649
+ .argument('[file]', 'Path to policy JSON (defaults to system policy path)')
650
+ .action(async (file) => {
651
+ try {
652
+ const { getSystemPolicyPath, validatePolicy } = await import('./lib/policy.js');
653
+ const policyPath = file ? (0, path_1.resolve)(file) : getSystemPolicyPath();
654
+ if (!(0, fs_1.existsSync)(policyPath)) {
655
+ console.error(`Error: policy file not found: ${policyPath}`);
656
+ process.exit(1);
657
+ }
658
+ const rawText = (0, fs_1.readFileSync)(policyPath, 'utf-8');
659
+ const stripped = stripJsonComments(rawText);
660
+ const parsed = JSON.parse(stripped);
661
+ const errors = validatePolicy(parsed);
662
+ if (errors.length > 0) {
663
+ console.error(`Policy INVALID: ${policyPath}`);
664
+ for (const e of errors) {
665
+ console.error(` - ${e}`);
666
+ }
667
+ process.exit(1);
668
+ }
669
+ console.log(`Policy VALID: ${policyPath}`);
670
+ }
671
+ catch (err) {
672
+ console.error(`Error: ${err.message}`);
673
+ process.exit(1);
674
+ }
675
+ });
676
+ policyCmd
677
+ .command('init')
678
+ .description('Create an enterprise policy template')
679
+ .option('--path <path>', 'Write policy to a custom path')
680
+ .option('--institution <name>', 'Institution name shown in UI')
681
+ .option('--admin <username>', 'Admin username (repeatable)', collectStringOption, [])
682
+ .option('--runtime <runtime>', 'Forced runtime (auto|apptainer|podman)', 'apptainer')
683
+ .option('--force', 'Overwrite existing policy file')
684
+ .action(async (opts) => {
685
+ try {
686
+ const policyMod = await import('./lib/policy.js');
687
+ const { getSystemPolicyPath, savePolicy, validatePolicy } = policyMod;
688
+ const targetPath = opts.path ? (0, path_1.resolve)(opts.path) : getSystemPolicyPath();
689
+ if ((0, fs_1.existsSync)(targetPath) && !opts.force) {
690
+ console.error(`Error: policy already exists: ${targetPath}`);
691
+ console.error('Use --force to overwrite.');
692
+ process.exit(1);
693
+ }
694
+ const runtime = (opts.runtime || '').trim().toLowerCase();
695
+ if (!['auto', 'apptainer', 'podman'].includes(runtime)) {
696
+ console.error('Error: --runtime must be one of: auto, apptainer, podman');
697
+ process.exit(1);
698
+ }
699
+ const licenseStatus = await (async () => {
700
+ const { validateLicense } = await import('./lib/license.js');
701
+ return validateLicense();
702
+ })();
703
+ const institution = (opts.institution || '').trim()
704
+ || licenseStatus.payload?.institution
705
+ || 'Your Institution';
706
+ const admins = (opts.admin || []).map(a => String(a).trim()).filter(Boolean);
707
+ const bootstrapAdmin = (0, os_1.userInfo)().username;
708
+ if (!admins.includes(bootstrapAdmin))
709
+ admins.unshift(bootstrapAdmin);
710
+ const policy = {
711
+ version: 1,
712
+ institution,
713
+ admins: { usernames: [...new Set(admins)] },
714
+ force: {
715
+ runtime: runtime,
716
+ },
717
+ constraints: {},
718
+ };
719
+ const errors = validatePolicy(policy);
720
+ if (errors.length > 0) {
721
+ console.error('Generated policy is invalid:');
722
+ for (const e of errors) {
723
+ console.error(` - ${e}`);
724
+ }
725
+ process.exit(1);
726
+ }
727
+ const dirMode = targetPath.startsWith('/etc/') ? 0o755 : 0o700;
728
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(targetPath), { recursive: true, mode: dirMode });
729
+ savePolicy(policy, targetPath);
730
+ console.log(`Policy written: ${targetPath}`);
731
+ console.log(`Institution: ${institution}`);
732
+ console.log(`Admins: ${policy.admins.usernames.join(', ')}`);
733
+ console.log(`Forced runtime: ${runtime}`);
734
+ }
735
+ catch (err) {
736
+ console.error(`Error: ${err.message}`);
737
+ process.exit(1);
564
738
  }
565
739
  });
566
740
  // ── Dot-notation helpers ──────────────────────────────────
@@ -585,6 +759,181 @@ function setNestedValue(obj, key, value) {
585
759
  }
586
760
  current[parts[parts.length - 1]] = value;
587
761
  }
762
+ function deleteNestedValue(obj, key) {
763
+ const parts = key.split('.').filter(Boolean);
764
+ if (parts.length === 0)
765
+ return false;
766
+ const stack = [obj];
767
+ let current = obj;
768
+ for (let i = 0; i < parts.length - 1; i++) {
769
+ if (current == null || typeof current !== 'object')
770
+ return false;
771
+ current = current[parts[i]];
772
+ stack.push(current);
773
+ }
774
+ const parent = stack[stack.length - 1];
775
+ const last = parts[parts.length - 1];
776
+ if (parent == null || typeof parent !== 'object')
777
+ return false;
778
+ if (!Object.prototype.hasOwnProperty.call(parent, last))
779
+ return false;
780
+ delete parent[last];
781
+ // Prune empty parent objects (e.g. resetting "network.mode" should remove "network" if empty)
782
+ for (let i = stack.length - 1; i > 0; i--) {
783
+ const node = stack[i];
784
+ if (node == null || typeof node !== 'object')
785
+ break;
786
+ if (Array.isArray(node))
787
+ break;
788
+ if (Object.keys(node).length > 0)
789
+ break;
790
+ const parentNode = stack[i - 1];
791
+ const prop = parts[i - 1];
792
+ if (parentNode && typeof parentNode === 'object') {
793
+ delete parentNode[prop];
794
+ }
795
+ }
796
+ return true;
797
+ }
798
+ function stripJsonComments(rawText) {
799
+ return rawText
800
+ .split('\n')
801
+ .filter(line => !line.trimStart().startsWith('//'))
802
+ .join('\n');
803
+ }
804
+ function collectStringOption(value, previous) {
805
+ previous.push(value);
806
+ return previous;
807
+ }
808
+ function resolveLicenseTargetPath(opts) {
809
+ const explicitTargets = [opts.system, opts.user, !!opts.path].filter(Boolean).length;
810
+ if (explicitTargets > 1) {
811
+ throw new Error('use only one of --system, --user, or --path.');
812
+ }
813
+ const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
814
+ return opts.path
815
+ ? (0, path_1.resolve)(opts.path)
816
+ : opts.system
817
+ ? '/etc/labgate/license.key'
818
+ : opts.user
819
+ ? (0, path_1.join)((0, os_1.homedir)(), '.labgate', 'license.key')
820
+ : (isRoot ? '/etc/labgate/license.key' : (0, path_1.join)((0, os_1.homedir)(), '.labgate', 'license.key'));
821
+ }
822
+ async function installLicenseKeyAtPath(rawLicenseKey, targetPath, overwrite) {
823
+ const key = (rawLicenseKey || '').trim();
824
+ if (!key) {
825
+ throw new Error('license key is empty.');
826
+ }
827
+ if ((0, fs_1.existsSync)(targetPath) && !overwrite) {
828
+ throw new Error(`target already exists: ${targetPath}. Use --overwrite to replace it.`);
829
+ }
830
+ const targetDir = (0, path_1.dirname)(targetPath);
831
+ const dirMode = targetPath.startsWith('/etc/') ? 0o755 : 0o700;
832
+ (0, fs_1.mkdirSync)(targetDir, { recursive: true, mode: dirMode });
833
+ (0, fs_1.writeFileSync)(targetPath, key + '\n', { encoding: 'utf-8', mode: config_js_1.PRIVATE_FILE_MODE });
834
+ (0, config_js_1.ensurePrivateFile)(targetPath);
835
+ const previousLicensePath = process.env.LABGATE_LICENSE_PATH;
836
+ process.env.LABGATE_LICENSE_PATH = targetPath;
837
+ const { validateLicense } = await import('./lib/license.js');
838
+ const status = validateLicense();
839
+ if (previousLicensePath === undefined) {
840
+ delete process.env.LABGATE_LICENSE_PATH;
841
+ }
842
+ else {
843
+ process.env.LABGATE_LICENSE_PATH = previousLicensePath;
844
+ }
845
+ if (!status.valid) {
846
+ try {
847
+ (0, fs_1.unlinkSync)(targetPath);
848
+ }
849
+ catch { /* best effort cleanup */ }
850
+ throw new Error(`installed key is invalid: ${status.error ?? 'unknown error'}`);
851
+ }
852
+ }
853
+ async function activateEnterpriseLicense(input) {
854
+ let parsedUrl;
855
+ try {
856
+ parsedUrl = new URL(input.serverUrl);
857
+ }
858
+ catch {
859
+ throw new Error(`invalid activation URL: ${input.serverUrl}`);
860
+ }
861
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
862
+ throw new Error(`invalid activation URL protocol: ${parsedUrl.protocol}`);
863
+ }
864
+ const headers = {
865
+ 'Content-Type': 'application/json',
866
+ };
867
+ if (input.token) {
868
+ headers.authorization = `Bearer ${input.token}`;
869
+ }
870
+ const payload = {
871
+ activation_key: input.activationKey,
872
+ client: {
873
+ product: 'labgate',
874
+ version: PKG_VERSION,
875
+ hostname: (0, os_1.hostname)(),
876
+ username: (0, os_1.userInfo)().username,
877
+ platform: process.platform,
878
+ arch: process.arch,
879
+ },
880
+ };
881
+ const controller = new AbortController();
882
+ const timer = setTimeout(() => controller.abort(), input.timeoutMs);
883
+ let response;
884
+ try {
885
+ response = await fetch(parsedUrl.toString(), {
886
+ method: 'POST',
887
+ headers,
888
+ body: JSON.stringify(payload),
889
+ signal: controller.signal,
890
+ });
891
+ }
892
+ catch (err) {
893
+ if (err?.name === 'AbortError') {
894
+ throw new Error(`activation request timed out after ${input.timeoutMs}ms`);
895
+ }
896
+ throw new Error(`activation request failed: ${err?.message ?? String(err)}`);
897
+ }
898
+ finally {
899
+ clearTimeout(timer);
900
+ }
901
+ const raw = await response.text();
902
+ let body = {};
903
+ if (raw.trim()) {
904
+ try {
905
+ body = JSON.parse(raw);
906
+ }
907
+ catch {
908
+ if (!response.ok) {
909
+ throw new Error(`activation failed: HTTP ${response.status} ${response.statusText}`);
910
+ }
911
+ throw new Error('activation server returned invalid JSON');
912
+ }
913
+ }
914
+ if (!response.ok) {
915
+ const detail = pickString(body.error, body.message, body.detail) || `HTTP ${response.status} ${response.statusText}`;
916
+ throw new Error(`activation failed: ${detail}`);
917
+ }
918
+ const licenseKey = pickString(body.license_key, body.licenseKey, body.key);
919
+ if (!licenseKey) {
920
+ throw new Error('activation response missing license key');
921
+ }
922
+ return {
923
+ licenseKey,
924
+ message: pickString(body.message),
925
+ };
926
+ }
927
+ function pickString(...values) {
928
+ for (const value of values) {
929
+ if (typeof value === 'string') {
930
+ const trimmed = value.trim();
931
+ if (trimmed)
932
+ return trimmed;
933
+ }
934
+ }
935
+ return undefined;
936
+ }
588
937
  async function runAgent(agent, workdir, opts) {
589
938
  // Resolve workdir
590
939
  const resolved = (0, path_1.resolve)(workdir);
@@ -601,6 +950,11 @@ async function runAgent(agent, workdir, opts) {
601
950
  // Use effective config (merges user config with admin policy if enterprise)
602
951
  const effective = (0, config_js_1.loadEffectiveConfig)();
603
952
  const config = effective.config;
953
+ if (config.network.mode === 'none' && (agent === 'claude' || agent === 'codex')) {
954
+ log.error(`network.mode=none is not supported for ${agent}. ` +
955
+ 'Set network.mode to "host" or "filtered".');
956
+ process.exit(1);
957
+ }
604
958
  if (verbose) {
605
959
  log.header('Config');
606
960
  const rows = [
@@ -646,6 +1000,50 @@ async function runAgent(agent, workdir, opts) {
646
1000
  sharedAuditDir: effective.sharedAuditDir,
647
1001
  });
648
1002
  }
1003
+ async function printLicenseStatus(licensePathOverride) {
1004
+ const previousLicensePath = process.env.LABGATE_LICENSE_PATH;
1005
+ if (licensePathOverride) {
1006
+ process.env.LABGATE_LICENSE_PATH = licensePathOverride;
1007
+ }
1008
+ const { validateLicense } = await import('./lib/license.js');
1009
+ const status = validateLicense();
1010
+ if (licensePathOverride) {
1011
+ if (previousLicensePath === undefined) {
1012
+ delete process.env.LABGATE_LICENSE_PATH;
1013
+ }
1014
+ else {
1015
+ process.env.LABGATE_LICENSE_PATH = previousLicensePath;
1016
+ }
1017
+ }
1018
+ if (!status.filePath && !status.error) {
1019
+ console.log('No enterprise license found.');
1020
+ console.log('License file locations (checked in order):');
1021
+ console.log(' 1. $LABGATE_LICENSE_PATH (env var)');
1022
+ console.log(' 2. /etc/labgate/license.key');
1023
+ console.log(' 3. ~/.labgate/license.key');
1024
+ return;
1025
+ }
1026
+ if (status.filePath) {
1027
+ console.log(`License file: ${status.filePath}`);
1028
+ }
1029
+ if (status.error) {
1030
+ console.log(`Status: INVALID — ${status.error}`);
1031
+ if (status.payload) {
1032
+ console.log(`Institution: ${status.payload.institution}`);
1033
+ console.log(`Expires: ${status.payload.expires}`);
1034
+ }
1035
+ return;
1036
+ }
1037
+ if (status.valid && status.payload) {
1038
+ console.log(`Status: VALID`);
1039
+ console.log(`Institution: ${status.payload.institution}`);
1040
+ console.log(`Tier: ${status.payload.tier}`);
1041
+ console.log(`License ID: ${status.payload.id}`);
1042
+ console.log(`Issued: ${status.payload.issued}`);
1043
+ console.log(`Expires: ${status.payload.expires}`);
1044
+ console.log(`Days remaining: ${status.daysRemaining}`);
1045
+ }
1046
+ }
649
1047
  // Commander uses -V for version by default. Keep backward compatibility and
650
1048
  // provide lowercase -v as a user-friendly alias.
651
1049
  if (process.argv.length === 3 && process.argv[2] === '-v') {