unbound-cli 1.15.1 → 1.16.7

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.
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
- const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, failureReason, NUKE_ENV_VARS, removeBinaryInstall, runBinaryClear, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
6
+ const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, failureReason, NUKE_ENV_VARS, removeBinaryInstall, runBinaryClear, BINARY_INSTALL_PATHS, configDirForScript } = require('../src/commands/setup');
7
7
 
8
8
  // shellEscape single-quotes every value, so a real key surfaces as
9
9
  // --api-key '<key>' at the head of the argv tail.
@@ -93,6 +93,16 @@ test('buildScriptArgs: non-mdm includes --domain when frontendUrl passed', () =>
93
93
  assert.ok(args.includes("--domain 'https://gateway.acme.com'"), args);
94
94
  });
95
95
 
96
+ test('buildScriptArgs: configDir appends shell-escaped --config-dir', () => {
97
+ const args = buildScriptArgs('sk-x', { configDir: '/x/y' });
98
+ assert.ok(args.includes("--config-dir '/x/y'"), args);
99
+ });
100
+
101
+ test('buildScriptArgs: omits --config-dir when not passed', () => {
102
+ const args = buildScriptArgs('sk-x', {});
103
+ assert.ok(!args.includes('--config-dir'), args);
104
+ });
105
+
96
106
  // The result is always trimmed regardless of which flags are present.
