unbound-cli 1.16.8 → 1.16.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.16.8",
3
+ "version": "1.16.10",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -200,7 +200,7 @@ function register(program) {
200
200
  .addOption(new Option('--discovery-key <key>', 'Deprecated and ignored. The scan uses your API key.').hideHelp())
201
201
  .addHelpText('after', `
202
202
  Scans this device for installed AI coding tools (Cursor, Claude Code,
203
- Gemini CLI, Codex, Windsurf, Roo Code, Cline, GitHub Copilot, JetBrains,
203
+ Gemini CLI, Codex, Devin Desktop, Roo Code, Cline, GitHub Copilot, JetBrains,
204
204
  and more) and reports findings to the Unbound backend.
205
205
 
206
206
  Which --api-key to use depends on scope:
package/src/toolHealth.js CHANGED
@@ -17,6 +17,7 @@ const fs = require('fs');
17
17
  const os = require('os');
18
18
  const path = require('path');
19
19
  const { spawnSync } = require('child_process');
20
+ const TOML = require('@iarna/toml');
20
21
  const claudePaths = require('./claudePaths');
21
22
 
22
23
  const HOME = os.homedir();
@@ -353,7 +354,18 @@ function buildVariants(gatewayUrl, binaryPath) {
353
354
  key: 'codex-subscription', label: 'Codex (subscription)', family: 'codex', mode: 'subscription',
354
355
  checks: () => [
355
356
  configCheck('Config', '~/.codex/hooks.json', { json: true, test: refsUnbound }),
356
- configCheck('Hooks feature flag', '~/.codex/config.toml', { json: false, test: (t) => /codex_hooks\s*=\s*true/.test(t) }, { short: 'codex hooks not enabled' }),
357
+ // Codex enables hooks via `[features] hooks = true`; the pre-Aug-2026 alias
358
+ // `[features] codex_hooks = true` is still honoured, with the canonical `hooks`
359
+ // winning when both are set. Both live under [features], so a hooks key in any
360
+ // other table isn't this flag; an unparseable config (which Codex couldn't load
361
+ // either) reads as not enabled.
362
+ configCheck('Hooks feature flag', '~/.codex/config.toml', { json: false, test: (t) => {
363
+ let cfg;
364
+ try { cfg = TOML.parse(t.replace(/^\uFEFF/, '')); } catch { return false; } // tolerate a UTF-8 BOM, like readJson
365
+ const features = cfg.features;
366
+ if (!features || typeof features !== 'object') return false;
367
+ return features.hooks === undefined ? features.codex_hooks === true : features.hooks === true;
368
+ } }, { short: 'codex hooks not enabled' }),
357
369
  scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
358
370
  envKeyCheck('API key env', 'UNBOUND_CODEX_API_KEY'),
359
371
  ],
@@ -0,0 +1,71 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ // End-to-end guard through the real shipped binary: `unbound doctor --json`.
9
+ // The unit tests exercise detectTools directly; this proves the same fix holds
10
+ // when the CLI is actually run. A Codex install using the current spelling
11
+ // (`[features] hooks = true`) must report healthy, not tampered — the reported
12
+ // bug. Key validation is pointed at an unroutable API so it fails open offline
13
+ // and never leaves the machine.
14
+
15
+ const CLI = path.join(__dirname, '..', 'src', 'index.js');
16
+
17
+ function runDoctor(home) {
18
+ const r = spawnSync('node', [CLI, 'doctor', '--json'], {
19
+ encoding: 'utf8',
20
+ env: {
21
+ ...process.env,
22
+ HOME: home,
23
+ UNBOUND_CODEX_API_KEY: 'dummy-key',
24
+ // Unroutable API: validateApiKey fails fast and fails open (status stays
25
+ // as detectTools derived it), so the run is hermetic and offline.
26
+ UNBOUND_API_URL: 'http://127.0.0.1:1',
27
+ },
28
+ });
29
+ // doctor exits non-zero when ANY tool is tampered; this host may carry
30
+ // unrelated MDM-managed tools, so we assert on the codex entry, not the code.
31
+ const start = r.stdout.indexOf('{');
32
+ assert.notEqual(start, -1, `doctor produced no JSON\nstdout:${r.stdout}\nstderr:${r.stderr}`);
33
+ return JSON.parse(r.stdout.slice(start));
34
+ }
35
+
36
+ function seedHealthyCodex(home) {
37
+ const wrapper = path.join(home, '.codex', 'hooks', 'unbound.py');
38
+ fs.mkdirSync(path.dirname(wrapper), { recursive: true });
39
+ fs.writeFileSync(wrapper, '# unbound');
40
+ fs.writeFileSync(path.join(home, '.codex', 'hooks.json'), JSON.stringify({
41
+ hooks: { PreToolUse: [{ command: wrapper }] },
42
+ }));
43
+ }
44
+
45
+ test('unbound doctor: real binary reads new-spelling [features] hooks = true codex as healthy', () => {
46
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-doctor-e2e-'));
47
+ try {
48
+ seedHealthyCodex(home);
49
+ fs.writeFileSync(path.join(home, '.codex', 'config.toml'), 'model = "gpt-5"\n\n[features]\nhooks = true\n');
50
+ const report = runDoctor(home);
51
+ const codex = report.tools.find((t) => t.tool === 'codex-subscription');
52
+ assert.ok(codex, 'doctor JSON must include the codex-subscription tool');
53
+ assert.equal(codex.status, 'healthy', 'a working codex install must not read as tampered');
54
+ } finally {
55
+ fs.rmSync(home, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test('unbound doctor: real binary reads legacy [features] codex_hooks = true codex as healthy', () => {
60
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-doctor-e2e-'));
61
+ try {
62
+ seedHealthyCodex(home);
63
+ fs.writeFileSync(path.join(home, '.codex', 'config.toml'), '[features]\ncodex_hooks = true\n');
64
+ const report = runDoctor(home);
65
+ const codex = report.tools.find((t) => t.tool === 'codex-subscription');
66
+ assert.ok(codex, 'doctor JSON must include the codex-subscription tool');
67
+ assert.equal(codex.status, 'healthy', 'a legacy codex install must stay healthy');
68
+ } finally {
69
+ fs.rmSync(home, { recursive: true, force: true });
70
+ }
71
+ });
@@ -361,8 +361,148 @@ test('detectTools: codex binary regression (wrapper at ~/.codex/hooks/unbound.py
361
361
  hooks: { PreToolUse: [{ command: wrapper }] },
362
362
  }));
363
363
  writeFile(wrapper, '#!/bin/sh\nexec /opt/unbound/current/unbound-hook/unbound-hook hook codex "$@"\n');
364
+ writeFile(path.join(tmp, '.codex', 'config.toml'), '[features]\nhooks = true\n');
365
+ process.env.UNBOUND_CODEX_API_KEY = 'k';
366
+ try {
367
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
368
+ assert.equal(t.status, 'healthy');
369
+ } finally {
370
+ delete process.env.UNBOUND_CODEX_API_KEY;
371
+ }
372
+ });
373
+ });
374
+
375
+ // The installer now writes `hooks = true` under [features] (and strips the legacy
376
+ // `codex_hooks`). A healthy Codex install using the new spelling must read healthy.
377
+ test('detectTools: codex with new-spelling [features] hooks = true → healthy', () => {
378
+ withHome((tmp, th) => {
379
+ const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
380
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
381
+ hooks: { PreToolUse: [{ command: wrapper }] },
382
+ }));
383
+ writeFile(wrapper, '# unbound');
384
+ writeFile(path.join(tmp, '.codex', 'config.toml'), 'model = "gpt-5"\n\n[features]\nhooks = true\n');
385
+ process.env.UNBOUND_CODEX_API_KEY = 'k';
386
+ try {
387
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
388
+ assert.equal(t.status, 'healthy');
389
+ } finally {
390
+ delete process.env.UNBOUND_CODEX_API_KEY;
391
+ }
392
+ });
393
+ });
394
+
395
+ // An unrelated key ending in `hooks = true` must NOT satisfy the flag: with no real
396
+ // hooks flag present the feature is off, so the install reads tampered.
397
+ test('detectTools: codex config with only `web_hooks = true` (no real flag) → tampered', () => {
398
+ withHome((tmp, th) => {
399
+ const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
400
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
401
+ hooks: { PreToolUse: [{ command: wrapper }] },
402
+ }));
403
+ writeFile(wrapper, '# unbound');
404
+ writeFile(path.join(tmp, '.codex', 'config.toml'), 'web_hooks = true\n');
405
+ process.env.UNBOUND_CODEX_API_KEY = 'k';
406
+ try {
407
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
408
+ assert.equal(t.status, 'tampered');
409
+ } finally {
410
+ delete process.env.UNBOUND_CODEX_API_KEY;
411
+ }
412
+ });
413
+ });
414
+
415
+ // `hooks = true` under a table other than [features] does not enable Codex's hooks,
416
+ // so the flag must not count it: the install reads tampered, not healthy.
417
+ test('detectTools: codex `hooks = true` outside [features] (wrong table) → tampered', () => {
418
+ withHome((tmp, th) => {
419
+ const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
420
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
421
+ hooks: { PreToolUse: [{ command: wrapper }] },
422
+ }));
423
+ writeFile(wrapper, '# unbound');
424
+ writeFile(path.join(tmp, '.codex', 'config.toml'), '[mcp_servers.demo]\nhooks = true\n');
425
+ process.env.UNBOUND_CODEX_API_KEY = 'k';
426
+ try {
427
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
428
+ assert.equal(t.status, 'tampered');
429
+ } finally {
430
+ delete process.env.UNBOUND_CODEX_API_KEY;
431
+ }
432
+ });
433
+ });
434
+
435
+ // The legacy alias only ever lived under [features]. A root-level `codex_hooks = true`
436
+ // is not a form the installer or Codex ever wrote, so it must not count: tampered.
437
+ test('detectTools: codex root-level `codex_hooks = true` (outside [features]) → tampered', () => {
438
+ withHome((tmp, th) => {
439
+ const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
440
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
441
+ hooks: { PreToolUse: [{ command: wrapper }] },
442
+ }));
443
+ writeFile(wrapper, '# unbound');
364
444
  writeFile(path.join(tmp, '.codex', 'config.toml'), 'codex_hooks = true\n');
365
445
  process.env.UNBOUND_CODEX_API_KEY = 'k';
446
+ try {
447
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
448
+ assert.equal(t.status, 'tampered');
449
+ } finally {
450
+ delete process.env.UNBOUND_CODEX_API_KEY;
451
+ }
452
+ });
453
+ });
454
+
455
+ // Codex resolves the canonical `hooks` ahead of the alias, so an explicit
456
+ // `hooks = false` disables hooks even when `codex_hooks = true` sits beside it.
457
+ test('detectTools: codex `[features] hooks = false` beside `codex_hooks = true` → tampered', () => {
458
+ withHome((tmp, th) => {
459
+ const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
460
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
461
+ hooks: { PreToolUse: [{ command: wrapper }] },
462
+ }));
463
+ writeFile(wrapper, '# unbound');
464
+ writeFile(path.join(tmp, '.codex', 'config.toml'), '[features]\nhooks = false\ncodex_hooks = true\n');
465
+ process.env.UNBOUND_CODEX_API_KEY = 'k';
466
+ try {
467
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
468
+ assert.equal(t.status, 'tampered');
469
+ } finally {
470
+ delete process.env.UNBOUND_CODEX_API_KEY;
471
+ }
472
+ });
473
+ });
474
+
475
+ // Before the Aug-2026 rename the installer wrote the alias `[features] codex_hooks = true`.
476
+ // Codex still honours that table-scoped alias, so a real legacy install reads healthy.
477
+ test('detectTools: codex legacy `[features] codex_hooks = true` alias → healthy', () => {
478
+ withHome((tmp, th) => {
479
+ const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
480
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
481
+ hooks: { PreToolUse: [{ command: wrapper }] },
482
+ }));
483
+ writeFile(wrapper, '# unbound');
484
+ writeFile(path.join(tmp, '.codex', 'config.toml'), '[features]\ncodex_hooks = true\n');
485
+ process.env.UNBOUND_CODEX_API_KEY = 'k';
486
+ try {
487
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
488
+ assert.equal(t.status, 'healthy');
489
+ } finally {
490
+ delete process.env.UNBOUND_CODEX_API_KEY;
491
+ }
492
+ });
493
+ });
494
+
495
+ // Codex tolerates a leading UTF-8 BOM on config.toml, so doctor must too: strip it
496
+ // before parsing (like readJson) rather than failing closed to tampered.
497
+ test('detectTools: codex config with a leading UTF-8 BOM (`[features] hooks = true`) → healthy', () => {
498
+ withHome((tmp, th) => {
499
+ const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
500
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
501
+ hooks: { PreToolUse: [{ command: wrapper }] },
502
+ }));
503
+ writeFile(wrapper, '# unbound');
504
+ writeFile(path.join(tmp, '.codex', 'config.toml'), '\uFEFF[features]\nhooks = true\n');
505
+ process.env.UNBOUND_CODEX_API_KEY = 'k';
366
506
  try {
367
507
  const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
368
508
  assert.equal(t.status, 'healthy');
@@ -372,6 +512,65 @@ test('detectTools: codex binary regression (wrapper at ~/.codex/hooks/unbound.py
372
512
  });
373
513
  });
374
514
 
515
+ // The installer can emit the inline-table form `features = { hooks = true }`, which is
516
+ // the same [features] hooks = true after parsing, so it must read healthy.
517
+ test('detectTools: codex inline `features = { hooks = true }` → healthy', () => {
518
+ withHome((tmp, th) => {
519
+ const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
520
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
521
+ hooks: { PreToolUse: [{ command: wrapper }] },
522
+ }));
523
+ writeFile(wrapper, '# unbound');
524
+ writeFile(path.join(tmp, '.codex', 'config.toml'), 'features = { hooks = true }\n');
525
+ process.env.UNBOUND_CODEX_API_KEY = 'k';
526
+ try {
527
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
528
+ assert.equal(t.status, 'healthy');
529
+ } finally {
530
+ delete process.env.UNBOUND_CODEX_API_KEY;
531
+ }
532
+ });
533
+ });
534
+
535
+ // A config Codex can't parse can't have hooks enabled, so even one that contains a
536
+ // `codex_hooks = true` line must read tampered rather than healthy.
537
+ test('detectTools: codex with a malformed config.toml → tampered', () => {
538
+ withHome((tmp, th) => {
539
+ const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
540
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
541
+ hooks: { PreToolUse: [{ command: wrapper }] },
542
+ }));
543
+ writeFile(wrapper, '# unbound');
544
+ writeFile(path.join(tmp, '.codex', 'config.toml'), 'codex_hooks = true\n[unterminated\n');
545
+ process.env.UNBOUND_CODEX_API_KEY = 'k';
546
+ try {
547
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
548
+ assert.equal(t.status, 'tampered');
549
+ } finally {
550
+ delete process.env.UNBOUND_CODEX_API_KEY;
551
+ }
552
+ });
553
+ });
554
+
555
+ // The hook file going missing must still read tampered even when the feature flag is set.
556
+ test('detectTools: codex hook script missing (flag set) → tampered', () => {
557
+ withHome((tmp, th) => {
558
+ const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
559
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
560
+ hooks: { PreToolUse: [{ command: wrapper }] },
561
+ }));
562
+ // Deliberately do NOT create the unbound.py hook script.
563
+ writeFile(path.join(tmp, '.codex', 'config.toml'), '[features]\nhooks = true\n');
564
+ process.env.UNBOUND_CODEX_API_KEY = 'k';
565
+ try {
566
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
567
+ assert.equal(t.status, 'tampered');
568
+ } finally {
569
+ delete process.env.UNBOUND_CODEX_API_KEY;
570
+ }
571
+ });
572
+ });
573
+
375
574
  // envCheck's rc-file fallback applies to the gateway URL check
376
575
  // (ANTHROPIC_BASE_URL is still equality-checked since tenants share one
377
576
  // gateway URL). API keys are no longer equality-checked — see WEB-4949 — so