unbound-cli 1.6.4 → 1.7.0
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/README.md +25 -0
- package/package.json +2 -1
- package/src/api.js +29 -3
- package/src/auth.js +28 -8
- package/src/commands/discover.js +56 -36
- package/src/commands/doctor.js +17 -4
- package/src/commands/onboard.js +18 -4
- package/src/commands/setup.js +109 -115
- package/src/commands/status.js +18 -3
- package/src/index.js +32 -1
- package/src/run-id.js +27 -0
- package/src/setup-report.js +119 -0
- package/src/telemetry.js +201 -0
- package/src/toolHealth.js +112 -11
- package/src/utils.js +86 -10
- package/test/api-validate-key.test.js +107 -0
- package/test/auth-server-leak.test.js +201 -0
- package/test/cli-flush.test.js +101 -0
- package/test/download-pipe-hole.test.js +221 -0
- package/test/onboard-failure-exit.test.js +125 -0
- package/test/setup-report.test.js +102 -0
- package/test/telemetry.test.js +114 -0
- package/test/tool-health.test.js +237 -11
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
// reportSetupFailure must be BEST-EFFORT: it posts {serial_number, step,
|
|
5
|
+
// exit_code} to /api/v1/setup/failed/, but a POST failure (offline/429/4xx)
|
|
6
|
+
// must never reject — so a caller can `await` it inline in an error path without
|
|
7
|
+
// masking the real error or changing the exit code.
|
|
8
|
+
|
|
9
|
+
function freshReport() {
|
|
10
|
+
for (const m of ['../src/setup-report', '../src/api', '../src/config']) {
|
|
11
|
+
delete require.cache[require.resolve(m)];
|
|
12
|
+
}
|
|
13
|
+
const api = require('../src/api');
|
|
14
|
+
const config = require('../src/config');
|
|
15
|
+
const report = require('../src/setup-report');
|
|
16
|
+
return { api, config, report };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('reportSetupFailure: posts the contract payload to /api/v1/setup/failed/', async () => {
|
|
20
|
+
const { api, config, report } = freshReport();
|
|
21
|
+
let captured = null;
|
|
22
|
+
api.post = async (path, opts) => { captured = { path, opts }; return { ok: true }; };
|
|
23
|
+
config.getApiKey = () => 'stored-key';
|
|
24
|
+
|
|
25
|
+
const ok = await report.reportSetupFailure({ step: 'cursor', exitCode: 2, backendUrl: 'https://b.acme' });
|
|
26
|
+
|
|
27
|
+
assert.equal(ok, true);
|
|
28
|
+
assert.equal(captured.path, '/api/v1/setup/failed/');
|
|
29
|
+
assert.equal(captured.opts.apiKey, 'stored-key');
|
|
30
|
+
assert.equal(captured.opts.baseUrl, 'https://b.acme');
|
|
31
|
+
const body = captured.opts.body;
|
|
32
|
+
assert.equal(typeof body.serial_number, 'string');
|
|
33
|
+
assert.ok(body.serial_number.length > 0 && body.serial_number.length <= 255);
|
|
34
|
+
assert.equal(body.step, 'cursor');
|
|
35
|
+
assert.equal(body.exit_code, 2);
|
|
36
|
+
// install_mode/managed are forced server-side — the CLI must NOT send them.
|
|
37
|
+
assert.equal('install_mode' in body, false);
|
|
38
|
+
assert.equal('managed' in body, false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('reportSetupFailure: bounds the POST with a 3s timeout (never blocks on default 30s)', async () => {
|
|
42
|
+
const { api, config, report } = freshReport();
|
|
43
|
+
let captured = null;
|
|
44
|
+
api.post = async (path, opts) => { captured = opts; return { ok: true }; };
|
|
45
|
+
config.getApiKey = () => 'k';
|
|
46
|
+
|
|
47
|
+
await report.reportSetupFailure({ step: 'cursor', exitCode: 1 });
|
|
48
|
+
assert.equal(captured.timeoutMs, 3000, 'failure report must pass a tight 3s timeout');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('reportSetupFailure: swallows a POST rejection and returns false (best-effort)', async () => {
|
|
52
|
+
const { api, config, report } = freshReport();
|
|
53
|
+
api.post = async () => { throw new Error('ECONNREFUSED / offline'); };
|
|
54
|
+
config.getApiKey = () => 'k';
|
|
55
|
+
|
|
56
|
+
let result;
|
|
57
|
+
await assert.doesNotReject(async () => { result = await report.reportSetupFailure({ step: 'codex', exitCode: 1 }); });
|
|
58
|
+
assert.equal(result, false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('reportSetupFailure: a 429 (rate-limited) is swallowed too', async () => {
|
|
62
|
+
const { api, config, report } = freshReport();
|
|
63
|
+
api.post = async () => { const e = new Error('Too many requests'); e.statusCode = 429; throw e; };
|
|
64
|
+
config.getApiKey = () => 'k';
|
|
65
|
+
|
|
66
|
+
const result = await report.reportSetupFailure({ step: 'cursor', exitCode: 1 });
|
|
67
|
+
assert.equal(result, false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('reportSetupFailure: no API key → skips quietly (false), no POST attempted', async () => {
|
|
71
|
+
const { api, config, report } = freshReport();
|
|
72
|
+
let called = false;
|
|
73
|
+
api.post = async () => { called = true; return {}; };
|
|
74
|
+
config.getApiKey = () => null;
|
|
75
|
+
|
|
76
|
+
const result = await report.reportSetupFailure({ step: 'cursor', exitCode: 1 });
|
|
77
|
+
assert.equal(result, false);
|
|
78
|
+
assert.equal(called, false, 'no POST without a key');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('normalizeStep: clamps to 64 chars and defaults empties to "unknown"', () => {
|
|
82
|
+
const { report } = freshReport();
|
|
83
|
+
assert.equal(report._normalizeStep(''), 'unknown');
|
|
84
|
+
assert.equal(report._normalizeStep(null), 'unknown');
|
|
85
|
+
assert.equal(report._normalizeStep('cursor'), 'cursor');
|
|
86
|
+
assert.equal(report._normalizeStep('x'.repeat(100)).length, 64);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('normalizeExitCode: integers pass through, non-integers fall back to 1', () => {
|
|
90
|
+
const { report } = freshReport();
|
|
91
|
+
assert.equal(report._normalizeExitCode(2), 2);
|
|
92
|
+
assert.equal(report._normalizeExitCode(-1), -1);
|
|
93
|
+
assert.equal(report._normalizeExitCode('nope'), 1);
|
|
94
|
+
assert.equal(report._normalizeExitCode(undefined), 1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('deviceIdentifier: returns a non-empty string within the 255-char limit', () => {
|
|
98
|
+
const { report } = freshReport();
|
|
99
|
+
const id = report.deviceIdentifier();
|
|
100
|
+
assert.equal(typeof id, 'string');
|
|
101
|
+
assert.ok(id.length > 0 && id.length <= 255);
|
|
102
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
// Sentry observability is opt-out and DSN-gated. These tests prove it stays
|
|
5
|
+
// fully disabled (no init, no network, no throw) when there's no DSN or when
|
|
6
|
+
// the user opts out, and that beforeSend scrubs secrets before anything leaves
|
|
7
|
+
// the machine. All without any real network or a real Sentry client.
|
|
8
|
+
|
|
9
|
+
function freshTelemetry() {
|
|
10
|
+
delete require.cache[require.resolve('../src/telemetry')];
|
|
11
|
+
return require('../src/telemetry');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function withEnv(vars, fn) {
|
|
15
|
+
const saved = {};
|
|
16
|
+
for (const k of Object.keys(vars)) {
|
|
17
|
+
saved[k] = process.env[k];
|
|
18
|
+
if (vars[k] === undefined) delete process.env[k];
|
|
19
|
+
else process.env[k] = vars[k];
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return fn();
|
|
23
|
+
} finally {
|
|
24
|
+
for (const k of Object.keys(vars)) {
|
|
25
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
26
|
+
else process.env[k] = saved[k];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('telemetry: disabled (no init, no throw) when UNBOUND_CLI_SENTRY_DSN is unset', () => {
|
|
32
|
+
withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined, UNBOUND_TELEMETRY: undefined }, () => {
|
|
33
|
+
const t = freshTelemetry();
|
|
34
|
+
assert.equal(t.isEnabled(), false);
|
|
35
|
+
// init/captureError/flush must be no-ops that never throw or hit the network.
|
|
36
|
+
assert.doesNotThrow(() => t.init({ flow: 'onboard', runId: 'r1' }));
|
|
37
|
+
assert.doesNotThrow(() => t.captureError(new Error('boom')));
|
|
38
|
+
return t.flush(); // resolves, no-op
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('telemetry: opt-out via UNBOUND_TELEMETRY disables even with a DSN present', () => {
|
|
43
|
+
for (const optOut of ['0', 'false', 'no', 'FALSE', 'No']) {
|
|
44
|
+
withEnv({ UNBOUND_CLI_SENTRY_DSN: 'https://abc@example.invalid/1', UNBOUND_TELEMETRY: optOut }, () => {
|
|
45
|
+
const t = freshTelemetry();
|
|
46
|
+
assert.equal(t.isEnabled(), false, `opt-out value "${optOut}" should disable`);
|
|
47
|
+
assert.doesNotThrow(() => t.init());
|
|
48
|
+
assert.doesNotThrow(() => t.captureError(new Error('x')));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('telemetry: enabled only when DSN set and not opted out', () => {
|
|
54
|
+
withEnv({ UNBOUND_CLI_SENTRY_DSN: 'https://abc@example.invalid/1', UNBOUND_TELEMETRY: undefined }, () => {
|
|
55
|
+
const t = freshTelemetry();
|
|
56
|
+
assert.equal(t.isEnabled(), true);
|
|
57
|
+
});
|
|
58
|
+
withEnv({ UNBOUND_CLI_SENTRY_DSN: 'https://abc@example.invalid/1', UNBOUND_TELEMETRY: '1' }, () => {
|
|
59
|
+
const t = freshTelemetry();
|
|
60
|
+
assert.equal(t.isEnabled(), true, 'UNBOUND_TELEMETRY=1 is not an opt-out');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('telemetry: wrapAction returns the action result and never inits when disabled', async () => {
|
|
65
|
+
await withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined }, async () => {
|
|
66
|
+
const t = freshTelemetry();
|
|
67
|
+
const wrapped = t.wrapAction('setup', async (a, b) => a + b);
|
|
68
|
+
const result = await wrapped(2, 3);
|
|
69
|
+
assert.equal(result, 5);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('telemetry: wrapAction captures then rethrows the original error', async () => {
|
|
74
|
+
await withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined }, async () => {
|
|
75
|
+
const t = freshTelemetry();
|
|
76
|
+
const original = new Error('real failure');
|
|
77
|
+
const wrapped = t.wrapAction('onboard', async () => { throw original; });
|
|
78
|
+
await assert.rejects(() => wrapped(), (err) => err === original);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('beforeSend: scrubs known secret values, home paths, and username', () => {
|
|
83
|
+
const t = freshTelemetry();
|
|
84
|
+
t.rememberSecret('sk-supersecretkey123');
|
|
85
|
+
const home = require('os').homedir();
|
|
86
|
+
const username = require('os').userInfo().username;
|
|
87
|
+
|
|
88
|
+
const event = {
|
|
89
|
+
server_name: 'my-laptop',
|
|
90
|
+
user: { username, id: '1' },
|
|
91
|
+
message: `failed with key sk-supersecretkey123 at ${home}/.unbound/config.json`,
|
|
92
|
+
exception: { values: [{ value: `token ub_abcdefgh12345 leaked for user ${username}` }] },
|
|
93
|
+
};
|
|
94
|
+
const out = t._beforeSend(event);
|
|
95
|
+
|
|
96
|
+
assert.ok(out, 'event is not dropped');
|
|
97
|
+
assert.equal(out.server_name, undefined, 'server_name removed');
|
|
98
|
+
assert.equal(out.user.username, undefined, 'username field removed');
|
|
99
|
+
assert.ok(!out.message.includes('sk-supersecretkey123'), out.message);
|
|
100
|
+
assert.ok(!out.message.includes(home), out.message);
|
|
101
|
+
assert.ok(out.message.includes('~/.unbound'), out.message);
|
|
102
|
+
assert.ok(!out.exception.values[0].value.includes('ub_abcdefgh12345'), out.exception.values[0].value);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('beforeSend: never throws — returns null instead of crashing the flow', () => {
|
|
106
|
+
const t = freshTelemetry();
|
|
107
|
+
// A frozen object can't be mutated; beforeSend must catch and drop, not throw.
|
|
108
|
+
const frozen = Object.freeze({ message: 'x', user: Object.freeze({ username: 'a' }) });
|
|
109
|
+
assert.doesNotThrow(() => {
|
|
110
|
+
const r = t._beforeSend(frozen);
|
|
111
|
+
// Either scrubbed or dropped (null) — both acceptable; the point is no throw.
|
|
112
|
+
assert.ok(r === null || typeof r === 'object');
|
|
113
|
+
});
|
|
114
|
+
});
|
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
|
+
});
|