97
107
  test('buildScriptArgs: result is always trimmed', () => {
98
108
  const cases = [
@@ -618,3 +628,36 @@ test('failureReason: non-Error throw (null/undefined) never throws', () => {
618
628
  assert.equal(failureReason(undefined), 'failed');
619
629
  assert.equal(failureReason('a string'), 'failed');
620
630
  });
631
+
632
+ test('configDirForScript: forwards the active dir for hooks AND gateway scripts when CLAUDE_CONFIG_DIR is set', () => {
633
+ const prev = process.env.CLAUDE_CONFIG_DIR;
634
+ process.env.CLAUDE_CONFIG_DIR = '/opt/cc';
635
+ try {
636
+ assert.equal(configDirForScript('claude-code/hooks/setup.py'), require('path').resolve('/opt/cc'));
637
+ assert.equal(configDirForScript('claude-code/gateway/setup.py'), require('path').resolve('/opt/cc'));
638
+ } finally {
639
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = prev;
640
+ }
641
+ });
642
+
643
+ test('configDirForScript: never forwards to MDM scripts (per-user home) or unrelated tools', () => {
644
+ const prev = process.env.CLAUDE_CONFIG_DIR;
645
+ process.env.CLAUDE_CONFIG_DIR = '/opt/cc';
646
+ try {
647
+ assert.equal(configDirForScript('claude-code/hooks/mdm/setup.py'), undefined);
648
+ assert.equal(configDirForScript('claude-code/gateway/mdm/setup.py'), undefined);
649
+ assert.equal(configDirForScript('cursor/setup.py'), undefined);
650
+ } finally {
651
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = prev;
652
+ }
653
+ });
654
+
655
+ test('configDirForScript: returns undefined when CLAUDE_CONFIG_DIR is unset (default dir)', () => {
656
+ const prev = process.env.CLAUDE_CONFIG_DIR;
657
+ delete process.env.CLAUDE_CONFIG_DIR;
658
+ try {
659
+ assert.equal(configDirForScript('claude-code/gateway/setup.py'), undefined);
660
+ } finally {
661
+ if (prev !== undefined) process.env.CLAUDE_CONFIG_DIR = prev;
662
+ }
663
+ });
@@ -0,0 +1,69 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { elevated, scopeNotice } = require('../src/commands/setup');
7
+
8
+ function onPlatform(plat, fn) {
9
+ const orig = Object.getOwnPropertyDescriptor(process, 'platform');
10
+ Object.defineProperty(process, 'platform', { value: plat, configurable: true });
11
+ try { return fn(); } finally { Object.defineProperty(process, 'platform', orig); }
12
+ }
13
+
14
+ // Windows has no `sudo`. Telling a Windows user to run `sudo unbound doctor --fix`
15
+ // sends them to a command that fails with "'sudo' is not recognized".
16
+ test('elevated: Windows advises an Administrator terminal and never says sudo', () => {
17
+ const out = onPlatform('win32', () => elevated('unbound doctor --fix'));
18
+ assert.ok(!out.includes('sudo'), out);
19
+ assert.match(out, /Administrator/);
20
+ assert.ok(out.includes('unbound doctor --fix'), out);
21
+ });
22
+
23
+ test('elevated: macOS and Linux keep the sudo prefix', () => {
24
+ for (const plat of ['darwin', 'linux']) {
25
+ assert.equal(onPlatform(plat, () => elevated('unbound doctor --fix')), 'sudo unbound doctor --fix');
26
+ }
27
+ });
28
+
29
+ test('elevated: the command itself is preserved verbatim on every platform', () => {
30
+ const cmd = 'unbound discover --api-key <KEY> --set-cron';
31
+ for (const plat of ['darwin', 'linux', 'win32']) {
32
+ assert.ok(onPlatform(plat, () => elevated(cmd)).includes(cmd));
33
+ }
34
+ });
35
+
36
+ // The reported bug: an elevated shell silently switched setup to an org-wide install,
37
+ // printing output identical to a per-user one. The two must be distinguishable.
38
+ test('scopeNotice: org-wide and per-user read differently', () => {
39
+ const mdm = scopeNotice(true);
40
+ const user = scopeNotice(false);
41
+ assert.notEqual(mdm, user);
42
+ assert.match(mdm, /organization-wide/);
43
+ assert.match(mdm, /every user on this device/);
44
+ assert.match(user, /your account only/);
45
+ });
46
+
47
+ test('scopeNotice: the org-wide line says why the scope was chosen', () => {
48
+ // Elevation is ambient state the user did not set for this purpose, so the
49
+ // reason has to be on screen, not inferred.
50
+ assert.match(scopeNotice(true), /elevated/);
51
+ });
52
+
53
+ // Guard against a new hardcoded instruction slipping in beside the fixed ones.
54
+ test('no actionable sudo instruction is printed to the user', () => {
55
+ for (const file of ['doctor.js', 'onboard.js']) {
56
+ const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'commands', file), 'utf8');
57
+ const printed = src.split('\n').filter((l) => /console\.(log|error)|output\.(error|warn|info)/.test(l));
58
+ const offenders = printed.filter((l) => l.includes('sudo unbound'));
59
+ assert.deepEqual(offenders, [], `${file} prints a literal sudo command: ${offenders.join(' | ')}`);
60
+ }
61
+ });
62
+
63
+ // Both scope announcements must fire at the decision point, not somewhere later.
64
+ test('setup and onboard both announce the resolved scope', () => {
65
+ for (const file of ['setup.js', 'onboard.js']) {
66
+ const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'commands', file), 'utf8');
67
+ assert.ok(/output\.info\(scopeNotice\(isMdm\)\)/.test(src), `${file} does not announce its scope`);
68
+ }
69
+ });
@@ -0,0 +1,206 @@
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
+
7
+ const { managedDir } = require('../src/toolHealth');
8
+
9
+ // Windows has two plausible machine-wide roots and the setup repo does not use one
10
+ // uniformly: Claude Code and Codex install under Program Files, Cursor and Augment
11
+ // under ProgramData. Looking in the wrong one reports a governed tool as ungoverned.
12
+ const WIN_ENV = { ProgramData: 'C:\\ProgramData', ProgramFiles: 'C:\\Program Files' };
13
+
14
+ test('managedDir win32: Claude Code and Codex live under Program Files', () => {
15
+ assert.equal(managedDir('claude-code', 'win32', WIN_ENV), path.join('C:\\Program Files', 'ClaudeCode'));
16
+ assert.equal(managedDir('codex', 'win32', WIN_ENV), path.join('C:\\Program Files', 'Codex'));
17
+ });
18
+
19
+ test('managedDir win32: Cursor and Augment live under ProgramData', () => {
20
+ assert.equal(managedDir('cursor', 'win32', WIN_ENV), path.join('C:\\ProgramData', 'Cursor'));
21
+ assert.equal(managedDir('augment', 'win32', WIN_ENV), path.join('C:\\ProgramData', 'Augment'));
22
+ });
23
+
24
+ test('managedDir win32: honors ProgramFiles/ProgramData overrides, falls back when unset', () => {
25
+ assert.equal(managedDir('claude-code', 'win32', { ProgramFiles: 'D:\\PF' }), path.join('D:\\PF', 'ClaudeCode'));
26
+ assert.equal(managedDir('claude-code', 'win32', {}), path.join('C:\\Program Files', 'ClaudeCode'));
27
+ assert.equal(managedDir('cursor', 'win32', {}), path.join('C:\\ProgramData', 'Cursor'));
28
+ });
29
+
30
+ // macOS and Linux were already correct; pin them so the Windows fix cannot regress them.
31
+ test('managedDir darwin: unchanged', () => {
32
+ assert.equal(managedDir('cursor', 'darwin', {}), '/Library/Application Support/Cursor');
33
+ assert.equal(managedDir('claude-code', 'darwin', {}), '/Library/Application Support/ClaudeCode');
34
+ assert.equal(managedDir('codex', 'darwin', {}), '/Library/Application Support/Codex');
35
+ assert.equal(managedDir('augment', 'darwin', {}), '/etc/augment');
36
+ });
37
+
38
+ test('managedDir linux: unchanged', () => {
39
+ assert.equal(managedDir('cursor', 'linux', {}), '/etc/cursor');
40
+ assert.equal(managedDir('claude-code', 'linux', {}), '/etc/claude-code');
41
+ assert.equal(managedDir('codex', 'linux', {}), '/etc/codex');
42
+ assert.equal(managedDir('augment', 'linux', {}), '/etc/augment');
43
+ });
44
+
45
+ test('managedDir: tools with no managed directory return null', () => {
46
+ assert.equal(managedDir('copilot', 'win32', WIN_ENV), null);
47
+ assert.equal(managedDir('gemini', 'darwin', {}), null);
48
+ });
49
+
50
+ // --- config-file discovery, exercised through the existing _mdmDirs override ---
51
+
52
+ function withHome(fn) {
53
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-mdm-'));
54
+ const origHome = process.env.HOME;
55
+ process.env.HOME = tmp;
56
+ delete require.cache[require.resolve('../src/toolHealth')];
57
+ try {
58
+ return fn(tmp, require('../src/toolHealth'));
59
+ } finally {
60
+ if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome;
61
+ delete require.cache[require.resolve('../src/toolHealth')];
62
+ fs.rmSync(tmp, { recursive: true, force: true });
63
+ }
64
+ }
65
+
66
+ function writeFile(p, body) {
67
+ fs.mkdirSync(path.dirname(p), { recursive: true });
68
+ fs.writeFileSync(p, body);
69
+ }
70
+
71
+ // The MDM install prefers managed-settings.d/unbound.json and only writes the flat file
72
+ // as a fallback, on every platform. A drop-in-only device is the normal case, not an edge.
73
+ test('Claude Code: managed-settings.d drop-in alone is recognised as MDM-managed', () => {
74
+ withHome((tmp, th) => {
75
+ const dir = path.join(tmp, 'mdm', 'ClaudeCode');
76
+ writeFile(path.join(dir, 'managed-settings.d', 'unbound.json'),
77
+ JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(dir, 'hooks', 'unbound.py') }] } }));
78
+ writeFile(path.join(dir, 'hooks', 'unbound.py'), '#');
79
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': dir } }).find((x) => x.family === 'claude-code');
80
+ assert.equal(t.status, 'managed-by-mdm');
81
+ assert.equal(t.scope, 'mdm');
82
+ });
83
+ });
84
+
85
+ test('Claude Code: flat managed-settings.json still works when no drop-in exists', () => {
86
+ withHome((tmp, th) => {
87
+ const dir = path.join(tmp, 'mdm', 'ClaudeCode');
88
+ writeFile(path.join(dir, 'managed-settings.json'),
89
+ JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(dir, 'hooks', 'unbound.py') }] } }));
90
+ writeFile(path.join(dir, 'hooks', 'unbound.py'), '#');
91
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': dir } }).find((x) => x.family === 'claude-code');
92
+ assert.equal(t.status, 'managed-by-mdm');
93
+ });
94
+ });
95
+
96
+ // Codex's MDM install writes hooks.json, not managed-settings.json.
97
+ test('Codex: managed hooks.json is recognised as MDM-managed', () => {
98
+ withHome((tmp, th) => {
99
+ const dir = path.join(tmp, 'mdm', 'Codex');
100
+ writeFile(path.join(dir, 'hooks.json'),
101
+ JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(dir, 'hooks', 'unbound.py') }] } }));
102
+ writeFile(path.join(dir, 'hooks', 'unbound.py'), '#');
103
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { codex: dir } }).find((x) => x.family === 'codex');
104
+ assert.equal(t.status, 'managed-by-mdm');
105
+ assert.equal(t.scope, 'mdm');
106
+ });
107
+ });
108
+
109
+ // A plain Claude Enterprise managed file that never mentions Unbound must not register.
110
+ test('a foreign managed config is not reported as an Unbound MDM install', () => {
111
+ withHome((tmp, th) => {
112
+ const dir = path.join(tmp, 'mdm', 'ClaudeCode');
113
+ writeFile(path.join(dir, 'managed-settings.d', 'unbound.json'),
114
+ JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://bedrock.acme.internal' } }));
115
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': dir } }).find((x) => x.family === 'claude-code');
116
+ assert.notEqual(t.scope, 'mdm');
117
+ });
118
+ });
119
+
120
+ // --- doctor routing depends on these two fields ---
121
+ // `unbound doctor --fix` splits repairs with `tampered.filter(t => t.scope === 'mdm')`.
122
+ // Before the path fix these installs read as not-installed, so a broken org-managed
123
+ // Claude Code or Codex was never offered a repair at all.
124
+
125
+ test('doctor routing: a broken Claude Code drop-in install is tampered + mdm scope', () => {
126
+ withHome((tmp, th) => {
127
+ const dir = path.join(tmp, 'mdm', 'ClaudeCode');
128
+ // config references the hook, but the hook script was removed
129
+ writeFile(path.join(dir, 'managed-settings.d', 'unbound.json'),
130
+ JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(dir, 'hooks', 'unbound.py') }] } }));
131
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': dir } }).find((x) => x.family === 'claude-code');
132
+ assert.equal(t.status, 'tampered');
133
+ assert.equal(t.scope, 'mdm');
134
+ });
135
+ });
136
+
137
+ test('doctor routing: a broken Codex hooks.json install is tampered + mdm scope', () => {
138
+ withHome((tmp, th) => {
139
+ const dir = path.join(tmp, 'mdm', 'Codex');
140
+ writeFile(path.join(dir, 'hooks.json'),
141
+ JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(dir, 'hooks', 'unbound.py') }] } }));
142
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { codex: dir } }).find((x) => x.family === 'codex');
143
+ assert.equal(t.status, 'tampered');
144
+ assert.equal(t.scope, 'mdm');
145
+ });
146
+ });
147
+
148
+ // A drop-in left behind by an earlier install stays on disk after its hooks are
149
+ // stripped. It must not mask a valid flat managed-settings.json sitting beside it.
150
+ test('Claude Code: a stale drop-in does not hide a valid flat managed-settings.json', () => {
151
+ withHome((tmp, th) => {
152
+ const dir = path.join(tmp, 'mdm', 'ClaudeCode');
153
+ writeFile(path.join(dir, 'managed-settings.d', 'unbound.json'),
154
+ JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://bedrock.acme.internal' } }));
155
+ writeFile(path.join(dir, 'managed-settings.json'),
156
+ JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(dir, 'hooks', 'unbound.py') }] } }));
157
+ writeFile(path.join(dir, 'hooks', 'unbound.py'), '#');
158
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': dir } }).find((x) => x.family === 'claude-code');
159
+ assert.equal(t.status, 'managed-by-mdm');
160
+ assert.equal(t.scope, 'mdm');
161
+ });
162
+ });
163
+
164
+ test('Claude Code: neither candidate referencing Unbound is still not an Unbound install', () => {
165
+ withHome((tmp, th) => {
166
+ const dir = path.join(tmp, 'mdm', 'ClaudeCode');
167
+ writeFile(path.join(dir, 'managed-settings.d', 'unbound.json'), JSON.stringify({ env: {} }));
168
+ writeFile(path.join(dir, 'managed-settings.json'), JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://x' } }));
169
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': dir } }).find((x) => x.family === 'claude-code');
170
+ assert.notEqual(t.scope, 'mdm');
171
+ });
172
+ });
173
+
174
+ // A python-era drop-in can survive a migration to the binary hook. It still names
175
+ // unbound.py, but that script is gone; the flat file beside it names the live binary.
176
+ // The healthy candidate has to win, or a working install reports as tampered and
177
+ // doctor sends the user through a pointless elevated repair.
178
+ test('Claude Code: a stale drop-in naming a missing hook loses to a flat file naming a live one', () => {
179
+ withHome((tmp, th) => {
180
+ const dir = path.join(tmp, 'mdm', 'ClaudeCode');
181
+ const binary = path.join(tmp, 'opt', 'unbound-hook', 'unbound-hook');
182
+ writeFile(binary, '#');
183
+ writeFile(path.join(dir, 'managed-settings.d', 'unbound.json'),
184
+ JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(dir, 'hooks', 'unbound.py') }] } }));
185
+ writeFile(path.join(dir, 'managed-settings.json'),
186
+ JSON.stringify({ hooks: { PreToolUse: [{ command: binary }] } }));
187
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': dir }, _binaryPath: binary })
188
+ .find((x) => x.family === 'claude-code');
189
+ assert.equal(t.status, 'managed-by-mdm');
190
+ assert.equal(t.scope, 'mdm');
191
+ });
192
+ });
193
+
194
+ // When no candidate is healthy, the install really is broken and must still say so.
195
+ test('Claude Code: both candidates naming missing hooks is still tampered', () => {
196
+ withHome((tmp, th) => {
197
+ const dir = path.join(tmp, 'mdm', 'ClaudeCode');
198
+ writeFile(path.join(dir, 'managed-settings.d', 'unbound.json'),
199
+ JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(dir, 'hooks', 'unbound.py') }] } }));
200
+ writeFile(path.join(dir, 'managed-settings.json'),
201
+ JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(dir, 'hooks', 'unbound.py') }] } }));
202
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': dir } }).find((x) => x.family === 'claude-code');
203
+ assert.equal(t.status, 'tampered');
204
+ assert.equal(t.scope, 'mdm');
205
+ });
206
+ });
@@ -33,12 +33,14 @@ function withHome(fn) {
33
33
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-doctor-'));
34
34
  const origHome = process.env.HOME;
35
35
  process.env.HOME = tmp;
36
+ delete require.cache[require.resolve('../src/claudePaths')];
36
37
  delete require.cache[require.resolve('../src/toolHealth')];
37
38
  try {
38
39
  return fn(tmp, require('../src/toolHealth'));
39
40
  } finally {
40
41
  if (origHome === undefined) delete process.env.HOME;
41
42
  else process.env.HOME = origHome;
43
+ delete require.cache[require.resolve('../src/claudePaths')];
42
44
  delete require.cache[require.resolve('../src/toolHealth')];
43
45
  fs.rmSync(tmp, { recursive: true, force: true });
44
46
  }
@@ -609,3 +611,186 @@ test('validateToolKeys: missing key (no env var) → tampered without consulting
609
611
  assert.equal(called, false);
610
612
  });
