unbound-cli 1.15.2 → 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/LOCAL_DEV.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/auth.js +29 -2
- package/src/claudePaths.js +82 -0
- package/src/commands/analytics.js +1 -1
- package/src/commands/chat.js +2 -2
- package/src/commands/discover.js +17 -6
- package/src/commands/doctor.js +32 -8
- package/src/commands/download.js +2 -2
- package/src/commands/oacb.js +37 -11
- package/src/commands/onboard.js +34 -39
- package/src/commands/setup.js +62 -5
- package/src/commands/status.js +13 -1
- package/src/device-serial.js +17 -1
- package/src/index.js +3 -3
- package/src/lib/downloads.js +2 -2
- package/src/lib/policy-ai-assist.js +4 -2
- package/src/scheduled.js +3 -6
- package/src/toolHealth.js +115 -40
- package/test/analytics-export-job.test.js +1 -1
- package/test/chat-internals.test.js +1 -1
- package/test/claude-paths.test.js +182 -0
- package/test/discover-exit-code.test.js +16 -1
- package/test/onboard-cron.test.js +13 -11
- package/test/onboard-owner-key.test.js +38 -0
- package/test/onboard-scope.test.js +41 -6
- package/test/setup-args.test.js +44 -1
- package/test/setup-scope.test.js +69 -0
- package/test/tool-health-mdm-paths.test.js +206 -0
- package/test/tool-health.test.js +185 -0
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
|
|
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
|
|
92
|
-
|
|
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
|
|
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
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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: '
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
'
|
|
195
|
-
|
|
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
|
-
|
|
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', '
|
|
262
|
-
scriptCheck('Hook script', '
|
|
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', '
|
|
270
|
-
|
|
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, /
|
|
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 {
|
|
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
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
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
|
|
131
|
+
'per-user onboarding must scan with the user apiKey'
|
|
133
132
|
);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
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
|
|
107
|
-
test('onboard --set-cron with sudo → schedules discovery-only with the
|
|
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, '
|
|
112
|
-
assert.equal(calls.cron.discoveryKey, undefined, 'no separate
|
|
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 () => {
|