unbound-cli 1.6.5 → 1.7.1
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 +2 -3
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/api.js +20 -0
- package/src/commands/discover.js +3 -1
- package/src/commands/doctor.js +17 -4
- package/src/commands/onboard.js +40 -17
- package/src/commands/status.js +18 -3
- package/src/index.js +1 -1
- package/src/toolHealth.js +112 -11
- package/test/api-validate-key.test.js +107 -0
- package/test/onboard-cron.test.js +25 -2
- package/test/onboard-scope.test.js +41 -6
- package/test/tool-health.test.js +237 -11
package/LOCAL_DEV.md
CHANGED
|
@@ -83,7 +83,7 @@ node src/index.js setup --backend-url http://localhost:8000 --frontend-url http:
|
|
|
83
83
|
sudo node src/index.js setup --api-key <ADMIN_KEY> --all --backend-url http://localhost:8000 --frontend-url http://localhost:3000
|
|
84
84
|
|
|
85
85
|
# Onboarding (combined setup + discover)
|
|
86
|
-
node src/index.js onboard --api-key <USER_KEY>
|
|
86
|
+
node src/index.js onboard --api-key <USER_KEY> \
|
|
87
87
|
--backend-url http://localhost:8000 --frontend-url http://localhost:3000
|
|
88
88
|
sudo node src/index.js onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> \
|
|
89
89
|
--backend-url http://localhost:8000 --frontend-url http://localhost:3000
|
|
@@ -183,8 +183,7 @@ node src/index.js setup --all
|
|
|
183
183
|
node src/index.js setup --all --api-key <key>
|
|
184
184
|
|
|
185
185
|
# Onboarding (one command: login + setup --all + discover)
|
|
186
|
-
node src/index.js onboard --api-key <USER_KEY>
|
|
187
|
-
sudo node src/index.js onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
186
|
+
node src/index.js onboard --api-key <USER_KEY>
|
|
188
187
|
|
|
189
188
|
# MDM onboarding (admin device enrollment, requires root — MDM scope auto-detected from sudo)
|
|
190
189
|
sudo node src/index.js onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
|
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ For new users, the fastest path from install to a fully-configured device is the
|
|
|
37
37
|
```bash
|
|
38
38
|
# End user
|
|
39
39
|
npm install -g unbound-cli
|
|
40
|
-
unbound onboard --api-key <USER_KEY>
|
|
40
|
+
unbound onboard --api-key <USER_KEY>
|
|
41
41
|
|
|
42
42
|
# Admin enrolling a device via MDM (requires root)
|
|
43
43
|
sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -141,6 +141,25 @@ function getRaw(url) {
|
|
|
141
141
|
});
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// WEB-4949: Probe whether a key is tenant-valid by hitting the same endpoint
|
|
145
|
+
// `unbound login` uses. 200 -> valid, 401/403 -> invalid, anything else
|
|
146
|
+
// (timeout, DNS, 5xx) -> unknown. Callers in status/doctor fail open on
|
|
147
|
+
// 'unknown' so an offline laptop doesn't false-tamper. Short default timeout
|
|
148
|
+
// (3s) keeps `unbound status` snappy even when N keys are validated in
|
|
149
|
+
// parallel against a slow link.
|
|
150
|
+
async function validateApiKey(apiKey, { baseUrl, timeoutMs = 3000 } = {}) {
|
|
151
|
+
if (!apiKey) return 'invalid';
|
|
152
|
+
try {
|
|
153
|
+
await request('GET', '/api/v1/users/privileges/', { apiKey, baseUrl, timeoutMs });
|
|
154
|
+
return 'valid';
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (err instanceof ApiError && (err.statusCode === 401 || err.statusCode === 403)) {
|
|
157
|
+
return 'invalid';
|
|
158
|
+
}
|
|
159
|
+
return 'unknown';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
144
163
|
module.exports = {
|
|
145
164
|
ApiError,
|
|
146
165
|
get: (path, opts) => request('GET', path, opts),
|
|
@@ -148,4 +167,5 @@ module.exports = {
|
|
|
148
167
|
put: (path, opts) => request('PUT', path, opts),
|
|
149
168
|
del: (path, opts) => request('DELETE', path, opts),
|
|
150
169
|
getRaw,
|
|
170
|
+
validateApiKey,
|
|
151
171
|
};
|
package/src/commands/discover.js
CHANGED
|
@@ -199,7 +199,9 @@ Scans this device for installed AI coding tools (Cursor, Claude Code,
|
|
|
199
199
|
Gemini CLI, Codex, Windsurf, Roo Code, Cline, GitHub Copilot, JetBrains,
|
|
200
200
|
and more) and reports findings to the Unbound backend.
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
Which --api-key to use depends on scope:
|
|
203
|
+
- Single-user scan (no sudo): use your own user API key.
|
|
204
|
+
- All-users scan (sudo): use the org discovery key.
|
|
203
205
|
The --domain defaults to the backend URL configured via "unbound config set-backend-url"
|
|
204
206
|
(falls back to https://backend.getunbound.ai when unset).
|
|
205
207
|
|
package/src/commands/doctor.js
CHANGED
|
@@ -3,7 +3,7 @@ const config = require('../config');
|
|
|
3
3
|
const api = require('../api');
|
|
4
4
|
const output = require('../output');
|
|
5
5
|
const { getDeviceSerial } = require('../device-serial');
|
|
6
|
-
const { detectTools } = require('../toolHealth');
|
|
6
|
+
const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
|
|
7
7
|
const { hasRootPrivileges } = require('./setup');
|
|
8
8
|
|
|
9
9
|
// Validate the stored API key against the backend. Returns one of:
|
|
@@ -61,12 +61,16 @@ Examples:
|
|
|
61
61
|
.option('--json', 'Output raw JSON')
|
|
62
62
|
.action(async (opts) => {
|
|
63
63
|
try {
|
|
64
|
-
const apiKey = config.getApiKey();
|
|
65
64
|
const gatewayUrl = config.getGatewayUrl();
|
|
65
|
+
const baseUrl = config.getBaseUrl();
|
|
66
66
|
|
|
67
67
|
const spin = output.spinner('Running diagnostics...');
|
|
68
68
|
const key = await checkApiKey();
|
|
69
|
-
const tools = detectTools({ gatewayUrl
|
|
69
|
+
const tools = detectTools({ gatewayUrl });
|
|
70
|
+
// WEB-4949: per-tool API keys are validated against the gateway in
|
|
71
|
+
// parallel (fail-open on network errors).
|
|
72
|
+
await validateToolKeys(tools, (k) => api.validateApiKey(k, { baseUrl }));
|
|
73
|
+
const keySkippedCount = countValidationSkipped(tools);
|
|
70
74
|
spin.stop();
|
|
71
75
|
|
|
72
76
|
const tampered = tools.filter((t) => t.status === 'tampered');
|
|
@@ -81,9 +85,13 @@ Examples:
|
|
|
81
85
|
mode: t.mode,
|
|
82
86
|
status: t.status,
|
|
83
87
|
conflict: !!t.conflict,
|
|
84
|
-
checks: t.checks.map((c) => ({
|
|
88
|
+
checks: t.checks.map((c) => ({
|
|
89
|
+
name: c.name, ok: c.ok, kind: c.kind, detail: c.detail,
|
|
90
|
+
warn: !!c.warn, validation_skipped: !!c.validationSkipped,
|
|
91
|
+
})),
|
|
85
92
|
})),
|
|
86
93
|
healthy: !unhealthy,
|
|
94
|
+
key_validations_skipped: keySkippedCount,
|
|
87
95
|
});
|
|
88
96
|
if (unhealthy) process.exitCode = 1;
|
|
89
97
|
return;
|
|
@@ -116,6 +124,11 @@ Examples:
|
|
|
116
124
|
} else s = C.dim('○ Not set up');
|
|
117
125
|
console.log(` ${labelOf(t).padEnd(W)} ${s}`);
|
|
118
126
|
}
|
|
127
|
+
// WEB-4949: surface fail-open so CI/operators don't read an offline
|
|
128
|
+
// run as "everything verified". Mirrors the unverified-key-state line.
|
|
129
|
+
if (keySkippedCount > 0) {
|
|
130
|
+
console.log(` ${C.dim(`Note: ${keySkippedCount} per-tool API key validation${keySkippedCount === 1 ? '' : 's'} skipped (gateway unreachable).`)}`);
|
|
131
|
+
}
|
|
119
132
|
console.log('');
|
|
120
133
|
|
|
121
134
|
// Fix routing. User-level tools are fixable by anyone (`unbound doctor
|
package/src/commands/onboard.js
CHANGED
|
@@ -27,7 +27,7 @@ function register(program) {
|
|
|
27
27
|
)
|
|
28
28
|
.option('--api-key <key>', 'API key (user key, or admin key when run with sudo). Falls back to UNBOUND_API_KEY or a stored `unbound login` key)')
|
|
29
29
|
.addOption(new Option('--admin-api-key <key>', 'Alias for --api-key (back-compat)').hideHelp())
|
|
30
|
-
.option('--discovery-key <key>', '
|
|
30
|
+
.option('--discovery-key <key>', 'Org discovery key for the device scan — required only under sudo (MDM/org scope); ignored for per-user onboarding (or set UNBOUND_DISCOVERY_KEY)')
|
|
31
31
|
.option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
|
|
32
32
|
.option('--set-cron', 'Set up a daily background job to keep governance up to date (under sudo, schedules a discovery scan only — not a full tool re-install)')
|
|
33
33
|
.option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
|
|
@@ -37,24 +37,27 @@ function register(program) {
|
|
|
37
37
|
.addHelpText('after', `
|
|
38
38
|
Runs the full onboarding flow, auto-detecting scope from your privileges:
|
|
39
39
|
With sudo, installs the MDM tool bundle (${MDM_ALL_TOOLS.join(', ')}) and
|
|
40
|
-
scans all users on the device. Without sudo, installs
|
|
41
|
-
(${ALL_TOOLS.join(', ')}) and scans the current user
|
|
40
|
+
scans all users on the device using --discovery-key. Without sudo, installs
|
|
41
|
+
the user bundle (${ALL_TOOLS.join(', ')}) and scans the current user using
|
|
42
|
+
your own API key.
|
|
42
43
|
|
|
43
44
|
1. Logs in / resolves the API key (or reuses a stored \`unbound login\` key
|
|
44
45
|
when --api-key is omitted).
|
|
45
46
|
2. Installs the tool bundle for the detected scope.
|
|
46
|
-
3. Runs device discovery with
|
|
47
|
-
|
|
47
|
+
3. Runs device discovery. Per-user scope scans with your API key so the
|
|
48
|
+
device is attributed to you from the first report; sudo/MDM scope scans
|
|
49
|
+
all users with --discovery-key. With --set-cron, sets up a recurring daily
|
|
50
|
+
scheduled scan (cross-platform) instead of a one-time scan.
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
Per-user onboarding needs only your API key. The separate --discovery-key is
|
|
53
|
+
required only under sudo (MDM/org enrollment), where the scan covers every user
|
|
54
|
+
on the device and cannot be attributed from a single user's key.
|
|
52
55
|
|
|
53
56
|
Examples:
|
|
54
|
-
$ unbound onboard --api-key <USER_KEY>
|
|
57
|
+
$ unbound onboard --api-key <USER_KEY>
|
|
55
58
|
$ sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
|
|
56
|
-
$ unbound onboard --api-key <USER_KEY> --
|
|
57
|
-
$ unbound onboard --api-key <USER_KEY> --
|
|
59
|
+
$ unbound onboard --api-key <USER_KEY> --backfill
|
|
60
|
+
$ unbound onboard --api-key <USER_KEY> --set-cron
|
|
58
61
|
`)
|
|
59
62
|
.action(telemetry.wrapAction('onboard', async (opts) => {
|
|
60
63
|
const apiKeyOpt = opts.apiKey || opts.adminApiKey || process.env.UNBOUND_API_KEY;
|
|
@@ -65,8 +68,12 @@ Examples:
|
|
|
65
68
|
telemetry.rememberSecret(discoveryKeyOpt);
|
|
66
69
|
const isMdm = hasRootPrivileges();
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
// MDM/org scope (sudo) scans every user on the device, so it still needs a
|
|
72
|
+
// dedicated org discovery key. Per-user scope scans only the current user
|
|
73
|
+
// and attributes the device via that user's own API key (see Step 2), so a
|
|
74
|
+
// separate discovery key is neither needed nor used.
|
|
75
|
+
if (isMdm && !discoveryKeyOpt) {
|
|
76
|
+
output.error('--discovery-key is required under sudo (MDM/org enrollment), or set UNBOUND_DISCOVERY_KEY env var');
|
|
70
77
|
process.exitCode = 1;
|
|
71
78
|
return;
|
|
72
79
|
}
|
|
@@ -77,6 +84,13 @@ Examples:
|
|
|
77
84
|
process.exitCode = 1;
|
|
78
85
|
return;
|
|
79
86
|
}
|
|
87
|
+
// Back-compat: --discovery-key used to be required for per-user onboarding.
|
|
88
|
+
// It is now ignored in user scope — the personal API key is used for the
|
|
89
|
+
// scan so the device is attributed to the user from the first report
|
|
90
|
+
// (WEB-4891). Warn rather than fail so existing scripts keep working.
|
|
91
|
+
if (!isMdm && discoveryKeyOpt) {
|
|
92
|
+
output.warn('--discovery-key is deprecated for per-user onboarding and is ignored; your API key is used for the device scan, so the device is attributed to you. You can drop --discovery-key (and unset UNBOUND_DISCOVERY_KEY if it is set in your environment).');
|
|
93
|
+
}
|
|
80
94
|
|
|
81
95
|
let setupSucceeded = false;
|
|
82
96
|
let discoverySucceeded = false;
|
|
@@ -148,7 +162,11 @@ Examples:
|
|
|
148
162
|
console.log('');
|
|
149
163
|
output.info('Step 2/2: Running device discovery');
|
|
150
164
|
console.log('');
|
|
151
|
-
|
|
165
|
+
// Per-user scope: scan with the user's own API key so the backend
|
|
166
|
+
// attributes the device to them at ingestion. Using the org discovery
|
|
167
|
+
// key here leaves the device unattributed until a device->user mapping
|
|
168
|
+
// resolves later (WEB-4891).
|
|
169
|
+
await runDiscoveryScan({ apiKey, domain: discoveryDomain });
|
|
152
170
|
discoverySucceeded = true;
|
|
153
171
|
skippedTools = skipped;
|
|
154
172
|
}
|
|
@@ -168,11 +186,12 @@ Examples:
|
|
|
168
186
|
skipRunAtLoad: true,
|
|
169
187
|
});
|
|
170
188
|
} else {
|
|
189
|
+
// Per-user cron re-runs `unbound onboard`, which now scans with the
|
|
190
|
+
// user's own API key — no separate discovery key to store.
|
|
171
191
|
output.info('Setting up daily scheduled run');
|
|
172
192
|
await setupScheduledRun({
|
|
173
193
|
command: 'onboard',
|
|
174
194
|
apiKey: apiKeyOpt || config.getApiKey(),
|
|
175
|
-
discoveryKey: discoveryKeyOpt,
|
|
176
195
|
domain: discoveryDomain,
|
|
177
196
|
skipRunAtLoad: true,
|
|
178
197
|
});
|
|
@@ -199,11 +218,15 @@ Examples:
|
|
|
199
218
|
if (isMdm) {
|
|
200
219
|
console.error(` Re-run just the scheduled scan with: sudo unbound discover --api-key <DISCOVERY_KEY> --set-cron${suffix}`);
|
|
201
220
|
} else {
|
|
202
|
-
console.error(` Re-run cron setup with: unbound onboard --api-key <KEY> --
|
|
221
|
+
console.error(` Re-run cron setup with: unbound onboard --api-key <KEY> --set-cron${suffix}`);
|
|
203
222
|
}
|
|
204
223
|
} else if (setupSucceeded && !discoverySucceeded) {
|
|
205
224
|
console.error(' Tool setup completed successfully — only discovery failed.');
|
|
206
|
-
|
|
225
|
+
if (isMdm) {
|
|
226
|
+
console.error(` Re-run discovery only with: sudo unbound discover --api-key <DISCOVERY_KEY>${suffix}`);
|
|
227
|
+
} else {
|
|
228
|
+
console.error(` Re-run discovery only with: unbound discover --api-key <KEY>${suffix}`);
|
|
229
|
+
}
|
|
207
230
|
}
|
|
208
231
|
process.exitCode = 1;
|
|
209
232
|
}
|
package/src/commands/status.js
CHANGED
|
@@ -2,7 +2,7 @@ const config = require('../config');
|
|
|
2
2
|
const api = require('../api');
|
|
3
3
|
const output = require('../output');
|
|
4
4
|
const { getDeviceSerial } = require('../device-serial');
|
|
5
|
-
const { detectTools } = require('../toolHealth');
|
|
5
|
+
const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
|
|
6
6
|
|
|
7
7
|
function roleFromPrivileges(p) {
|
|
8
8
|
if (!p) return null;
|
|
@@ -76,8 +76,17 @@ Examples:
|
|
|
76
76
|
pairs.push(['API status', connectivity]);
|
|
77
77
|
|
|
78
78
|
// Locally detected AI tools wired through Unbound, with their mode.
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// WEB-4949: per-tool API keys are validated against the gateway in
|
|
80
|
+
// parallel (fail-open on network errors) — see toolHealth.validateToolKeys.
|
|
81
|
+
// The spinner is essential — N keys × 3s timeout on a slow link would
|
|
82
|
+
// otherwise look like the command had frozen.
|
|
83
|
+
const baseUrl = config.getBaseUrl();
|
|
84
|
+
const detected = detectTools({ gatewayUrl: config.getGatewayUrl() });
|
|
85
|
+
const keySpin = output.spinner('Validating tool API keys...');
|
|
86
|
+
await validateToolKeys(detected, (k) => api.validateApiKey(k, { baseUrl }));
|
|
87
|
+
keySpin.stop();
|
|
88
|
+
const connected = detected.filter((t) => t.status !== 'not-installed');
|
|
89
|
+
const skippedCount = countValidationSkipped(connected);
|
|
81
90
|
|
|
82
91
|
if (opts.json) {
|
|
83
92
|
const cfg = loggedIn ? config.readConfig() : {};
|
|
@@ -92,6 +101,7 @@ Examples:
|
|
|
92
101
|
gateway_url: config.getGatewayUrl(),
|
|
93
102
|
api_status: connectivity,
|
|
94
103
|
connected_tools: connected.map((t) => ({ tool: t.key, label: t.label, mode: t.mode, status: t.status })),
|
|
104
|
+
key_validations_skipped: skippedCount,
|
|
95
105
|
});
|
|
96
106
|
return;
|
|
97
107
|
}
|
|
@@ -113,6 +123,11 @@ Examples:
|
|
|
113
123
|
console.log(` ${mark} ${t.label}${mode}${note}`);
|
|
114
124
|
}
|
|
115
125
|
}
|
|
126
|
+
// WEB-4949: tell the operator when fail-open masked a real check.
|
|
127
|
+
// Without this, an offline run looks identical to a fully-validated one.
|
|
128
|
+
if (skippedCount > 0) {
|
|
129
|
+
console.log(` ${C.dim(`Note: ${skippedCount} API key validation${skippedCount === 1 ? '' : 's'} skipped (gateway unreachable).`)}`);
|
|
130
|
+
}
|
|
116
131
|
console.log('');
|
|
117
132
|
} catch (err) {
|
|
118
133
|
output.error(err.message);
|
package/src/index.js
CHANGED
|
@@ -55,7 +55,7 @@ AUTHENTICATION
|
|
|
55
55
|
$ unbound config urls <gateway-url> <frontend-url> <backend-url>
|
|
56
56
|
|
|
57
57
|
ONBOARDING (one-step install + discover; scope auto-detected from sudo)
|
|
58
|
-
$ unbound onboard --api-key <USER_KEY>
|
|
58
|
+
$ unbound onboard --api-key <USER_KEY> Current user
|
|
59
59
|
$ sudo unbound onboard --api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> All users (MDM)
|
|
60
60
|
|
|
61
61
|
TOOL SETUP
|
package/src/toolHealth.js
CHANGED
|
@@ -126,6 +126,26 @@ function scriptCheck(label, file, kind = 'structural') {
|
|
|
126
126
|
return { name: label, ok, kind, detail: ok ? file : `not found (${file})`, summary: `${label.toLowerCase()} missing` };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Set-but-non-empty check + value capture, intended for per-tool API key envs.
|
|
130
|
+
// WEB-4949: API keys are not equality-checked against the stored
|
|
131
|
+
// `config.api_key` — per-tool keys can legitimately differ for the same tenant
|
|
132
|
+
// (different keys for Cursor vs Codex, dashboard-rotated key, etc.). The
|
|
133
|
+
// `keyCheck`/`envName`/`value` tags let `validateToolKeys` revisit each
|
|
134
|
+
// captured value and flip the check not-ok when the gateway rejects it.
|
|
135
|
+
function envKeyCheck(label, name, kind = 'aux') {
|
|
136
|
+
const base = label.replace(/ env$/, '');
|
|
137
|
+
const r = readEnvVar(name);
|
|
138
|
+
if (!r.found || !r.value) {
|
|
139
|
+
return { name: label, ok: false, kind, detail: `${name} is not set`, summary: `${base} not set` };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
name: label, ok: true, kind,
|
|
143
|
+
detail: `${name} set (${r.source})`,
|
|
144
|
+
summary: `${base} set`,
|
|
145
|
+
keyCheck: true, envName: name, value: r.value,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
129
149
|
function envCheck(label, name, expected, kind = 'aux') {
|
|
130
150
|
const base = label.replace(/ env$/, '');
|
|
131
151
|
const r = readEnvVar(name);
|
|
@@ -217,8 +237,11 @@ function refsUnbound(obj) {
|
|
|
217
237
|
}
|
|
218
238
|
|
|
219
239
|
// One descriptor per (tool, mode). `family` groups the two-mode tools so the
|
|
220
|
-
// collapsed view shows a single line per product.
|
|
221
|
-
|
|
240
|
+
// collapsed view shows a single line per product. `apiKey` is no longer
|
|
241
|
+
// equality-checked (WEB-4949) — per-tool keys can legitimately differ from
|
|
242
|
+
// the stored login key. Use `validateToolKeys` post-detection to verify each
|
|
243
|
+
// captured key against the gateway.
|
|
244
|
+
function buildVariants(gatewayUrl, binaryPath) {
|
|
222
245
|
const gw = (gatewayUrl || GATEWAY_DEFAULT).replace(/\/+$/, ''); // setup rstrips too
|
|
223
246
|
return [
|
|
224
247
|
{
|
|
@@ -226,7 +249,7 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
|
|
|
226
249
|
checks: () => [
|
|
227
250
|
configCheck('Config', '~/.cursor/hooks.json', { json: true, test: refsUnbound }),
|
|
228
251
|
scriptCheck('Hook script', '~/.cursor/hooks/unbound.py'),
|
|
229
|
-
|
|
252
|
+
envKeyCheck('API key env', 'UNBOUND_CURSOR_API_KEY'),
|
|
230
253
|
],
|
|
231
254
|
},
|
|
232
255
|
{
|
|
@@ -234,7 +257,7 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
|
|
|
234
257
|
checks: () => [
|
|
235
258
|
configCheck('Config', '~/.claude/settings.json', { json: true, test: refsUnbound }),
|
|
236
259
|
scriptCheck('Hook script', '~/.claude/hooks/unbound.py'),
|
|
237
|
-
|
|
260
|
+
envKeyCheck('API key env', 'UNBOUND_CLAUDE_API_KEY'),
|
|
238
261
|
],
|
|
239
262
|
},
|
|
240
263
|
{
|
|
@@ -242,7 +265,9 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
|
|
|
242
265
|
checks: () => [
|
|
243
266
|
configCheck('Config', '~/.claude/settings.json', { json: true, test: (j) => typeof j.apiKeyHelper === 'string' && j.apiKeyHelper.includes('anthropic_key.sh') }),
|
|
244
267
|
scriptCheck('Key helper', '~/.claude/anthropic_key.sh'),
|
|
245
|
-
|
|
268
|
+
envKeyCheck('API key env', 'UNBOUND_API_KEY'),
|
|
269
|
+
// Gateway URL is a real equality check — the hook must point at the
|
|
270
|
+
// configured backend; tenants don't generate different gateway URLs.
|
|
246
271
|
envCheck('Gateway URL env', 'ANTHROPIC_BASE_URL', gw),
|
|
247
272
|
],
|
|
248
273
|
},
|
|
@@ -252,14 +277,14 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
|
|
|
252
277
|
configCheck('Config', '~/.codex/hooks.json', { json: true, test: refsUnbound }),
|
|
253
278
|
configCheck('Hooks feature flag', '~/.codex/config.toml', { json: false, test: (t) => /codex_hooks\s*=\s*true/.test(t) }, { short: 'codex hooks not enabled' }),
|
|
254
279
|
scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
|
|
255
|
-
|
|
280
|
+
envKeyCheck('API key env', 'UNBOUND_CODEX_API_KEY'),
|
|
256
281
|
],
|
|
257
282
|
},
|
|
258
283
|
{
|
|
259
284
|
key: 'codex-gateway', label: 'Codex (gateway)', family: 'codex', mode: 'gateway',
|
|
260
285
|
checks: () => [
|
|
261
286
|
configCheck('Config', '~/.codex/config.toml', { json: false, test: (t) => /openai_base_url\s*=/.test(t) }),
|
|
262
|
-
|
|
287
|
+
envKeyCheck('API key env', 'OPENAI_API_KEY'),
|
|
263
288
|
],
|
|
264
289
|
},
|
|
265
290
|
{
|
|
@@ -276,7 +301,7 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
|
|
|
276
301
|
const checks = [configCheck('Config', '~/.copilot/hooks/unbound.json', { json: true, test: refsUnbound })];
|
|
277
302
|
if (hasBinary) checks.push(scriptCheck('Hook binary', binaryPath || BINARY_PATH));
|
|
278
303
|
else checks.push(scriptCheck('Hook script', '~/.copilot/hooks/unbound.py'));
|
|
279
|
-
checks.push(
|
|
304
|
+
checks.push(envKeyCheck('API key env', 'UNBOUND_COPILOT_API_KEY'));
|
|
280
305
|
return checks;
|
|
281
306
|
},
|
|
282
307
|
},
|
|
@@ -305,8 +330,8 @@ function detectVariant(variant) {
|
|
|
305
330
|
// org-managed scenarios can be exercised without writing under /Library or /etc.
|
|
306
331
|
// `_binaryPath` (test-only) overrides the system hook-binary path so binary-mode
|
|
307
332
|
// scenarios can be exercised without writing under /opt.
|
|
308
|
-
function detectTools({ gatewayUrl,
|
|
309
|
-
const variants = buildVariants(gatewayUrl,
|
|
333
|
+
function detectTools({ gatewayUrl, _mdmDirs, _binaryPath } = {}) {
|
|
334
|
+
const variants = buildVariants(gatewayUrl, _binaryPath).map(detectVariant);
|
|
310
335
|
const families = [];
|
|
311
336
|
const seen = new Set();
|
|
312
337
|
for (const v of variants) {
|
|
@@ -337,4 +362,80 @@ function detectTools({ gatewayUrl, apiKey, _mdmDirs, _binaryPath } = {}) {
|
|
|
337
362
|
return families;
|
|
338
363
|
}
|
|
339
364
|
|
|
340
|
-
|
|
365
|
+
// WEB-4949: Validate each tool's captured env-var key against the gateway and
|
|
366
|
+
// mutate the corresponding `keyCheck: true` entries in place. Fail-open: an
|
|
367
|
+
// 'unknown' verdict (network error, timeout, 5xx) leaves the check healthy
|
|
368
|
+
// but tags it `validationSkipped: true` so status/doctor can surface
|
|
369
|
+
// "gateway unreachable — N key(s) not verified" instead of silently lying.
|
|
370
|
+
// Unique keys are fanned out in parallel so N tools share one round-trip's
|
|
371
|
+
// worth of latency. `validateKey` is
|
|
372
|
+
// `(key) => Promise<'valid'|'invalid'|'unknown'>` — injected from
|
|
373
|
+
// status/doctor (which wrap `api.validateApiKey`) so tests can stub it. A
|
|
374
|
+
// validator that throws is coerced to 'unknown' so a regression in the api
|
|
375
|
+
// client can never wipe out the local-state view callers depend on.
|
|
376
|
+
async function validateToolKeys(tools, validateKey) {
|
|
377
|
+
if (!tools?.length) return tools;
|
|
378
|
+
const unique = new Set();
|
|
379
|
+
for (const t of tools) {
|
|
380
|
+
for (const c of t.checks || []) {
|
|
381
|
+
if (c.keyCheck && c.value) unique.add(c.value);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (!unique.size) return tools;
|
|
385
|
+
const entries = await Promise.all(
|
|
386
|
+
[...unique].map(async (k) => {
|
|
387
|
+
try {
|
|
388
|
+
return [k, await validateKey(k)];
|
|
389
|
+
} catch {
|
|
390
|
+
return [k, 'unknown'];
|
|
391
|
+
}
|
|
392
|
+
})
|
|
393
|
+
);
|
|
394
|
+
const validity = new Map(entries);
|
|
395
|
+
for (const t of tools) {
|
|
396
|
+
if (!t.checks?.length) continue;
|
|
397
|
+
let okFlipped = false;
|
|
398
|
+
for (const c of t.checks) {
|
|
399
|
+
if (!c.keyCheck) continue;
|
|
400
|
+
const verdict = validity.get(c.value);
|
|
401
|
+
if (verdict === 'invalid') {
|
|
402
|
+
c.ok = false;
|
|
403
|
+
c.warn = true;
|
|
404
|
+
const base = c.name.replace(/ env$/, '');
|
|
405
|
+
c.summary = `${base} invalid`;
|
|
406
|
+
c.detail = `${c.envName} set but not valid for this tenant`;
|
|
407
|
+
okFlipped = true;
|
|
408
|
+
} else if (verdict === 'unknown') {
|
|
409
|
+
// Stay healthy (fail-open), but record the skip so the caller can
|
|
410
|
+
// surface it. Without this tag the operator can't tell an offline
|
|
411
|
+
// run from a fully-validated one — that's the WEB-4922 lesson.
|
|
412
|
+
// Does NOT flip `ok`, so the `statusOf` re-derivation stays guarded.
|
|
413
|
+
c.validationSkipped = true;
|
|
414
|
+
c.detail = `${c.detail}; validation skipped — gateway unreachable`;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// ONLY re-derive when a check's `ok` actually flipped. Without this guard,
|
|
418
|
+
// `statusOf` overwrites `managed-by-mdm` (the sentinel `detectTools` sets
|
|
419
|
+
// for MDM-managed tools) back to a plain 'healthy', erasing the MDM badge
|
|
420
|
+
// from both human + JSON output. MDM checks have no `keyCheck` entries so
|
|
421
|
+
// this guard naturally excludes them — no MDM-specific carve-out needed.
|
|
422
|
+
if (okFlipped) t.status = statusOf(t.checks);
|
|
423
|
+
}
|
|
424
|
+
return tools;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// WEB-4949: Convenience accessor for status/doctor. Returns the count of
|
|
428
|
+
// `keyCheck` entries flagged `validationSkipped: true` across all tools.
|
|
429
|
+
// Used to render a one-line "N key(s) not verified" note when the validator
|
|
430
|
+
// failed open. Returns 0 when validation ran cleanly or no key checks exist.
|
|
431
|
+
function countValidationSkipped(tools) {
|
|
432
|
+
let n = 0;
|
|
433
|
+
for (const t of tools || []) {
|
|
434
|
+
for (const c of t.checks || []) {
|
|
435
|
+
if (c.keyCheck && c.validationSkipped) n += 1;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return n;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
module.exports = { detectTools, statusOf, readEnvVar, validateToolKeys, countValidationSkipped, GATEWAY_DEFAULT };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const api = require('../src/api');
|
|
5
|
+
|
|
6
|
+
// WEB-4949: validateApiKey wraps the /api/v1/users/privileges/ probe with the
|
|
7
|
+
// fail-open semantics status/doctor need. Stand up a real local HTTP server
|
|
8
|
+
// per case so we exercise the actual transport (timeout, status mapping, no
|
|
9
|
+
// throw on 4xx) instead of mocking the api client.
|
|
10
|
+
function withServer(handler, fn) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const server = http.createServer(handler);
|
|
13
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
14
|
+
const { address, port } = server.address();
|
|
15
|
+
const baseUrl = `http://${address}:${port}`;
|
|
16
|
+
try {
|
|
17
|
+
await fn(baseUrl);
|
|
18
|
+
resolve();
|
|
19
|
+
} catch (err) {
|
|
20
|
+
reject(err);
|
|
21
|
+
} finally {
|
|
22
|
+
server.close();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
server.on('error', reject);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('validateApiKey: 200 → valid', async () => {
|
|
30
|
+
await withServer(
|
|
31
|
+
(_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}'); },
|
|
32
|
+
async (baseUrl) => {
|
|
33
|
+
assert.equal(await api.validateApiKey('k', { baseUrl }), 'valid');
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('validateApiKey: 401 → invalid', async () => {
|
|
39
|
+
await withServer(
|
|
40
|
+
(_req, res) => { res.writeHead(401, { 'content-type': 'application/json' }); res.end('{"error":"bad key"}'); },
|
|
41
|
+
async (baseUrl) => {
|
|
42
|
+
assert.equal(await api.validateApiKey('k', { baseUrl }), 'invalid');
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('validateApiKey: 403 → invalid', async () => {
|
|
48
|
+
await withServer(
|
|
49
|
+
(_req, res) => { res.writeHead(403); res.end(''); },
|
|
50
|
+
async (baseUrl) => {
|
|
51
|
+
assert.equal(await api.validateApiKey('k', { baseUrl }), 'invalid');
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('validateApiKey: 500 → unknown (fail-open)', async () => {
|
|
57
|
+
await withServer(
|
|
58
|
+
(_req, res) => { res.writeHead(500); res.end(''); },
|
|
59
|
+
async (baseUrl) => {
|
|
60
|
+
assert.equal(await api.validateApiKey('k', { baseUrl }), 'unknown');
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('validateApiKey: timeout → unknown (fail-open)', async () => {
|
|
66
|
+
// Handler never responds; short timeout forces the unknown branch.
|
|
67
|
+
await withServer(
|
|
68
|
+
(_req, _res) => { /* hang */ },
|
|
69
|
+
async (baseUrl) => {
|
|
70
|
+
assert.equal(await api.validateApiKey('k', { baseUrl, timeoutMs: 100 }), 'unknown');
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('validateApiKey: connection refused → unknown (fail-open)', async () => {
|
|
76
|
+
// Pick a localhost port nothing is listening on. 1 is reliably refused.
|
|
77
|
+
assert.equal(
|
|
78
|
+
await api.validateApiKey('k', { baseUrl: 'http://127.0.0.1:1', timeoutMs: 500 }),
|
|
79
|
+
'unknown'
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('validateApiKey: missing key → invalid without a network call', async () => {
|
|
84
|
+
// baseUrl points at a dead port; if we made a network call the test would
|
|
85
|
+
// hang or take ages. A short timeout would also work but is unnecessary
|
|
86
|
+
// because the function should short-circuit before any IO.
|
|
87
|
+
assert.equal(await api.validateApiKey('', { baseUrl: 'http://127.0.0.1:1' }), 'invalid');
|
|
88
|
+
assert.equal(await api.validateApiKey(null, { baseUrl: 'http://127.0.0.1:1' }), 'invalid');
|
|
89
|
+
assert.equal(await api.validateApiKey(undefined, { baseUrl: 'http://127.0.0.1:1' }), 'invalid');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('validateApiKey: sends the key as bearer + hits /api/v1/users/privileges/', async () => {
|
|
93
|
+
let observedAuth = null;
|
|
94
|
+
let observedPath = null;
|
|
95
|
+
await withServer(
|
|
96
|
+
(req, res) => {
|
|
97
|
+
observedAuth = req.headers['authorization'];
|
|
98
|
+
observedPath = req.url;
|
|
99
|
+
res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}');
|
|
100
|
+
},
|
|
101
|
+
async (baseUrl) => {
|
|
102
|
+
await api.validateApiKey('sk-abc123', { baseUrl });
|
|
103
|
+
assert.equal(observedAuth, 'Bearer sk-abc123');
|
|
104
|
+
assert.ok(observedPath.startsWith('/api/v1/users/privileges/'), observedPath);
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
});
|
|
@@ -100,7 +100,9 @@ test('onboard schedules via setupScheduledRun with command onboard', () => {
|
|
|
100
100
|
);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
// WEB-4891: per-user onboarding scans with the user's own API key, so the
|
|
104
|
+
// per-user cron stores a single key — no separate discoveryKey.
|
|
105
|
+
test('per-user onboard cron passes apiKey but NOT discoveryKey to setupScheduledRun', () => {
|
|
104
106
|
const callIdx = onboardSrc.indexOf("command: 'onboard'");
|
|
105
107
|
assert.notEqual(callIdx, -1, 'expected command: onboard call site in onboard.js');
|
|
106
108
|
const open = onboardSrc.lastIndexOf('{', callIdx);
|
|
@@ -112,10 +114,31 @@ test('onboard passes both apiKey and discoveryKey to setupScheduledRun', () => {
|
|
|
112
114
|
}
|
|
113
115
|
const callArgs = onboardSrc.slice(open, close + 1);
|
|
114
116
|
assert.ok(callArgs.includes('apiKey'), 'setupScheduledRun call must include apiKey');
|
|
115
|
-
assert.ok(callArgs.includes('discoveryKey'), '
|
|
117
|
+
assert.ok(!callArgs.includes('discoveryKey'), 'per-user onboard cron must NOT pass a discoveryKey (single-key onboarding, WEB-4891)');
|
|
116
118
|
assert.ok(!callArgs.toLowerCase().includes('backfill'), 'setupScheduledRun call must not include backfill');
|
|
117
119
|
});
|
|
118
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', () => {
|
|
126
|
+
const userBranchStart = onboardSrc.indexOf('const apiKey = config.getApiKey();');
|
|
127
|
+
assert.notEqual(userBranchStart, -1, 'expected the per-user branch in onboard.js');
|
|
128
|
+
const userBranch = onboardSrc.slice(userBranchStart);
|
|
129
|
+
assert.match(
|
|
130
|
+
userBranch,
|
|
131
|
+
/runDiscoveryScan\(\{\s*apiKey\s*,/,
|
|
132
|
+
'per-user onboarding must scan with the user apiKey, not the org discovery key'
|
|
133
|
+
);
|
|
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'
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
119
142
|
test('onboard imports setupScheduledRun from ../scheduled', () => {
|
|
120
143
|
assert.match(
|
|
121
144
|
onboardSrc,
|
|
@@ -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 } = {}) {
|
|
38
|
+
async function runOnboard(isRoot, { setCron = false, discoveryKey = 'd' } = {}) {
|
|
39
39
|
for (const m of [
|
|
40
40
|
'../src/commands/onboard',
|
|
41
41
|
'../src/commands/setup',
|
|
@@ -48,7 +48,7 @@ async function runOnboard(isRoot, { setCron = false } = {}) {
|
|
|
48
48
|
delete require.cache[require.resolve(m)];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const calls = { mdm: 0, user: 0, discovery: 0, loggedIn: 0, cron: null };
|
|
51
|
+
const calls = { mdm: 0, user: 0, discovery: 0, loggedIn: 0, cron: null, warns: [] };
|
|
52
52
|
const setup = require('../src/commands/setup');
|
|
53
53
|
const discover = require('../src/commands/discover');
|
|
54
54
|
const auth = require('../src/auth');
|
|
@@ -59,7 +59,7 @@ async function runOnboard(isRoot, { setCron = false } = {}) {
|
|
|
59
59
|
setup.hasRootPrivileges = () => isRoot;
|
|
60
60
|
setup.runMdmSetupAllBundle = async () => { calls.mdm++; return { ok: true, skipped: [] }; };
|
|
61
61
|
setup.runSetupAllBundle = async () => { calls.user++; return { ok: true, skipped: [] }; };
|
|
62
|
-
discover.runDiscoveryScan = async () => { calls.discovery++; };
|
|
62
|
+
discover.runDiscoveryScan = async (o) => { calls.discovery++; calls.discoveryArgs = o; };
|
|
63
63
|
auth.ensureLoggedIn = async () => { calls.loggedIn++; };
|
|
64
64
|
scheduled.setupScheduledRun = async (o) => { calls.cron = o; };
|
|
65
65
|
config.setUrls = () => ({});
|
|
@@ -68,13 +68,15 @@ async function runOnboard(isRoot, { setCron = false } = {}) {
|
|
|
68
68
|
config.getFrontendUrl = () => 'https://f.acme';
|
|
69
69
|
config.getGatewayUrl = () => 'https://g.acme';
|
|
70
70
|
config.isLoggedIn = () => true;
|
|
71
|
-
for (const k of ['info', 'success', 'error'
|
|
71
|
+
for (const k of ['info', 'success', 'error']) output[k] = () => {};
|
|
72
|
+
output.warn = (m) => calls.warns.push(m);
|
|
72
73
|
|
|
73
74
|
const { register } = require('../src/commands/onboard');
|
|
74
75
|
const program = new Command();
|
|
75
76
|
program.exitOverride();
|
|
76
77
|
register(program);
|
|
77
|
-
const argv = ['node', 'unbound', 'onboard', '--api-key', 'k'
|
|
78
|
+
const argv = ['node', 'unbound', 'onboard', '--api-key', 'k'];
|
|
79
|
+
if (discoveryKey) argv.push('--discovery-key', discoveryKey);
|
|
78
80
|
if (setCron) argv.push('--set-cron');
|
|
79
81
|
await program.parseAsync(argv);
|
|
80
82
|
return calls;
|
|
@@ -86,6 +88,7 @@ test('onboard with sudo/root → installs the MDM bundle (all users), no login',
|
|
|
86
88
|
assert.equal(calls.user, 0, 'user bundle NOT installed');
|
|
87
89
|
assert.equal(calls.loggedIn, 0, 'MDM scope does not run ensureLoggedIn');
|
|
88
90
|
assert.equal(calls.discovery, 1, 'discovery still runs');
|
|
91
|
+
assert.equal(calls.discoveryArgs.apiKey, 'd', 'MDM scope scans with the org discovery key');
|
|
89
92
|
});
|
|
90
93
|
|
|
91
94
|
test('onboard without sudo → logs in and installs the user bundle', async () => {
|
|
@@ -94,6 +97,9 @@ test('onboard without sudo → logs in and installs the user bundle', async () =
|
|
|
94
97
|
assert.equal(calls.mdm, 0, 'MDM bundle NOT installed');
|
|
95
98
|
assert.equal(calls.loggedIn, 1, 'user scope runs ensureLoggedIn');
|
|
96
99
|
assert.equal(calls.discovery, 1, 'discovery still runs');
|
|
100
|
+
// WEB-4891: per-user scan uses the resolved user key, NOT the org discovery key.
|
|
101
|
+
assert.equal(calls.discoveryArgs.apiKey, 'resolved-key', 'user scope scans with the user API key');
|
|
102
|
+
assert.notEqual(calls.discoveryArgs.apiKey, 'd', 'user scope must NOT scan with the discovery key');
|
|
97
103
|
});
|
|
98
104
|
|
|
99
105
|
// --set-cron under sudo schedules discovery only (not a daily full MDM
|
|
@@ -110,5 +116,34 @@ test('onboard --set-cron without sudo → schedules the full onboard run', async
|
|
|
110
116
|
const calls = await runOnboard(false, { setCron: true });
|
|
111
117
|
assert.ok(calls.cron, 'a scheduled run was set up');
|
|
112
118
|
assert.equal(calls.cron.command, 'onboard', 'user scope keeps the full onboard cron');
|
|
113
|
-
|
|
119
|
+
// WEB-4891: the per-user cron re-runs onboard, which scans with the user's own
|
|
120
|
+
// key — so the user key is stored and no separate discovery key is.
|
|
121
|
+
assert.equal(calls.cron.apiKey, 'k', 'per-user cron stores the user API key');
|
|
122
|
+
assert.equal(calls.cron.discoveryKey, undefined, 'per-user onboard cron no longer forwards a discovery key');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// WEB-4891: the clean per-user path passes NO --discovery-key. It must work
|
|
126
|
+
// without error, scan with the user key, and emit no deprecation warning.
|
|
127
|
+
test('onboard without sudo and without --discovery-key → clean single-key path', async () => {
|
|
128
|
+
const calls = await runOnboard(false, { discoveryKey: null });
|
|
129
|
+
assert.equal(calls.user, 1, 'user bundle installed');
|
|
130
|
+
assert.equal(calls.discovery, 1, 'discovery runs');
|
|
131
|
+
assert.equal(calls.discoveryArgs.apiKey, 'resolved-key', 'scans with the user API key');
|
|
132
|
+
assert.equal(
|
|
133
|
+
calls.warns.filter((w) => /discovery-key is deprecated/.test(w)).length,
|
|
134
|
+
0,
|
|
135
|
+
'no deprecation warning when --discovery-key is omitted',
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Passing --discovery-key in user scope is deprecated: it is ignored (the scan
|
|
140
|
+
// still uses the user key) and exactly one deprecation warning is emitted.
|
|
141
|
+
test('onboard without sudo WITH --discovery-key → ignored + deprecation warning', async () => {
|
|
142
|
+
const calls = await runOnboard(false, { discoveryKey: 'd' });
|
|
143
|
+
assert.equal(calls.discoveryArgs.apiKey, 'resolved-key', 'still scans with the user API key, not the discovery key');
|
|
144
|
+
assert.equal(
|
|
145
|
+
calls.warns.filter((w) => /discovery-key is deprecated/.test(w)).length,
|
|
146
|
+
1,
|
|
147
|
+
'emits exactly one deprecation warning',
|
|
148
|
+
);
|
|
114
149
|
});
|
package/test/tool-health.test.js
CHANGED
|
@@ -79,15 +79,19 @@ test('detectTools: cursor config + script present but env key blank → tampered
|
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
// WEB-4949: per-tool API keys are no longer equality-checked against the
|
|
83
|
+
// stored config key — different valid keys for the same tenant are legal.
|
|
84
|
+
// detectTools just verifies presence; validateToolKeys (separately tested
|
|
85
|
+
// below) handles tenant-validity.
|
|
86
|
+
test('detectTools: cursor env key differs from the configured key → still healthy (no equality check)', () => {
|
|
83
87
|
withHome((tmp, th) => {
|
|
84
88
|
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
85
89
|
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
86
90
|
writeFile(script, '# unbound');
|
|
87
|
-
process.env.UNBOUND_CURSOR_API_KEY = '
|
|
91
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'different-but-presumed-valid';
|
|
88
92
|
try {
|
|
89
|
-
const cursor = th.detectTools(
|
|
90
|
-
assert.equal(cursor.status, '
|
|
93
|
+
const cursor = th.detectTools().find((t) => t.key === 'cursor');
|
|
94
|
+
assert.equal(cursor.status, 'healthy');
|
|
91
95
|
} finally {
|
|
92
96
|
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
93
97
|
}
|
|
@@ -302,20 +306,242 @@ test('detectTools: codex binary regression (wrapper at ~/.codex/hooks/unbound.py
|
|
|
302
306
|
});
|
|
303
307
|
});
|
|
304
308
|
|
|
305
|
-
|
|
309
|
+
// envCheck's rc-file fallback applies to the gateway URL check
|
|
310
|
+
// (ANTHROPIC_BASE_URL is still equality-checked since tenants share one
|
|
311
|
+
// gateway URL). API keys are no longer equality-checked — see WEB-4949 — so
|
|
312
|
+
// this test is repointed at the gateway-URL case to keep the rc-fallback
|
|
313
|
+
// path covered. Stale process.env + fresh rc value → still healthy.
|
|
314
|
+
test('detectTools: envCheck rc-file fallback (gateway URL env stale, rc holds the configured URL) → healthy', () => {
|
|
306
315
|
withHome((tmp, th) => {
|
|
316
|
+
const settings = { apiKeyHelper: '/Users/whatever/.claude/anthropic_key.sh' };
|
|
317
|
+
writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify(settings));
|
|
318
|
+
writeFile(path.join(tmp, '.claude', 'anthropic_key.sh'), '#!/bin/sh\necho $UNBOUND_API_KEY\n');
|
|
319
|
+
process.env.UNBOUND_API_KEY = 'k';
|
|
320
|
+
// rcFiles() lists different files per platform; .bashrc is in both.
|
|
321
|
+
writeFile(path.join(tmp, '.bashrc'),
|
|
322
|
+
'export ANTHROPIC_BASE_URL="https://gateway.example.com"\n');
|
|
323
|
+
process.env.ANTHROPIC_BASE_URL = 'https://stale.example.com';
|
|
324
|
+
try {
|
|
325
|
+
const t = th.detectTools({ gatewayUrl: 'https://gateway.example.com' })
|
|
326
|
+
.find((x) => x.family === 'claude-code');
|
|
327
|
+
assert.equal(t.status, 'healthy');
|
|
328
|
+
} finally {
|
|
329
|
+
delete process.env.UNBOUND_API_KEY;
|
|
330
|
+
delete process.env.ANTHROPIC_BASE_URL;
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// WEB-4949: validateToolKeys post-processes detectTools output. Verify the
|
|
336
|
+
// three branches: valid → unchanged healthy; invalid → flips to tampered with
|
|
337
|
+
// a meaningful summary; unknown (network error / 5xx) → fail-open healthy.
|
|
338
|
+
|
|
339
|
+
test('validateToolKeys: valid verdict leaves the tool healthy', async () => {
|
|
340
|
+
await withHome(async (tmp, th) => {
|
|
307
341
|
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
308
342
|
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
309
343
|
writeFile(script, '# unbound');
|
|
310
|
-
|
|
311
|
-
// zshrc/bashrc/profile on linux). Write to .bashrc — both lists include it.
|
|
312
|
-
writeFile(path.join(tmp, '.bashrc'), 'export UNBOUND_CURSOR_API_KEY="fresh-key"\n');
|
|
313
|
-
process.env.UNBOUND_CURSOR_API_KEY = 'stale-key';
|
|
344
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'valid-key';
|
|
314
345
|
try {
|
|
315
|
-
const
|
|
316
|
-
|
|
346
|
+
const tools = th.detectTools();
|
|
347
|
+
await th.validateToolKeys(tools, async () => 'valid');
|
|
348
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
349
|
+
assert.equal(cursor.status, 'healthy');
|
|
350
|
+
const keyCheck = cursor.checks.find((c) => c.keyCheck);
|
|
351
|
+
assert.equal(keyCheck.ok, true);
|
|
352
|
+
} finally {
|
|
353
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('validateToolKeys: invalid verdict flips the tool to tampered', async () => {
|
|
359
|
+
await withHome(async (tmp, th) => {
|
|
360
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
361
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
362
|
+
writeFile(script, '# unbound');
|
|
363
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'rejected-key';
|
|
364
|
+
try {
|
|
365
|
+
const tools = th.detectTools();
|
|
366
|
+
await th.validateToolKeys(tools, async () => 'invalid');
|
|
367
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
368
|
+
assert.equal(cursor.status, 'tampered');
|
|
369
|
+
const keyCheck = cursor.checks.find((c) => c.keyCheck);
|
|
370
|
+
assert.equal(keyCheck.ok, false);
|
|
371
|
+
assert.match(keyCheck.summary, /invalid/);
|
|
372
|
+
assert.match(keyCheck.detail, /not valid for this tenant/);
|
|
373
|
+
} finally {
|
|
374
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('validateToolKeys: unknown verdict fails open — tool stays healthy', async () => {
|
|
380
|
+
await withHome(async (tmp, th) => {
|
|
381
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
382
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
383
|
+
writeFile(script, '# unbound');
|
|
384
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'cant-reach-gateway';
|
|
385
|
+
try {
|
|
386
|
+
const tools = th.detectTools();
|
|
387
|
+
await th.validateToolKeys(tools, async () => 'unknown');
|
|
388
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
389
|
+
assert.equal(cursor.status, 'healthy');
|
|
390
|
+
} finally {
|
|
391
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('validateToolKeys: distinct keys across tools are each validated once', async () => {
|
|
397
|
+
await withHome(async (tmp, th) => {
|
|
398
|
+
// Two tools with two distinct env-var values + one shared value.
|
|
399
|
+
const cursorScript = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
400
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: cursorScript }] } }));
|
|
401
|
+
writeFile(cursorScript, '# unbound');
|
|
402
|
+
const copilotCfg = path.join(tmp, '.copilot', 'hooks', 'unbound.json');
|
|
403
|
+
const copilotScript = path.join(tmp, '.copilot', 'hooks', 'unbound.py');
|
|
404
|
+
writeFile(copilotCfg, JSON.stringify({ hooks: { PreToolUse: [{ command: copilotScript }] } }));
|
|
405
|
+
writeFile(copilotScript, '# unbound');
|
|
406
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'key-A';
|
|
407
|
+
process.env.UNBOUND_COPILOT_API_KEY = 'key-B';
|
|
408
|
+
try {
|
|
409
|
+
const tools = th.detectTools();
|
|
410
|
+
const seen = [];
|
|
411
|
+
await th.validateToolKeys(tools, async (k) => { seen.push(k); return 'valid'; });
|
|
412
|
+
// Each unique key is validated exactly once, regardless of how many
|
|
413
|
+
// tools reference it. Order isn't asserted.
|
|
414
|
+
assert.deepEqual(new Set(seen), new Set(['key-A', 'key-B']));
|
|
415
|
+
assert.equal(seen.length, 2);
|
|
416
|
+
} finally {
|
|
417
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
418
|
+
delete process.env.UNBOUND_COPILOT_API_KEY;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Real-world common case: one tool's key was rotated and the env wasn't
|
|
424
|
+
// updated. The valid tool stays healthy; the invalid one flips. They share
|
|
425
|
+
// the same fan-out, so a partial verdict must round-trip correctly.
|
|
426
|
+
test('validateToolKeys: mixed verdicts — invalid tool flips, valid tool unchanged', async () => {
|
|
427
|
+
await withHome(async (tmp, th) => {
|
|
428
|
+
const cursorScript = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
429
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: cursorScript }] } }));
|
|
430
|
+
writeFile(cursorScript, '# unbound');
|
|
431
|
+
const copilotCfg = path.join(tmp, '.copilot', 'hooks', 'unbound.json');
|
|
432
|
+
const copilotScript = path.join(tmp, '.copilot', 'hooks', 'unbound.py');
|
|
433
|
+
writeFile(copilotCfg, JSON.stringify({ hooks: { PreToolUse: [{ command: copilotScript }] } }));
|
|
434
|
+
writeFile(copilotScript, '# unbound');
|
|
435
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'good';
|
|
436
|
+
process.env.UNBOUND_COPILOT_API_KEY = 'bad';
|
|
437
|
+
try {
|
|
438
|
+
const tools = th.detectTools();
|
|
439
|
+
await th.validateToolKeys(tools, async (k) => k === 'good' ? 'valid' : 'invalid');
|
|
440
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
441
|
+
const copilot = tools.find((t) => t.key === 'copilot');
|
|
442
|
+
assert.equal(cursor.status, 'healthy');
|
|
443
|
+
assert.equal(copilot.status, 'tampered');
|
|
444
|
+
} finally {
|
|
445
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
446
|
+
delete process.env.UNBOUND_COPILOT_API_KEY;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Defensive: a validator that throws (a future api.validateApiKey bug, a
|
|
452
|
+
// transport regression) must NOT take down `unbound status`/`doctor`. Each
|
|
453
|
+
// thrown key is coerced to 'unknown' so the tool stays healthy and the skip
|
|
454
|
+
// counter surfaces the issue to the operator.
|
|
455
|
+
test('validateToolKeys: throwing validator coerces to unknown (does not propagate)', async () => {
|
|
456
|
+
await withHome(async (tmp, th) => {
|
|
457
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
458
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
459
|
+
writeFile(script, '# unbound');
|
|
460
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'k';
|
|
461
|
+
try {
|
|
462
|
+
const tools = th.detectTools();
|
|
463
|
+
await assert.doesNotReject(
|
|
464
|
+
th.validateToolKeys(tools, async () => { throw new Error('boom'); })
|
|
465
|
+
);
|
|
466
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
467
|
+
assert.equal(cursor.status, 'healthy');
|
|
468
|
+
const keyCheck = cursor.checks.find((c) => c.keyCheck);
|
|
469
|
+
assert.equal(keyCheck.validationSkipped, true);
|
|
470
|
+
assert.equal(th.countValidationSkipped(tools), 1);
|
|
471
|
+
} finally {
|
|
472
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Tag for offline surfacing — without this the operator can't distinguish a
|
|
478
|
+
// fully-validated run from one that silently failed open.
|
|
479
|
+
test('validateToolKeys: unknown verdict tags the check with validationSkipped + countValidationSkipped reports it', async () => {
|
|
480
|
+
await withHome(async (tmp, th) => {
|
|
481
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
482
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
483
|
+
writeFile(script, '# unbound');
|
|
484
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'k';
|
|
485
|
+
try {
|
|
486
|
+
const tools = th.detectTools();
|
|
487
|
+
await th.validateToolKeys(tools, async () => 'unknown');
|
|
488
|
+
assert.equal(th.countValidationSkipped(tools), 1);
|
|
489
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
490
|
+
const keyCheck = cursor.checks.find((c) => c.keyCheck);
|
|
491
|
+
assert.equal(keyCheck.validationSkipped, true);
|
|
492
|
+
assert.equal(keyCheck.ok, true); // fail-open: still healthy
|
|
317
493
|
} finally {
|
|
318
494
|
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
319
495
|
}
|
|
320
496
|
});
|
|
321
497
|
});
|
|
498
|
+
|
|
499
|
+
// Bot regression (Bugbot Medium / Greptile P1): validateToolKeys was
|
|
500
|
+
// unconditionally re-running statusOf, which overwrites `managed-by-mdm`
|
|
501
|
+
// (since `statusOf` doesn't know about that sentinel) back to `healthy`,
|
|
502
|
+
// erasing the MDM badge from both human + JSON output. The fix is to only
|
|
503
|
+
// re-derive status when a check's `ok` actually flipped.
|
|
504
|
+
test('validateToolKeys: managed-by-mdm status survives validation when nothing flips', async () => {
|
|
505
|
+
await withHome(async (tmp, th) => {
|
|
506
|
+
const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
|
|
507
|
+
writeFile(path.join(mdmDir, 'managed-settings.json'),
|
|
508
|
+
JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(mdmDir, 'hooks', 'unbound.py') }] } }));
|
|
509
|
+
writeFile(path.join(mdmDir, 'hooks', 'unbound.py'), '#');
|
|
510
|
+
const tools = th.detectTools({ _mdmDirs: { 'claude-code': mdmDir } });
|
|
511
|
+
const claude = tools.find((t) => t.family === 'claude-code');
|
|
512
|
+
assert.equal(claude.status, 'managed-by-mdm', 'pre-validation sanity');
|
|
513
|
+
// No keyCheck entries on the MDM variant, so the validator is never
|
|
514
|
+
// consulted. Status must NOT collapse to plain 'healthy'.
|
|
515
|
+
let called = false;
|
|
516
|
+
await th.validateToolKeys(tools, async () => { called = true; return 'valid'; });
|
|
517
|
+
assert.equal(claude.status, 'managed-by-mdm', 'MDM badge was overwritten');
|
|
518
|
+
assert.equal(claude.scope, 'mdm');
|
|
519
|
+
assert.equal(called, false);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// W2 defensive guards.
|
|
524
|
+
test('validateToolKeys: null/empty tools — no throw, no validator calls', async () => {
|
|
525
|
+
const th = require('../src/toolHealth');
|
|
526
|
+
let called = false;
|
|
527
|
+
await th.validateToolKeys(null, async () => { called = true; });
|
|
528
|
+
await th.validateToolKeys(undefined, async () => { called = true; });
|
|
529
|
+
await th.validateToolKeys([], async () => { called = true; });
|
|
530
|
+
assert.equal(called, false);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('validateToolKeys: missing key (no env var) → tampered without consulting the validator', async () => {
|
|
534
|
+
await withHome(async (tmp, th) => {
|
|
535
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
536
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
537
|
+
writeFile(script, '# unbound');
|
|
538
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
539
|
+
let called = false;
|
|
540
|
+
const tools = th.detectTools();
|
|
541
|
+
await th.validateToolKeys(tools, async () => { called = true; return 'valid'; });
|
|
542
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
543
|
+
assert.equal(cursor.status, 'tampered');
|
|
544
|
+
// The validator is never called for missing keys — there's no value to check.
|
|
545
|
+
assert.equal(called, false);
|
|
546
|
+
});
|
|
547
|
+
});
|