611
613
  });
614
+
615
+ // --- gateway detection identifies OUR key helper, not merely the conventional name.
616
+ // The setup no longer removes someone else's helper, so a foreign one at the same path
617
+ // must not read as a gateway install. ---
618
+ const { isUnboundKeyHelper, keyHelperCheck } = require('../src/toolHealth');
619
+
620
+ test('isUnboundKeyHelper: the value the gateway setup writes', () => {
621
+ assert.equal(isUnboundKeyHelper('~/.claude/anthropic_key.sh'), true);
622
+ });
623
+
624
+ test('isUnboundKeyHelper: the expanded form of that value', () => {
625
+ assert.equal(isUnboundKeyHelper(path.join(os.homedir(), '.claude', 'anthropic_key.sh')), true);
626
+ });
627
+
628
+ test("isUnboundKeyHelper: someone else's helper is not ours", () => {
629
+ assert.equal(isUnboundKeyHelper('~/bin/my_own_key.sh'), false);
630
+ assert.equal(isUnboundKeyHelper('~/.claude/anthropic_key.sh.bak'), false);
631
+ assert.equal(isUnboundKeyHelper(undefined), false);
632
+ });
633
+
634
+ test('isUnboundKeyHelper: an install under another home still counts', () => {
635
+ assert.equal(isUnboundKeyHelper('/Users/someone/.claude/anthropic_key.sh'), true);
636
+ });
637
+
638
+ test('keyHelperCheck: a shebang or trailing newline does not disown a real install', () => {
639
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'unb-helper-'));
640
+ const file = path.join(dir, 'anthropic_key.sh');
641
+ fs.writeFileSync(file, '#!/bin/sh\necho $UNBOUND_API_KEY\n');
642
+ assert.equal(keyHelperCheck('Key helper', file).ok, true);
643
+ });
644
+
645
+ test('keyHelperCheck: the script the gateway wrote is ours', () => {
646
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'unb-helper-'));
647
+ const file = path.join(dir, 'anthropic_key.sh');
648
+ fs.writeFileSync(file, 'echo $UNBOUND_API_KEY');
649
+ assert.equal(keyHelperCheck('Key helper', file).ok, true);
650
+ });
651
+
652
+ test('keyHelperCheck: a helper of the same name holding something else is not ours', () => {
653
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'unb-helper-'));
654
+ const file = path.join(dir, 'anthropic_key.sh');
655
+ fs.writeFileSync(file, 'echo $MY_COMPANY_KEY');
656
+ const check = keyHelperCheck('Key helper', file);
657
+ assert.equal(check.ok, false);
658
+ assert.match(check.detail, /not written by Unbound/);
659
+ });
660
+
661
+ test('keyHelperCheck: a missing helper', () => {
662
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'unb-helper-'));
663
+ assert.equal(keyHelperCheck('Key helper', path.join(dir, 'absent.sh')).ok, false);
664
+ });
665
+
666
+ // The setup no longer strips a customer's own ANTHROPIC_BASE_URL export when it installs
667
+ // or removes ours, so a device can persist both. The gateway check has to find ours among
668
+ // them rather than judge on whichever value is read first.
669
+ function withGatewayInstall(tmp, extra) {
670
+ writeFile(path.join(tmp, '.claude', 'settings.json'),
671
+ JSON.stringify({ apiKeyHelper: '/Users/whatever/.claude/anthropic_key.sh' }));
672
+ writeFile(path.join(tmp, '.claude', 'anthropic_key.sh'), '#!/bin/sh\necho $UNBOUND_API_KEY\n');
673
+ process.env.UNBOUND_API_KEY = 'k';
674
+ extra();
675
+ }
676
+
677
+ test('detectTools: a customer export beside ours in the same rc → healthy', () => {
678
+ withHome((tmp, th) => {
679
+ withGatewayInstall(tmp, () => {
680
+ writeFile(path.join(tmp, '.bashrc'),
681
+ 'export ANTHROPIC_BASE_URL="https://llm.acme-corp.internal"\n'
682
+ + 'export ANTHROPIC_BASE_URL="https://gateway.example.com"\n');
683
+ });
684
+ try {
685
+ const t = th.detectTools({ gatewayUrl: 'https://gateway.example.com' })
686
+ .find((x) => x.family === 'claude-code');
687
+ assert.equal(t.status, 'healthy');
688
+ } finally {
689
+ delete process.env.UNBOUND_API_KEY;
690
+ }
691
+ });
692
+ });
693
+
694
+ test('detectTools: a customer export in an earlier rc file than ours → healthy', () => {
695
+ withHome((tmp, th) => {
696
+ withGatewayInstall(tmp, () => {
697
+ writeFile(path.join(tmp, '.zshrc'), 'export ANTHROPIC_BASE_URL="https://llm.acme-corp.internal"\n');
698
+ writeFile(path.join(tmp, '.bashrc'), 'export ANTHROPIC_BASE_URL="https://gateway.example.com"\n');
699
+ });
700
+ try {
701
+ const t = th.detectTools({ gatewayUrl: 'https://gateway.example.com' })
702
+ .find((x) => x.family === 'claude-code');
703
+ assert.equal(t.status, 'healthy');
704
+ } finally {
705
+ delete process.env.UNBOUND_API_KEY;
706
+ }
707
+ });
708
+ });
709
+
710
+ test('detectTools: only a customer export, none of ours → tampered', () => {
711
+ withHome((tmp, th) => {
712
+ withGatewayInstall(tmp, () => {
713
+ writeFile(path.join(tmp, '.bashrc'), 'export ANTHROPIC_BASE_URL="https://llm.acme-corp.internal"\n');
714
+ });
715
+ try {
716
+ const t = th.detectTools({ gatewayUrl: 'https://gateway.example.com' })
717
+ .find((x) => x.family === 'claude-code');
718
+ assert.equal(t.status, 'tampered');
719
+ } finally {
720
+ delete process.env.UNBOUND_API_KEY;
721
+ }
722
+ });
723
+ });
724
+
725
+ test('rcEnvValues: every export is returned, in file order', () => {
726
+ withHome((tmp, th) => {
727
+ writeFile(path.join(tmp, '.bashrc'),
728
+ 'export ANTHROPIC_BASE_URL="https://one.example.com"\n'
729
+ + 'export PATH=/usr/local/bin:$PATH\n'
730
+ + "export ANTHROPIC_BASE_URL='https://two.example.com'\n");
731
+ assert.deepEqual(th.rcEnvValues('ANTHROPIC_BASE_URL').map((v) => v.value),
732
+ ['https://one.example.com', 'https://two.example.com']);
733
+ });
734
+ });
735
+
736
+ test('rcEnvValues: nothing persisted', () => {
737
+ withHome((tmp, th) => {
738
+ writeFile(path.join(tmp, '.bashrc'), 'export PATH=/usr/local/bin:$PATH\n');
739
+ assert.deepEqual(th.rcEnvValues('ANTHROPIC_BASE_URL'), []);
740
+ });
741
+ });
742
+
743
+ test('detectTools: claude-code hooks under a custom CLAUDE_CONFIG_DIR are detected there', () => {
744
+ withHome((tmp, th) => {
745
+ const ccdir = path.join(tmp, 'custom-cc');
746
+ process.env.CLAUDE_CONFIG_DIR = ccdir;
747
+ process.env.UNBOUND_CLAUDE_API_KEY = 'k';
748
+ try {
749
+ const script = path.join(ccdir, 'hooks', 'unbound.py');
750
+ writeFile(path.join(ccdir, 'settings.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
751
+ writeFile(script, '# unbound');
752
+ const claude = th.detectTools().filter((t) => t.family === 'claude-code');
753
+ assert.equal(claude.length, 1, 'one collapsed claude-code line');
754
+ assert.equal(claude[0].status, 'healthy');
755
+ } finally {
756
+ delete process.env.CLAUDE_CONFIG_DIR;
757
+ delete process.env.UNBOUND_CLAUDE_API_KEY;
758
+ }
759
+ });
760
+ });
761
+
762
+ test('detectTools: with CLAUDE_CONFIG_DIR set, stale hooks in ~/.claude are NOT counted', () => {
763
+ withHome((tmp, th) => {
764
+ process.env.CLAUDE_CONFIG_DIR = path.join(tmp, 'custom-cc');
765
+ process.env.UNBOUND_CLAUDE_API_KEY = 'k';
766
+ try {
767
+ const script = path.join(tmp, '.claude', 'hooks', 'unbound.py');
768
+ writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
769
+ writeFile(script, '# unbound');
770
+ const claude = th.detectTools().find((t) => t.family === 'claude-code');
771
+ assert.equal(claude.status, 'not-installed', 'stale ~/.claude hooks are ignored when the dir is relocated');
772
+ } finally {
773
+ delete process.env.CLAUDE_CONFIG_DIR;
774
+ delete process.env.UNBOUND_CLAUDE_API_KEY;
775
+ }
776
+ });
777
+ });
778
+
779
+ // Under a relocated dir the gateway writes the helper beside that dir, which is
780
+ // not named .claude — so neither the literal nor the tail match applies and the
781
+ // install would otherwise read as someone else's helper.
782
+ test('isUnboundKeyHelper: a helper under a relocated CLAUDE_CONFIG_DIR is still ours', () => {
783
+ const prev = process.env.CLAUDE_CONFIG_DIR;
784
+ process.env.CLAUDE_CONFIG_DIR = path.join(os.tmpdir(), 'relocated-cc');
785
+ delete require.cache[require.resolve('../src/claudePaths')];
786
+ delete require.cache[require.resolve('../src/toolHealth')];
787
+ try {
788
+ const th = require('../src/toolHealth');
789
+ assert.equal(th.isUnboundKeyHelper(path.join(os.tmpdir(), 'relocated-cc', 'anthropic_key.sh')), true);
790
+ assert.equal(th.isUnboundKeyHelper(path.join(os.tmpdir(), 'somewhere-else', 'anthropic_key.sh')), false);
791
+ } finally {
792
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = prev;
793
+ delete require.cache[require.resolve('../src/claudePaths')];
794
+ delete require.cache[require.resolve('../src/toolHealth')];
795
+ }
796
+ });