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.
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 claudePaths = require('./claudePaths');
20
21
 
21
22
  const HOME = os.homedir();
22
23
  const GATEWAY_DEFAULT = 'https://api.getunbound.ai';
@@ -30,6 +31,13 @@ function expand(p) {
30
31
  return p.startsWith('~') ? path.join(HOME, p.slice(1)) : p;
31
32
  }
32
33
 
34
+ function claudePath(...parts) {
35
+ // The default dir is rendered as a literal ~ so the check labels doctor prints
36
+ // stay stable; expand() turns it back into a real path on read.
37
+ if (!claudePaths.isNonDefaultConfigDir()) return ['~/.claude', ...parts].join('/');
38
+ return path.join(claudePaths.resolveClaudeConfigDir(), ...parts);
39
+ }
40
+
33
41
  function fileExists(p) {
34
42
  try {
35
43
  return fs.statSync(expand(p)).isFile();
@@ -84,17 +92,25 @@ function readEnvVar(name) {
84
92
  return { found: false };
85
93
  }
86
94
 
87
- const re = new RegExp(`^\\s*export\\s+${name}=(.*)$`, 'm');
95
+ const persisted = rcEnvValues(name);
96
+ return persisted.length ? { found: true, ...persisted[0] } : { found: false };
97
+ }
98
+
99
+ // Every `export NAME=...` the rc files hold, in file order. Setup removes only the
100
+ // exports it wrote, so a customer's own endpoint can sit alongside ours in another file
101
+ // or another line -- one value is not enough to tell whether our wiring is intact.
102
+ function rcEnvValues(name) {
103
+ if (process.platform === 'win32') return [];
104
+ const re = new RegExp(`^\\s*export\\s+${name}=(.*)$`, 'gm');
105
+ const values = [];
88
106
  for (const rc of rcFiles()) {
89
107
  const text = readText(rc);
90
108
  if (!text) continue;
91
- const m = text.match(re);
92
- if (m) {
93
- const val = m[1].trim().replace(/^["']|["']$/g, '');
94
- return { found: true, value: val, source: path.basename(expand(rc)) };
109
+ for (const m of text.matchAll(re)) {
110
+ values.push({ value: m[1].trim().replace(/^["']|["']$/g, ''), source: path.basename(expand(rc)) });
95
111
  }
96
112
  }
97
- return { found: false };
113
+ return values;
98
114
  }
99
115
 
100
116
  // --- check builders. Each returns { name, ok, kind, detail, summary, warn? }. ---
@@ -121,6 +137,43 @@ function configCheck(label, file, marker, opts = {}) {
121
137
  return { name: label, ok, kind, detail, summary };
122
138
  }
123
139
 
140
+ // The gateway setup writes exactly this helper, and points apiKeyHelper at exactly this
141
+ // path. Matching on those rather than on the filename keeps someone's own key helper --
142
+ // which the setup no longer removes -- from reading as a gateway install.
143
+ const UNBOUND_KEY_HELPER = '~/.claude/anthropic_key.sh';
144
+ // The helper reads UNBOUND_API_KEY; that reference is what makes it ours. Matched on the
145
+ // token rather than the whole body so a shebang or trailing newline doesn't disown a real
146
+ // install, and matched on the path's tail so an MDM install under another home still counts.
147
+ const UNBOUND_KEY_HELPER_TOKEN = 'UNBOUND_API_KEY';
148
+
149
+ const DEFAULT_KEY_HELPER_TAIL = '/.claude/anthropic_key.sh';
150
+
151
+ function isUnboundKeyHelper(value) {
152
+ if (typeof value !== 'string') return false;
153
+ const v = value.trim();
154
+ if (v === UNBOUND_KEY_HELPER) return true;
155
+ // settings.json spells a Windows path with backslashes, so compare on forward
156
+ // slashes — and through samePath, since Windows ignores case.
157
+ const slashed = v.replace(/\\/g, '/');
158
+ if (claudePaths.samePath(slashed.slice(-DEFAULT_KEY_HELPER_TAIL.length), DEFAULT_KEY_HELPER_TAIL)) return true;
159
+ // A relocated CLAUDE_CONFIG_DIR is not named .claude, so the helper the gateway
160
+ // wrote there matches neither form above — check the active dir explicitly.
161
+ return claudePaths.samePath(path.resolve(v), path.join(claudePaths.resolveClaudeConfigDir(), 'anthropic_key.sh'));
162
+ }
163
+
164
+ function keyHelperCheck(label, file, kind = 'structural') {
165
+ const body = readText(file);
166
+ if (body == null) {
167
+ return { name: label, ok: false, kind, detail: `not found (${file})`, summary: `${label.toLowerCase()} missing` };
168
+ }
169
+ const ok = body.includes(UNBOUND_KEY_HELPER_TOKEN);
170
+ return {
171
+ name: label, ok, kind,
172
+ detail: ok ? file : `present but not written by Unbound (${file})`,
173
+ summary: ok ? `${label.toLowerCase()} missing` : `${label.toLowerCase()} not ours`,
174
+ };
175
+ }
176
+
124
177
  function scriptCheck(label, file, kind = 'structural') {
125
178
  const ok = fileExists(file);
126
179
  return { name: label, ok, kind, detail: ok ? file : `not found (${file})`, summary: `${label.toLowerCase()} missing` };
@@ -154,27 +207,37 @@ function envCheck(label, name, expected, kind = 'aux') {
154
207
  // A value that doesn't match what setup wrote (stale key, wrong gateway URL) is a
155
208
  // real misconfiguration: mark it not-ok so the tool reports tampered, not healthy.
156
209
  if (expected && r.value !== expected) {
157
- // process.env can be a stale snapshot from a shell loaded before setup re-wrote
158
- // the rc, so consult the rc files as a fallback before declaring a mismatch.
159
- if (r.source === 'process env' && process.platform !== 'win32') {
160
- const re = new RegExp(`^\\s*export\\s+${name}=(.*)$`, 'm');
161
- for (const rc of rcFiles()) {
162
- const text = readText(rc);
163
- if (!text) continue;
164
- const m = text.match(re);
165
- if (!m) continue;
166
- const rcVal = m[1].trim().replace(/^["']|["']$/g, '');
167
- if (rcVal === expected) {
168
- const rcSource = path.basename(expand(rc));
169
- return { name: label, ok: true, kind, detail: `${name} set (${rcSource})`, summary: `${base} set` };
170
- }
171
- }
210
+ // Any persisted export matching what setup wrote means the wiring is intact. The live
211
+ // process.env can be a stale snapshot from a shell loaded before setup rewrote the rc,
212
+ // and setup leaves a customer's own export in place beside ours, so the first value
213
+ // read is not the one to judge on.
214
+ const match = rcEnvValues(name).find((v) => v.value === expected);
215
+ if (match) {
216
+ return { name: label, ok: true, kind, detail: `${name} set (${match.source})`, summary: `${base} set` };
172
217
  }
173
218
  return { name: label, ok: false, kind, warn: true, summary: `${base} differs from setup`, detail: `${name} set (${r.source}) but differs from what setup configured` };
174
219
  }
175
220
  return { name: label, ok: true, kind, detail: `${name} set (${r.source})`, summary: `${base} set` };
176
221
  }
177
222
 
223
+ // Machine-wide managed (MDM) directory per tool, mirroring each tool's
224
+ // get_managed_settings_dir in the setup repo. On Windows the root is NOT uniform:
225
+ // Claude Code and Codex install under Program Files, Cursor and Augment under
226
+ // ProgramData. Augment uses /etc/augment on macOS as well as Linux.
227
+ function managedDir(family, platform = process.platform, env = process.env) {
228
+ const mac = '/Library/Application Support';
229
+ const pd = env.ProgramData || 'C:\\ProgramData';
230
+ const pf = env.ProgramFiles || 'C:\\Program Files';
231
+ const dirs = {
232
+ cursor: { darwin: `${mac}/Cursor`, linux: '/etc/cursor', win32: path.join(pd, 'Cursor') },
233
+ 'claude-code': { darwin: `${mac}/ClaudeCode`, linux: '/etc/claude-code', win32: path.join(pf, 'ClaudeCode') },
234
+ codex: { darwin: `${mac}/Codex`, linux: '/etc/codex', win32: path.join(pf, 'Codex') },
235
+ augment: { darwin: '/etc/augment', linux: '/etc/augment', win32: path.join(pd, 'Augment') },
236
+ };
237
+ if (!dirs[family]) return null;
238
+ return dirs[family][platform] || dirs[family].linux;
239
+ }
240
+
178
241
  // MDM (org-wide) install detection. An Unbound MDM install is only real when the
179
242
  // system managed config REFERENCES the Unbound hook AND the hook script exists.
180
243
  // A plain managed-settings.json (Claude Enterprise / generic MDM) does NOT count,
@@ -184,24 +247,36 @@ function mdmDetect(family, dirOverride, binaryPath) {
184
247
  // Only Cursor / Claude Code / Codex / Augment have a managed (MDM) directory.
185
248
  // Copilot and Gemini have none — their org install writes the same per-user
186
249
  // config into every profile, so they're checked exactly like a user-level tool.
187
- const configName = { cursor: 'hooks.json', 'claude-code': 'managed-settings.json', codex: 'managed-settings.json', augment: 'settings.json' };
250
+ const configName = { cursor: 'hooks.json', 'claude-code': 'managed-settings.json', codex: 'hooks.json', augment: 'settings.json' };
188
251
  if (!configName[family]) return { status: null, checks: [] };
189
252
 
190
- const mac = '/Library/Application Support';
191
- const win = process.env.ProgramData || 'C:\\ProgramData';
192
- const dirs = {
193
- cursor: { darwin: `${mac}/Cursor`, linux: '/etc/cursor', win32: path.join(win, 'Cursor') },
194
- 'claude-code': { darwin: `${mac}/ClaudeCode`, linux: '/etc/claude-code', win32: path.join(win, 'ClaudeCode') },
195
- codex: { darwin: `${mac}/Codex`, linux: '/etc/codex', win32: path.join(win, 'Codex') },
196
- // Augment's managed dir is /etc/augment on BOTH macOS and Linux (mirrors the
197
- // setup repo's get_managed_settings_dir), not Library/Application Support.
198
- augment: { darwin: '/etc/augment', linux: '/etc/augment', win32: path.join(win, 'Augment') },
199
- };
200
- const dir = dirOverride || dirs[family][process.platform] || dirs[family].linux;
201
- const configPath = path.join(dir, configName[family]);
253
+ const dir = dirOverride || managedDir(family);
254
+ // Claude Code's MDM install prefers the managed-settings.d drop-in and only writes the
255
+ // flat file as a fallback, so a drop-in-only device must still register as managed.
256
+ const candidates = family === 'claude-code'
257
+ ? [path.join(dir, 'managed-settings.d', 'unbound.json'), path.join(dir, configName[family])]
258
+ : [path.join(dir, configName[family])];
202
259
  const scriptPath = path.join(dir, 'hooks', 'unbound.py');
203
260
 
204
- const cfgText = readText(configPath);
261
+ // Candidates can disagree: a drop-in left by an earlier install may still name a hook
262
+ // that is gone while the flat file beside it names a working one. Rank by how complete
263
+ // each install looks so the healthiest candidate decides the verdict, rather than
264
+ // whichever happens to be read first.
265
+ const completeness = (text) => {
266
+ const binary = text.includes(BINARY_MARKER);
267
+ if (!binary && !text.includes('unbound.py')) return 0;
268
+ return fileExists(binary ? (binaryPath || BINARY_PATH) : scriptPath) ? 2 : 1;
269
+ };
270
+ let configPath = null;
271
+ let cfgText = null;
272
+ let best = -1;
273
+ for (const candidate of candidates) {
274
+ const text = readText(candidate);
275
+ if (text == null) continue;
276
+ const score = completeness(text);
277
+ if (score > best) { best = score; configPath = candidate; cfgText = text; }
278
+ if (best === 2) break;
279
+ }
205
280
  if (cfgText == null) return { status: null, checks: [] };
206
281
  const hasBinary = cfgText.includes(BINARY_MARKER);
207
282
  const hasPython = cfgText.includes('unbound.py');
@@ -258,16 +333,16 @@ function buildVariants(gatewayUrl, binaryPath) {
258
333
  {
259
334
  key: 'claude-code-subscription', label: 'Claude Code (subscription)', family: 'claude-code', mode: 'subscription',
260
335
  checks: () => [
261
- configCheck('Config', '~/.claude/settings.json', { json: true, test: refsUnbound }),
262
- scriptCheck('Hook script', '~/.claude/hooks/unbound.py'),
336
+ configCheck('Config', claudePath('settings.json'), { json: true, test: refsUnbound }),
337
+ scriptCheck('Hook script', claudePath('hooks', 'unbound.py')),
263
338
  envKeyCheck('API key env', 'UNBOUND_CLAUDE_API_KEY'),
264
339
  ],
265
340
  },
266
341
  {
267
342
  key: 'claude-code-gateway', label: 'Claude Code (gateway)', family: 'claude-code', mode: 'gateway',
268
343
  checks: () => [
269
- configCheck('Config', '~/.claude/settings.json', { json: true, test: (j) => typeof j.apiKeyHelper === 'string' && j.apiKeyHelper.includes('anthropic_key.sh') }),
270
- scriptCheck('Key helper', '~/.claude/anthropic_key.sh'),
344
+ configCheck('Config', claudePath('settings.json'), { json: true, test: (j) => isUnboundKeyHelper(j.apiKeyHelper) }),
345
+ keyHelperCheck('Key helper', claudePath('anthropic_key.sh')),
271
346
  envKeyCheck('API key env', 'UNBOUND_API_KEY'),
272
347
  // Gateway URL is a real equality check — the hook must point at the
273
348
  // configured backend; tenants don't generate different gateway URLs.
@@ -458,4 +533,4 @@ function countValidationSkipped(tools) {
458
533
  return n;
459
534
  }
460
535
 
461
- module.exports = { detectTools, statusOf, readEnvVar, validateToolKeys, countValidationSkipped, GATEWAY_DEFAULT };
536
+ module.exports = { detectTools, managedDir, statusOf, readEnvVar, rcEnvValues, validateToolKeys, countValidationSkipped, GATEWAY_DEFAULT, isUnboundKeyHelper, keyHelperCheck };
@@ -126,7 +126,7 @@ test('a backend still on the streaming export (405) says so instead of "not foun
126
126
 
127
127
  test('describeError works with no dataset, for `download get`', () => {
128
128
  const message = describeError(new api.ApiError(403, {}));
129
- assert.match(message, /Admin role required/);
129
+ assert.match(message, /not available to your role/);
130
130
  });
131
131
 
132
132
  // A 404 means different things on the two paths, and saying the wrong one sends
@@ -131,7 +131,7 @@ test('normalizeAssistantTurn: caps content length', () => {
131
131
  });
132
132
 
133
133
  test('friendlyChatError: maps status codes to role-specific guidance', () => {
134
- assert.match(friendlyChatError({ statusCode: 403 }), /Admin or Manager/);
134
+ assert.match(friendlyChatError({ statusCode: 403 }), /Admin, Analytics Viewer or Manager/);
135
135
  assert.match(friendlyChatError({ statusCode: 401 }), /login/);
136
136
  assert.match(friendlyChatError({ statusCode: 429 }), /Rate limited/);
137
137
  assert.equal(friendlyChatError({ statusCode: 500, message: 'boom' }), 'boom');
@@ -0,0 +1,182 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const os = require('os');
4
+ const path = require('path');
5
+
6
+ function freshModule() {
7
+ delete require.cache[require.resolve('../src/claudePaths')];
8
+ return require('../src/claudePaths');
9
+ }
10
+
11
+ function withEnv(value, fn) {
12
+ const prev = process.env.CLAUDE_CONFIG_DIR;
13
+ if (value === undefined) delete process.env.CLAUDE_CONFIG_DIR;
14
+ else process.env.CLAUDE_CONFIG_DIR = value;
15
+ try { fn(freshModule()); }
16
+ finally {
17
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
18
+ else process.env.CLAUDE_CONFIG_DIR = prev;
19
+ }
20
+ }
21
+
22
+ const DEFAULT_DIR = path.join(os.homedir(), '.claude');
23
+
24
+ test('resolveClaudeConfigDir: env set absolute is returned absolutized', () => {
25
+ withEnv('/opt/cc', (cp) => assert.equal(cp.resolveClaudeConfigDir(), path.resolve('/opt/cc')));
26
+ });
27
+
28
+ test('resolveClaudeConfigDir: env relative is absolutized', () => {
29
+ withEnv('rel/cc', (cp) => assert.equal(cp.resolveClaudeConfigDir(), path.resolve('rel/cc')));
30
+ });
31
+
32
+ test('resolveClaudeConfigDir: env unset falls back to ~/.claude', () => {
33
+ withEnv(undefined, (cp) => assert.equal(cp.resolveClaudeConfigDir(), DEFAULT_DIR));
34
+ });
35
+
36
+ test('resolveClaudeConfigDir: empty/whitespace env falls back to ~/.claude', () => {
37
+ withEnv(' ', (cp) => assert.equal(cp.resolveClaudeConfigDir(), DEFAULT_DIR));
38
+ });
39
+
40
+ // Claude Code creates a literal "~" directory rather than expanding it, so the
41
+ // tilde must stay literal here too or the hooks land where Claude never looks.
42
+ test('resolveClaudeConfigDir: a leading ~ stays literal, as Claude Code treats it', () => {
43
+ withEnv('~/cc', (cp) => assert.equal(cp.resolveClaudeConfigDir(), path.resolve('~/cc')));
44
+ withEnv('~/cc', (cp) => assert.notEqual(cp.resolveClaudeConfigDir(), path.join(os.homedir(), 'cc')));
45
+ });
46
+
47
+ test('isNonDefaultConfigDir: true when env points elsewhere, false otherwise', () => {
48
+ withEnv('/opt/cc', (cp) => assert.equal(cp.isNonDefaultConfigDir(), true));
49
+ withEnv(undefined, (cp) => assert.equal(cp.isNonDefaultConfigDir(), false));
50
+ });
51
+
52
+ test('claudeHooksDir / claudeSettingsPath derive from the resolved dir', () => {
53
+ withEnv('/opt/cc', (cp) => {
54
+ assert.equal(cp.claudeHooksDir(), path.join('/opt/cc', 'hooks'));
55
+ assert.equal(cp.claudeSettingsPath(), path.join('/opt/cc', 'settings.json'));
56
+ });
57
+ });
58
+
59
+ test('detectConfigDirMismatch: null when default config dir', () => {
60
+ withEnv(undefined, (cp) => {
61
+ const fs = { existsSync: () => true };
62
+ assert.equal(cp.detectConfigDirMismatch([path.join('hooks', 'unbound.py')], { fs }), null);
63
+ });
64
+ });
65
+
66
+ test('detectConfigDirMismatch: object when required hook only in ~/.claude and env set', () => {
67
+ withEnv('/opt/cc', (cp) => {
68
+ const staleHook = path.join(DEFAULT_DIR, 'hooks', 'unbound.py');
69
+ const fs = { existsSync: (p) => p === staleHook };
70
+ assert.deepEqual(
71
+ cp.detectConfigDirMismatch([path.join('hooks', 'unbound.py')], { fs }),
72
+ { activeDir: path.resolve('/opt/cc'), staleDir: DEFAULT_DIR }
73
+ );
74
+ });
75
+ });
76
+
77
+ test('detectConfigDirMismatch: keys on the required hook, ignoring strays in active dir', () => {
78
+ withEnv('/opt/cc', (cp) => {
79
+ const activeStray = path.join('/opt/cc', 'hooks', 'oacb-enforce.sh');
80
+ const staleHook = path.join(DEFAULT_DIR, 'hooks', 'unbound.py');
81
+ const fs = { existsSync: (p) => p === activeStray || p === staleHook };
82
+ assert.deepEqual(
83
+ cp.detectConfigDirMismatch([path.join('hooks', 'unbound.py')], { fs }),
84
+ { activeDir: path.resolve('/opt/cc'), staleDir: DEFAULT_DIR }
85
+ );
86
+ });
87
+ });
88
+
89
+ test('detectConfigDirMismatch: default markers cover the hooks install', () => {
90
+ withEnv('/opt/cc', (cp) => {
91
+ const staleHook = path.join(DEFAULT_DIR, 'hooks', 'unbound.py');
92
+ const fs = { existsSync: (p) => p === staleHook };
93
+ assert.deepEqual(
94
+ cp.detectConfigDirMismatch(undefined, { fs }),
95
+ { activeDir: path.resolve('/opt/cc'), staleDir: DEFAULT_DIR }
96
+ );
97
+ });
98
+ });
99
+
100
+ test('detectConfigDirMismatch: null when hook present under active dir', () => {
101
+ withEnv('/opt/cc', (cp) => {
102
+ const fs = { existsSync: () => true };
103
+ assert.equal(cp.detectConfigDirMismatch([path.join('hooks', 'unbound.py')], { fs }), null);
104
+ });
105
+ });
106
+
107
+ test('detectConfigDirMismatch: null when hook absent everywhere', () => {
108
+ withEnv('/opt/cc', (cp) => {
109
+ const fs = { existsSync: () => false };
110
+ assert.equal(cp.detectConfigDirMismatch([path.join('hooks', 'unbound.py')], { fs }), null);
111
+ });
112
+ });
113
+
114
+ test('detectConfigDirMismatch: gateway-mode stale install (anthropic_key.sh) is detected by default markers', () => {
115
+ withEnv('/opt/cc', (cp) => {
116
+ const staleKeyHelper = path.join(DEFAULT_DIR, 'anthropic_key.sh');
117
+ const fs = { existsSync: (p) => p === staleKeyHelper };
118
+ assert.deepEqual(
119
+ cp.detectConfigDirMismatch(undefined, { fs }),
120
+ { activeDir: path.resolve('/opt/cc'), staleDir: DEFAULT_DIR }
121
+ );
122
+ });
123
+ });
124
+
125
+ test('detectConfigDirMismatch: gateway-mode present under active dir → null (default markers)', () => {
126
+ withEnv('/opt/cc', (cp) => {
127
+ const activeKeyHelper = path.join(path.resolve('/opt/cc'), 'anthropic_key.sh');
128
+ const fs = { existsSync: (p) => p === activeKeyHelper };
129
+ assert.equal(cp.detectConfigDirMismatch(undefined, { fs }), null);
130
+ });
131
+ });
132
+
133
+ test('displayPath: shortens a home-relative path and leaves others alone', () => {
134
+ const cp = freshModule();
135
+ assert.equal(cp.displayPath(path.join(os.homedir(), '.claude', 'settings.json')), '~/.claude/settings.json'.split('/').join(path.sep));
136
+ assert.equal(cp.displayPath(os.homedir()), '~');
137
+ assert.equal(cp.displayPath('/opt/cc/settings.json'), '/opt/cc/settings.json');
138
+ });
139
+
140
+ // A sibling dir that merely starts with the same characters is not under home.
141
+ test('displayPath: does not shorten a sibling whose name shares the home prefix', () => {
142
+ const cp = freshModule();
143
+ const sibling = os.homedir() + '-backup/settings.json';
144
+ assert.equal(cp.displayPath(sibling), sibling);
145
+ });
146
+
147
+ // Claude Code resolves a set-but-empty CLAUDE_CONFIG_DIR against the cwd rather
148
+ // than falling back to ~/.claude, so an install in the default dir is one it
149
+ // never reads. We keep the safe fallback and flag the state instead.
150
+ test('isBlankConfigDir: true only when the var is set and empty', () => {
151
+ withEnv(' ', (cp) => assert.equal(cp.isBlankConfigDir(), true));
152
+ withEnv('', (cp) => assert.equal(cp.isBlankConfigDir(), true));
153
+ withEnv(undefined, (cp) => assert.equal(cp.isBlankConfigDir(), false));
154
+ withEnv('/opt/cc', (cp) => assert.equal(cp.isBlankConfigDir(), false));
155
+ });
156
+
157
+ test('a blank dir still resolves to ~/.claude rather than the cwd', () => {
158
+ withEnv('', (cp) => assert.equal(cp.resolveClaudeConfigDir(), DEFAULT_DIR));
159
+ withEnv(' ', (cp) => assert.equal(cp.resolveClaudeConfigDir(), DEFAULT_DIR));
160
+ });
161
+
162
+ // Verified against real Windows path semantics (path.win32): resolve preserves
163
+ // the case it was given, so a differently-cased spelling of the default dir
164
+ // would otherwise read as relocated.
165
+ test('samePath: a differently-cased default dir is not treated as relocated on Windows', function () {
166
+ if (process.platform !== 'win32') return; // POSIX paths are case-sensitive; nothing to assert
167
+ const prev = process.env.CLAUDE_CONFIG_DIR;
168
+ process.env.CLAUDE_CONFIG_DIR = DEFAULT_DIR.toLowerCase();
169
+ try {
170
+ const cp = freshModule();
171
+ assert.equal(cp.isNonDefaultConfigDir(), false);
172
+ assert.equal(cp.detectConfigDirMismatch(undefined, { fs: { existsSync: () => true } }), null);
173
+ } finally {
174
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = prev;
175
+ }
176
+ });
177
+
178
+ // The guard must not fire on a sibling that merely shares the home prefix.
179
+ test('displayPath: still leaves a non-home path alone', () => {
180
+ const cp = freshModule();
181
+ assert.equal(cp.displayPath('/opt/cc/settings.json'), '/opt/cc/settings.json');
182
+ });
@@ -1,6 +1,21 @@
1
1
  const { test } = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
- const { classifyDiscoveryExit } = require('../src/commands/discover');
3
+ const { Command } = require('commander');
4
+ const { classifyDiscoveryExit, register } = require('../src/commands/discover');
5
+
6
+ // Existing crons can still pass a discovery key. With strict option parsing the
7
+ // CLI would reject an unregistered flag, so discover keeps --discovery-key as an
8
+ // accepted-but-ignored option — the scan runs off the API key. It must never throw.
9
+ test('discover accepts --discovery-key so stale crons are ignored, not rejected', () => {
10
+ const program = new Command();
11
+ register(program);
12
+ const discover = program.commands.find((c) => c.name() === 'discover');
13
+ assert.ok(discover, 'discover command is registered');
14
+ assert.ok(
15
+ discover.options.some((o) => o.long === '--discovery-key'),
16
+ 'discover must register --discovery-key (accept-but-ignore)',
17
+ );
18
+ });
4
19
 
5
20
  test('classifyDiscoveryExit: 0 is a successful scan', () => {
6
21
  assert.equal(classifyDiscoveryExit(0), 'success');
@@ -118,25 +118,27 @@ test('per-user onboard cron passes apiKey but NOT discoveryKey to setupScheduled
118
118
  assert.ok(!callArgs.toLowerCase().includes('backfill'), 'setupScheduledRun call must not include backfill');
119
119
  });
120
120
 
121
- // WEB-4891 core fix: the per-user discovery scan must run with the user's own
122
- // API key so the backend attributes the device to them at ingestion. Only the
123
- // MDM (sudo) branch — which scans every user and cannot be attributed from one
124
- // user's key — may scan with the org discovery key.
125
- test('per-user discovery scans with the user key; only MDM uses the discovery key', () => {
121
+ // The per-user discovery scan runs with the user's own API
122
+ // key; the MDM (sudo) branch resolves the device-owner key from the serial and
123
+ // scans with THAT. Neither scan uses a separate org discovery key.
124
+ test('per-user scans with the user key; MDM scans with the resolved device-owner key; no discovery key', () => {
126
125
  const userBranchStart = onboardSrc.indexOf('const apiKey = config.getApiKey();');
127
126
  assert.notEqual(userBranchStart, -1, 'expected the per-user branch in onboard.js');
128
127
  const userBranch = onboardSrc.slice(userBranchStart);
129
128
  assert.match(
130
129
  userBranch,
131
130
  /runDiscoveryScan\(\{\s*apiKey\s*,/,
132
- 'per-user onboarding must scan with the user apiKey, not the org discovery key'
131
+ 'per-user onboarding must scan with the user apiKey'
133
132
  );
134
- const discoveryKeyScans = (onboardSrc.match(/runDiscoveryScan\(\{\s*apiKey:\s*discoveryKeyOpt/g) || []).length;
135
- assert.equal(
136
- discoveryKeyScans,
137
- 1,
138
- 'exactly one runDiscoveryScan (the MDM branch) may use the org discovery key'
133
+ // MDM branch scans with the serial-resolved owner key (mdmScanKey), never the
134
+ // (now deprecated/ignored) discovery key.
135
+ assert.match(
136
+ onboardSrc,
137
+ /runDiscoveryScan\(\{\s*apiKey:\s*mdmScanKey/,
138
+ 'MDM onboarding must scan with the resolved device-owner key'
139
139
  );
140
+ const discoveryKeyScans = (onboardSrc.match(/runDiscoveryScan\(\{\s*apiKey:\s*discoveryKeyOpt/g) || []).length;
141
+ assert.equal(discoveryKeyScans, 0, 'no discovery scan may use the org discovery key');
140
142
  });
141
143
 
142
144
  test('onboard imports setupScheduledRun from ../scheduled', () => {
@@ -0,0 +1,38 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ // The owner key is minted from the hardware serial, which must come from a live
5
+ // probe, not the user-writable cache — a planted value would mint the wrong
6
+ // user's key.
7
+ function freshAuth(mock) {
8
+ for (const m of ['../src/auth', '../src/api', '../src/device-serial']) {
9
+ delete require.cache[require.resolve(m)];
10
+ }
11
+ const api = require('../src/api');
12
+ const deviceSerial = require('../src/device-serial');
13
+ mock({ api, deviceSerial });
14
+ return require('../src/auth');
15
+ }
16
+
17
+ test('fetchDeviceOwnerKey mints from the LIVE serial, never the plantable cache', async () => {
18
+ let sentQuery;
19
+ const auth = freshAuth(({ api, deviceSerial }) => {
20
+ deviceSerial.getDeviceSerial = async () => 'PLANTED-COWORKER-SERIAL'; // cache
21
+ deviceSerial.probeDeviceSerialLive = async () => 'REAL-HW-SERIAL'; // live probe
22
+ api.get = async (_path, opts) => { sentQuery = opts.query; return { api_key: 'owner-key' }; };
23
+ });
24
+ const key = await auth.fetchDeviceOwnerKey('admin-key', { baseUrl: 'https://b' });
25
+ assert.equal(key, 'owner-key');
26
+ assert.equal(sentQuery.serial_number, 'REAL-HW-SERIAL', 'must use the live hardware serial');
27
+ assert.notEqual(sentQuery.serial_number, 'PLANTED-COWORKER-SERIAL', 'must NOT trust the cache');
28
+ });
29
+
30
+ test('fetchDeviceOwnerKey refuses to mint when no live serial can be read', async () => {
31
+ let called = false;
32
+ const auth = freshAuth(({ api, deviceSerial }) => {
33
+ deviceSerial.probeDeviceSerialLive = async () => null;
34
+ api.get = async () => { called = true; return { api_key: 'x' }; };
35
+ });
36
+ await assert.rejects(() => auth.fetchDeviceOwnerKey('admin-key', {}), /hardware serial/);
37
+ assert.equal(called, false, 'must not hit the mint endpoint without a live serial');
38
+ });
@@ -35,7 +35,7 @@ test('hasRootPrivileges: false when effective uid is not 0 (no sudo)', () => {
35
35
  // ---- 2. onboard routes by detected scope ----
36
36
  // Reloads the module graph with stubbed deps so we can observe which bundle
37
37
  // helper the single `onboard` command invokes.
38
- async function runOnboard(isRoot, { setCron = false, discoveryKey = 'd' } = {}) {
38
+ async function runOnboard(isRoot, { setCron = false, discoveryKey = 'd', domain, ownerKeyThrows = false } = {}) {
39
39
  for (const m of [
40
40
  '../src/commands/onboard',
41
41
  '../src/commands/setup',
@@ -61,6 +61,13 @@ async function runOnboard(isRoot, { setCron = false, discoveryKey = 'd' } = {})
61
61
  setup.runSetupAllBundle = async () => { calls.user++; return { ok: true, skipped: [] }; };
62
62
  discover.runDiscoveryScan = async (o) => { calls.discovery++; calls.discoveryArgs = o; };
63
63
  auth.ensureLoggedIn = async () => { calls.loggedIn++; };
64
+ // MDM scope resolves the device-owner key from the serial and scans with it.
65
+ auth.fetchDeviceOwnerKey = async (adminKey, opts) => {
66
+ calls.ownerFetchedFrom = adminKey;
67
+ calls.ownerBaseUrl = opts && opts.baseUrl;
68
+ if (ownerKeyThrows) throw new Error('owner key resolution failed');
69
+ return 'owner-key';
70
+ };
64
71
  scheduled.setupScheduledRun = async (o) => { calls.cron = o; };
65
72
  config.setUrls = () => ({});
66
73
  config.getApiKey = () => 'resolved-key';
@@ -77,8 +84,13 @@ async function runOnboard(isRoot, { setCron = false, discoveryKey = 'd' } = {})
77
84
  register(program);
78
85
  const argv = ['node', 'unbound', 'onboard', '--api-key', 'k'];
79
86
  if (discoveryKey) argv.push('--discovery-key', discoveryKey);
87
+ if (domain) argv.push('--domain', domain);
80
88
  if (setCron) argv.push('--set-cron');
89
+ const prevExit = process.exitCode;
90
+ process.exitCode = 0;
81
91
  await program.parseAsync(argv);
92
+ calls.exitCode = process.exitCode || 0;
93
+ process.exitCode = prevExit;
82
94
  return calls;
83
95
  }
84
96
 
@@ -88,7 +100,30 @@ test('onboard with sudo/root → installs the MDM bundle (all users), no login',
88
100
  assert.equal(calls.user, 0, 'user bundle NOT installed');
89
101
  assert.equal(calls.loggedIn, 0, 'MDM scope does not run ensureLoggedIn');
90
102
  assert.equal(calls.discovery, 1, 'discovery still runs');
91
- assert.equal(calls.discoveryArgs.apiKey, 'd', 'MDM scope scans with the org discovery key');
103
+ assert.equal(calls.ownerFetchedFrom, 'k', 'MDM scope resolves the owner key from the admin key');
104
+ assert.equal(calls.discoveryArgs.apiKey, 'owner-key', 'MDM scope scans with the device-owner key resolved from the serial');
105
+ });
106
+
107
+ test('onboard MDM with a scheme-less --domain → normalized to an absolute URL for the owner lookup and scan', async () => {
108
+ const calls = await runOnboard(true, { domain: 'tenant.example.com' });
109
+ assert.equal(calls.ownerBaseUrl, 'https://tenant.example.com', 'owner lookup gets an absolute base URL');
110
+ assert.equal(calls.discoveryArgs.domain, 'https://tenant.example.com', 'scan reports to the same absolute URL');
111
+ });
112
+
113
+ test('onboard MDM: an owner-key failure aborts BEFORE the setup bundle (fail-fast, no partial onboard)', async () => {
114
+ const calls = await runOnboard(true, { ownerKeyThrows: true });
115
+ assert.equal(calls.mdm, 0, 'the MDM setup bundle must NOT install when the owner key cannot be resolved');
116
+ assert.equal(calls.discovery, 0, 'discovery must not run');
117
+ assert.equal(calls.exitCode, 1, 'onboard exits non-zero');
118
+ });
119
+
120
+ test('onboard MDM WITH --discovery-key → still resolves the owner key, warns that the flag is ignored', async () => {
121
+ const calls = await runOnboard(true, { discoveryKey: 'stale' });
122
+ assert.equal(calls.ownerFetchedFrom, 'k', 'MDM still resolves the owner key from the admin key');
123
+ assert.ok(
124
+ calls.warns.some((w) => /discovery-key is deprecated and ignored/.test(w)),
125
+ 'warns that --discovery-key is ignored',
126
+ );
92
127
  });
93
128
 
94
129
  test('onboard without sudo → logs in and installs the user bundle', async () => {
@@ -103,13 +138,13 @@ test('onboard without sudo → logs in and installs the user bundle', async () =
103
138
  });
104
139
 
105
140
  // --set-cron under sudo schedules discovery only (not a daily full MDM
106
- // re-install) and stores the discovery key, never the admin key.
107
- test('onboard --set-cron with sudo → schedules discovery-only with the discovery key', async () => {
141
+ // re-install) and stores the device-owner key, never the admin key.
142
+ test('onboard --set-cron with sudo → schedules discovery-only with the device-owner key', async () => {
108
143
  const calls = await runOnboard(true, { setCron: true });
109
144
  assert.ok(calls.cron, 'a scheduled run was set up');
110
145
  assert.equal(calls.cron.command, 'discover', 'schedules discover, not full onboard');
111
- assert.equal(calls.cron.apiKey, 'd', 'uses the discovery key, not the admin key');
112
- assert.equal(calls.cron.discoveryKey, undefined, 'no separate admin/discovery key passed');
146
+ assert.equal(calls.cron.apiKey, 'owner-key', 'uses the resolved device-owner key, not the admin key');
147
+ assert.equal(calls.cron.discoveryKey, undefined, 'no separate discovery key passed');
113
148
  });
114
149
 
115
150
  test('onboard --set-cron without sudo → schedules the full onboard run', async () => {