unbound-cli 1.16.16 → 1.16.18
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/package.json +1 -1
- package/src/commands/setup.js +47 -9
- package/src/scheduled.js +355 -37
- package/src/toolHealth.js +42 -26
- package/test/setup-args.test.js +307 -1
- package/test/tool-health.test.js +92 -0
package/package.json
CHANGED
package/src/commands/setup.js
CHANGED
|
@@ -535,6 +535,21 @@ function runBinaryClear({
|
|
|
535
535
|
}
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
+
// Best-effort teardown of the scheduled discovery scan and its stored API key,
|
|
539
|
+
// via uninstallScheduledRun. Returns a labelled `failed` list (empty on success)
|
|
540
|
+
// instead of throwing, so nuke folds a partial teardown into its summary rather
|
|
541
|
+
// than aborting the wipe. `uninstall` is injectable for hermetic tests; the
|
|
542
|
+
// require is lazy to avoid a module cycle.
|
|
543
|
+
async function removeScheduledRun({ uninstall = require('../scheduled').uninstallScheduledRun } = {}) {
|
|
544
|
+
try {
|
|
545
|
+
await uninstall();
|
|
546
|
+
return [];
|
|
547
|
+
} catch (err) {
|
|
548
|
+
output.warn(`nuke: scheduled-scan teardown failed (${err.message}); continuing.`);
|
|
549
|
+
return ['scheduled scan'];
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
538
553
|
// Windows has no `sudo`: elevation is a property of the terminal, not a command
|
|
539
554
|
// prefix. Advise the platform's real mechanism instead of one that fails to run.
|
|
540
555
|
function elevated(cmd) {
|
|
@@ -1134,10 +1149,10 @@ must update the MDM configuration.
|
|
|
1134
1149
|
.command('nuke')
|
|
1135
1150
|
.alias('uninstall')
|
|
1136
1151
|
.description(
|
|
1137
|
-
'Remove Unbound and start fresh: clears AI-tool configuration
|
|
1138
|
-
'
|
|
1139
|
-
'
|
|
1140
|
-
'the current user.'
|
|
1152
|
+
'Remove Unbound and start fresh: clears AI-tool configuration, tears down the ' +
|
|
1153
|
+
'scheduled discovery scan and its stored API key, and deletes stored ' +
|
|
1154
|
+
'credentials. Scope follows your privileges — run with sudo to also remove MDM ' +
|
|
1155
|
+
'(system-level) config for all users; without root it clears only the current user.'
|
|
1141
1156
|
)
|
|
1142
1157
|
.option('-y, --yes', 'Skip the confirmation prompt')
|
|
1143
1158
|
.addHelpText('after', `
|
|
@@ -1152,11 +1167,16 @@ Scope is chosen automatically from your privileges:
|
|
|
1152
1167
|
What it clears:
|
|
1153
1168
|
- USER-level tool config — Cursor, GitHub Copilot, Claude Code (subscription +
|
|
1154
1169
|
gateway), Gemini CLI, Codex (subscription + gateway).
|
|
1170
|
+
- The scheduled discovery scan and its stored API key — the LaunchAgent / cron /
|
|
1171
|
+
scheduled task, and the key in Keychain / creds file / Credential Manager. Under
|
|
1172
|
+
sudo this is torn down for the invoking user (the scan is a per-user artifact).
|
|
1155
1173
|
- MDM (system-level) tool config across all users [only when run as root]
|
|
1156
1174
|
- Stored credentials and settings (~/.unbound/config.json).
|
|
1157
1175
|
|
|
1158
1176
|
After it finishes, run \`unbound login\` (or \`unbound onboard\`) to set things up
|
|
1159
|
-
fresh. Clearing never contacts the Unbound API, so no API key is needed.
|
|
1177
|
+
fresh. Clearing never contacts the Unbound API, so no API key is needed. The
|
|
1178
|
+
scheduled-scan teardown runs fully offline; the per-tool and MDM clears fetch
|
|
1179
|
+
their setup scripts, so those steps still need network.
|
|
1160
1180
|
|
|
1161
1181
|
Examples:
|
|
1162
1182
|
$ sudo unbound nuke Remove everything on the device (asks to confirm)
|
|
@@ -1175,8 +1195,8 @@ Examples:
|
|
|
1175
1195
|
|
|
1176
1196
|
if (!opts.yes) {
|
|
1177
1197
|
output.warn(includeMdm
|
|
1178
|
-
? 'This removes ALL Unbound tool configuration on this device (user-level and MDM) and deletes your stored credentials.'
|
|
1179
|
-
: 'This removes your user-level Unbound tool configuration and deletes your stored credentials. (MDM/system-level config needs root and will be skipped — re-run with sudo to remove it too.)');
|
|
1198
|
+
? 'This removes ALL Unbound tool configuration on this device (user-level and MDM), tears down your scheduled discovery scan and its stored API key, and deletes your stored credentials.'
|
|
1199
|
+
: 'This removes your user-level Unbound tool configuration, tears down your scheduled discovery scan and its stored API key, and deletes your stored credentials. (MDM/system-level config needs root and will be skipped — re-run with sudo to remove it too.)');
|
|
1180
1200
|
const ok = await confirm('Continue?');
|
|
1181
1201
|
if (!ok) {
|
|
1182
1202
|
output.info('Cancelled. Nothing was changed.');
|
|
@@ -1227,17 +1247,34 @@ Examples:
|
|
|
1227
1247
|
const envCleared = clearUnboundEnvsEverywhere();
|
|
1228
1248
|
if (envCleared.length) output.info(`Removed Unbound env: ${envCleared.join(', ')}.`);
|
|
1229
1249
|
|
|
1250
|
+
// Tear down the scheduled scan and its stored key. It's a user-level
|
|
1251
|
+
// artifact, so it clears regardless of MDM scope; best-effort, folded into
|
|
1252
|
+
// the summary below rather than aborting the wipe.
|
|
1253
|
+
const scheduledFailed = await removeScheduledRun();
|
|
1254
|
+
if (scheduledFailed.length === 0) {
|
|
1255
|
+
// A post-condition, true whether or not a scan was installed — no empty
|
|
1256
|
+
// "removed" claim for a machine that never had one.
|
|
1257
|
+
output.success('No scheduled scan or stored key remain.');
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1230
1260
|
// Wipe credentials + settings last, regardless of tool-clear outcomes.
|
|
1231
1261
|
config.clearConfig();
|
|
1232
1262
|
output.success('Stored credentials and settings removed.');
|
|
1233
1263
|
|
|
1234
1264
|
console.log('');
|
|
1235
|
-
const failed = [...userFailed, ...mdmFailed, ...binaryFailed];
|
|
1265
|
+
const failed = [...userFailed, ...mdmFailed, ...binaryFailed, ...scheduledFailed];
|
|
1236
1266
|
const scope = includeMdm ? 'on this device' : 'for your user';
|
|
1237
1267
|
if (failed.length === 0) {
|
|
1238
1268
|
output.success(`Unbound removed ${scope}. The CLI is back to a fresh state — run "unbound login" to start over.`);
|
|
1239
1269
|
} else {
|
|
1240
|
-
output.warn(`Done ${scope}, but ${failed.length} step(s) reported issues: ${failed.join(', ')}
|
|
1270
|
+
output.warn(`Done ${scope}, but ${failed.length} step(s) reported issues: ${failed.join(', ')}.`);
|
|
1271
|
+
output.info('Your CLI login credentials and settings (~/.unbound/config.json) were removed.');
|
|
1272
|
+
if (failed.includes('scheduled scan')) {
|
|
1273
|
+
// The scheduled scan keeps its own stored key (Keychain / creds file /
|
|
1274
|
+
// Credential Manager). If its teardown reported an issue, don't imply
|
|
1275
|
+
// that key is gone — point at the command that clears it.
|
|
1276
|
+
output.warn('The scheduled scan may still be registered with its own stored API key — re-run `unbound discover unschedule` to remove it.');
|
|
1277
|
+
}
|
|
1241
1278
|
}
|
|
1242
1279
|
} catch (err) {
|
|
1243
1280
|
output.error(err.message);
|
|
@@ -1312,6 +1349,7 @@ module.exports = {
|
|
|
1312
1349
|
failureReason,
|
|
1313
1350
|
NUKE_ENV_VARS,
|
|
1314
1351
|
removeBinaryInstall,
|
|
1352
|
+
removeScheduledRun,
|
|
1315
1353
|
runBinaryClear,
|
|
1316
1354
|
BINARY_INSTALL_PATHS,
|
|
1317
1355
|
};
|
package/src/scheduled.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { spawn } = require('child_process');
|
|
1
|
+
const { spawn, execFileSync } = require('child_process');
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const os = require('os');
|
|
@@ -86,49 +86,367 @@ async function setupScheduledRunWindows(opts) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Ceiling on any single teardown command. Nuke clears the stored credentials as
|
|
90
|
+
// part of the wipe, so a wedged launchctl / security / systemctl / crontab must
|
|
91
|
+
// never stall it — on timeout we kill the command's whole process group.
|
|
92
|
+
const SCHEDULED_TEARDOWN_TIMEOUT_MS = 120000;
|
|
93
|
+
|
|
94
|
+
// The scheduled-scan artifacts, matching what coding-discovery-tool's
|
|
95
|
+
// setup-scheduled-scan.{sh,ps1} create. Kept in sync with that script.
|
|
96
|
+
const SCHEDULED_LABEL = 'ai.getunbound.scheduled';
|
|
97
|
+
const CRON_MARKER_TAG = '# ai.getunbound.scheduled';
|
|
98
|
+
// The Keychain accounts store_credentials_macos writes under SCHEDULED_LABEL.
|
|
99
|
+
const KEYCHAIN_ACCOUNTS = ['command', 'api_key', 'discovery_key', 'domain'];
|
|
100
|
+
|
|
101
|
+
// Run a command, rejecting on error, non-zero exit, or timeout. On timeout the
|
|
102
|
+
// whole process group is killed (`detached` + `kill(-pid)`), so a wedged grandchild
|
|
103
|
+
// (launchctl/security/systemctl/crontab) dies too instead of outliving nuke.
|
|
104
|
+
function runCommand(cmd, args, options, timeoutMs, label) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const child = spawn(cmd, args, { ...options, detached: true });
|
|
107
|
+
let timedOut = false;
|
|
108
|
+
const killGroup = (signal) => {
|
|
109
|
+
try { process.kill(-child.pid, signal); }
|
|
110
|
+
catch { try { child.kill(signal); } catch { /* already gone */ } }
|
|
111
|
+
};
|
|
112
|
+
const timer = setTimeout(() => { timedOut = true; killGroup('SIGKILL'); }, timeoutMs);
|
|
113
|
+
child.on('close', (code) => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
if (timedOut) reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
116
|
+
else if (code === 0) resolve();
|
|
117
|
+
else reject(new Error(`${label} exited ${code}`));
|
|
118
|
+
});
|
|
119
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// dscl prints the home after "NFSHomeDirectory:". Root lists two absolute paths
|
|
124
|
+
// (/var/root /private/var/root) → take the first; otherwise keep the value whole,
|
|
125
|
+
// since a home can contain spaces (/Users/John Doe).
|
|
126
|
+
function parseDsclHome(raw) {
|
|
127
|
+
const parts = raw.split(/\s+/);
|
|
128
|
+
return (parts.length > 1 && parts.every((p) => p.startsWith('/'))) ? parts[0] : raw;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Look a user's home up by argv (never a shell string built from the name).
|
|
132
|
+
function homeOf(name, platform) {
|
|
133
|
+
try {
|
|
134
|
+
if (platform === 'darwin') {
|
|
135
|
+
const out = execFileSync('dscl', ['.', '-read', `/Users/${name}`, 'NFSHomeDirectory'], { encoding: 'utf8' });
|
|
136
|
+
const h = parseDsclHome((out.split(':')[1] || '').trim());
|
|
137
|
+
if (h) return h;
|
|
138
|
+
} else {
|
|
139
|
+
const out = execFileSync('getent', ['passwd', name], { encoding: 'utf8' });
|
|
140
|
+
const h = (out.split(':')[5] || '').trim();
|
|
141
|
+
if (h) return h;
|
|
142
|
+
}
|
|
143
|
+
} catch { /* fall through */ }
|
|
144
|
+
if (name === 'root') return platform === 'darwin' ? '/var/root' : '/root';
|
|
145
|
+
return platform === 'darwin' ? `/Users/${name}` : `/home/${name}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The logged-in console user when we are already root with no SUDO_USER (MDM, su,
|
|
149
|
+
// a launchd job). null when there is none — a headless-root context.
|
|
150
|
+
function consoleUser(platform) {
|
|
151
|
+
try {
|
|
152
|
+
const u = platform === 'darwin'
|
|
153
|
+
? execFileSync('stat', ['-f', '%Su', '/dev/console'], { encoding: 'utf8' }).trim()
|
|
154
|
+
: execFileSync('logname', [], { encoding: 'utf8' }).trim();
|
|
155
|
+
if (u && u !== 'root') return u;
|
|
156
|
+
} catch { /* no console user */ }
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The user whose scheduled scan to tear down. The scan is a per-user artifact, so
|
|
161
|
+
// as root we target the invoking user — SUDO_USER, or the console user when
|
|
162
|
+
// already root without it — not root. A sudo install can also leave copies in
|
|
163
|
+
// root's home, which the caller clears too.
|
|
164
|
+
function resolveScheduledUser(env, platform) {
|
|
165
|
+
const amRoot = platform !== 'win32' && typeof process.getuid === 'function' && process.getuid() === 0;
|
|
166
|
+
if (amRoot) {
|
|
167
|
+
const name = (env.SUDO_USER && env.SUDO_USER !== 'root') ? env.SUDO_USER : consoleUser(platform);
|
|
168
|
+
if (name) {
|
|
169
|
+
let uid = '';
|
|
170
|
+
try { uid = execFileSync('id', ['-u', name], { encoding: 'utf8' }).trim(); } catch { /* best-effort */ }
|
|
171
|
+
return { username: name, home: homeOf(name, platform), uid, elevated: true };
|
|
172
|
+
}
|
|
173
|
+
// Headless root, no target user: clear root's own copies, but flag that we
|
|
174
|
+
// could not target a real user's scan so nuke warns instead of claiming clean.
|
|
175
|
+
return { username: 'root', home: homeOf('root', platform), uid: '0', elevated: false, unresolvedUser: true };
|
|
176
|
+
}
|
|
177
|
+
const uid = (platform !== 'win32' && typeof process.getuid === 'function') ? String(process.getuid()) : '';
|
|
178
|
+
return { username: null, home: os.homedir(), uid, elevated: false };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Run a command in the target user's session when we are elevated. `sudo -u`
|
|
182
|
+
// alone does NOT enter the user's GUI bootstrap or unlock their login keychain,
|
|
183
|
+
// so macOS keychain/launchctl work is wrapped in `launchctl asuser <uid>`; Linux
|
|
184
|
+
// `systemctl --user` needs the user's runtime bus (XDG_RUNTIME_DIR).
|
|
185
|
+
function asUserCmd(user, platform, cmd, args) {
|
|
186
|
+
if (!user.elevated) return { cmd, args };
|
|
187
|
+
if (platform === 'darwin') {
|
|
188
|
+
return { cmd: 'launchctl', args: ['asuser', user.uid, 'sudo', '-u', user.username, cmd, ...args] };
|
|
189
|
+
}
|
|
190
|
+
return { cmd: 'sudo', args: ['-u', user.username, 'env', `XDG_RUNTIME_DIR=/run/user/${user.uid}`, cmd, ...args] };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Ordered macOS/Linux teardown steps. Credentials are always removed and then
|
|
194
|
+
// verified, and when elevated both the invoking user's and root's copies are torn
|
|
195
|
+
// down (a sudo install can leave the key/job in root's home too).
|
|
196
|
+
function unixTeardownSteps(user, platform) {
|
|
197
|
+
const { home, uid, elevated } = user;
|
|
198
|
+
const steps = [];
|
|
199
|
+
if (platform === 'darwin') {
|
|
200
|
+
// Root can boot a LaunchAgent out of another user's GUI domain directly by
|
|
201
|
+
// naming gui/<uid>; the invoking user boots out their own. No sudo prefix.
|
|
202
|
+
if (uid) {
|
|
203
|
+
steps.push({ label: 'stop LaunchAgent', kind: 'cmd', cmd: 'launchctl', args: ['bootout', `gui/${uid}/${SCHEDULED_LABEL}`], ignoreFail: true });
|
|
204
|
+
}
|
|
205
|
+
// A sudo install bootstraps into root's GUI domain (gui/0), so boot that out too.
|
|
206
|
+
if (elevated) {
|
|
207
|
+
steps.push({ label: 'stop LaunchAgent (root)', kind: 'cmd', cmd: 'launchctl', args: ['bootout', `gui/0/${SCHEDULED_LABEL}`], ignoreFail: true });
|
|
208
|
+
}
|
|
209
|
+
steps.push({ label: 'remove plist', kind: 'file', target: path.join(home, 'Library/LaunchAgents', `${SCHEDULED_LABEL}.plist`) });
|
|
210
|
+
// Clear the key from the user's login keychain (via asuser when elevated), and
|
|
211
|
+
// when elevated from root's keychain too — `sudo onboard --set-cron` runs
|
|
212
|
+
// `security` as root, so an elevated install leaves a copy there.
|
|
213
|
+
for (const scope of elevated ? ['user', 'root'] : ['user']) {
|
|
214
|
+
const wrapKC = (args) => (scope === 'user' ? asUserCmd(user, platform, 'security', args) : { cmd: 'security', args });
|
|
215
|
+
for (const acct of KEYCHAIN_ACCOUNTS) {
|
|
216
|
+
// Best-effort: `security delete` exits 44 when the item is already absent
|
|
217
|
+
// (success). Honest reporting comes from the verify below, not this code.
|
|
218
|
+
const d = wrapKC(['delete-generic-password', '-s', SCHEDULED_LABEL, '-a', acct]);
|
|
219
|
+
steps.push({ label: `remove keychain ${acct} (${scope})`, kind: 'cmd', ...d, ignoreFail: true });
|
|
220
|
+
}
|
|
221
|
+
const f = wrapKC(['find-generic-password', '-s', SCHEDULED_LABEL, '-a', 'api_key']);
|
|
222
|
+
steps.push({ label: `verify api key removed (${scope})`, kind: 'verify', probeKind: 'securityFind', ...f, credential: true });
|
|
223
|
+
}
|
|
224
|
+
steps.push({ label: 'remove install dir', kind: 'file', target: path.join(home, '.local/share/unbound'), recursive: true });
|
|
225
|
+
if (elevated) {
|
|
226
|
+
// A sudo install can write the plist and install dir under root's home too.
|
|
227
|
+
const rootHome = homeOf('root', platform);
|
|
228
|
+
steps.push({ label: 'remove plist (root)', kind: 'file', target: path.join(rootHome, 'Library/LaunchAgents', `${SCHEDULED_LABEL}.plist`) });
|
|
229
|
+
steps.push({ label: 'remove install dir (root)', kind: 'file', target: path.join(rootHome, '.local/share/unbound'), recursive: true });
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
// A sudo install runs as root with HOME=/root, so its job + key land there —
|
|
233
|
+
// tear down the invoking user's copies and, when elevated, root's as well.
|
|
234
|
+
steps.push(...linuxScopeSteps(user, platform, 'user'));
|
|
235
|
+
if (elevated) {
|
|
236
|
+
steps.push(...linuxScopeSteps({ home: homeOf('root', platform), uid: '0', username: 'root', elevated: false }, platform, 'root'));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return steps;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The per-scope Linux teardown under one home: systemd --user timer, cron entry,
|
|
243
|
+
// creds file (verified gone), and install dir.
|
|
244
|
+
function linuxScopeSteps(scopeUser, platform, scope) {
|
|
245
|
+
const { home } = scopeUser;
|
|
246
|
+
const credsFile = path.join(home, '.unbound/scheduled-creds.json');
|
|
247
|
+
return [
|
|
248
|
+
{ label: `disable systemd timer (${scope})`, kind: 'cmd', ...asUserCmd(scopeUser, platform, 'systemctl', ['--user', 'disable', '--now', 'unbound-scheduled.timer']), ignoreFail: true },
|
|
249
|
+
{ label: `remove systemd timer (${scope})`, kind: 'file', target: path.join(home, '.config/systemd/user/unbound-scheduled.timer') },
|
|
250
|
+
{ label: `remove systemd service (${scope})`, kind: 'file', target: path.join(home, '.config/systemd/user/unbound-scheduled.service') },
|
|
251
|
+
{ label: `reload systemd (${scope})`, kind: 'cmd', ...asUserCmd(scopeUser, platform, 'systemctl', ['--user', 'daemon-reload']), ignoreFail: true },
|
|
252
|
+
{ label: `remove cron entry (${scope})`, kind: 'cron', user: scopeUser },
|
|
253
|
+
{ label: `remove credentials file (${scope})`, kind: 'file', target: credsFile, credential: true },
|
|
254
|
+
{ label: `verify credentials file removed (${scope})`, kind: 'verify', probeKind: 'fileExists', target: credsFile, credential: true },
|
|
255
|
+
{ label: `remove install dir (${scope})`, kind: 'file', target: path.join(home, '.local/share/unbound'), recursive: true },
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function windowsTeardownSteps(env) {
|
|
260
|
+
const steps = [{
|
|
261
|
+
label: 'remove scheduled task', kind: 'cmd',
|
|
262
|
+
cmd: 'schtasks', args: ['/Delete', '/TN', SCHEDULED_LABEL, '/F'], ignoreFail: true,
|
|
263
|
+
}];
|
|
264
|
+
// `cmdkey /delete` exits non-zero when the target is already absent (success),
|
|
265
|
+
// so the deletes are best-effort; the verify below confirms the key is gone.
|
|
266
|
+
for (const acct of KEYCHAIN_ACCOUNTS) {
|
|
267
|
+
steps.push({
|
|
268
|
+
label: `remove credential ${acct}`, kind: 'cmd',
|
|
269
|
+
cmd: 'cmdkey', args: [`/delete:${SCHEDULED_LABEL}:${acct}`], ignoreFail: true,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
// `cmdkey /list` prints a "Target: ...:api_key" line only while that credential
|
|
273
|
+
// still exists, so its absence from the output confirms removal.
|
|
274
|
+
steps.push({
|
|
275
|
+
label: 'verify api key removed', kind: 'verify', probeKind: 'cmdkeyList',
|
|
276
|
+
cmd: 'cmdkey', args: ['/list'], match: `${SCHEDULED_LABEL}:api_key`, credential: true,
|
|
277
|
+
});
|
|
278
|
+
const local = env.LOCALAPPDATA || (env.USERPROFILE ? path.join(env.USERPROFILE, 'AppData', 'Local') : null);
|
|
279
|
+
if (local) {
|
|
280
|
+
steps.push({ label: 'remove install dir', kind: 'file', target: path.join(local, 'Unbound'), recursive: true });
|
|
281
|
+
}
|
|
282
|
+
return steps;
|
|
283
|
+
}
|
|
284
|
+
|
|
89
285
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
286
|
+
* Remove the scheduled scan and its stored credentials LOCALLY — no download, so
|
|
287
|
+
* it works fully offline (an offboarding handover with no network is exactly when
|
|
288
|
+
* the key must not be left behind). Runs each artifact's teardown directly:
|
|
289
|
+
* launchd/crontab/systemd/Task-Scheduler for the job, Keychain / creds-file /
|
|
290
|
+
* Credential-Manager for the key, in the invoking user's context (SUDO_USER-aware
|
|
291
|
+
* under sudo). Credential removal always runs, whatever the job teardown does.
|
|
292
|
+
*
|
|
293
|
+
* Rejects if a credential-bearing step fails (so nuke never reports the key gone
|
|
294
|
+
* when it isn't); a best-effort job step that fails because the artifact was
|
|
295
|
+
* already absent is ignored. `run`/`fileRm`/`cron`/`timeoutMs`/`env`/`platform`
|
|
296
|
+
* are injectable for hermetic tests; callers (nuke, `discover unschedule`,
|
|
297
|
+
* `onboard unschedule`) invoke it with no args.
|
|
93
298
|
*/
|
|
94
|
-
async function uninstallScheduledRun(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
299
|
+
async function uninstallScheduledRun({
|
|
300
|
+
run = runCommand,
|
|
301
|
+
fileRm = defaultFileRm,
|
|
302
|
+
cron = defaultCronRemove,
|
|
303
|
+
probe = defaultProbe,
|
|
304
|
+
resolveUser = resolveScheduledUser,
|
|
305
|
+
timeoutMs = SCHEDULED_TEARDOWN_TIMEOUT_MS,
|
|
306
|
+
env = process.env,
|
|
307
|
+
platform = process.platform,
|
|
308
|
+
} = {}) {
|
|
309
|
+
const user = resolveUser(env, platform);
|
|
310
|
+
const steps = platform === 'win32' ? windowsTeardownSteps(env) : unixTeardownSteps(user, platform);
|
|
311
|
+
const failures = [];
|
|
312
|
+
for (const step of steps) {
|
|
98
313
|
try {
|
|
99
|
-
|
|
100
|
-
await
|
|
101
|
-
}
|
|
102
|
-
|
|
314
|
+
if (step.kind === 'cmd') {
|
|
315
|
+
await run(step.cmd, step.args, { stdio: 'ignore' }, timeoutMs, step.label);
|
|
316
|
+
} else if (step.kind === 'file') {
|
|
317
|
+
await fileRm(step.target, { recursive: step.recursive });
|
|
318
|
+
} else if (step.kind === 'cron') {
|
|
319
|
+
await cron(step.user, timeoutMs, platform);
|
|
320
|
+
} else if (step.kind === 'verify') {
|
|
321
|
+
// Only a confirmed 'absent' clears the step; 'present' or 'unknown' (the
|
|
322
|
+
// check could not run) must not be read as removed.
|
|
323
|
+
const status = await probe(step, timeoutMs);
|
|
324
|
+
if (status !== 'absent') {
|
|
325
|
+
throw new Error(status === 'present' ? 'still present after removal' : 'could not verify removal');
|
|
326
|
+
}
|
|
103
327
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
{ stdio: 'inherit', shell: false, windowsHide: true }
|
|
109
|
-
);
|
|
110
|
-
child.on('close', (code) => {
|
|
111
|
-
if (code === 0) resolve();
|
|
112
|
-
else reject(new Error(`Scheduled-run uninstall failed with exit code ${code}`));
|
|
113
|
-
});
|
|
114
|
-
child.on('error', reject);
|
|
115
|
-
});
|
|
116
|
-
} finally {
|
|
117
|
-
try { fs.unlinkSync(tmp); } catch { /* best-effort */ }
|
|
328
|
+
} catch (err) {
|
|
329
|
+
// A best-effort job step usually failed because the artifact was already
|
|
330
|
+
// gone; a credential step failing means the key may still be on disk.
|
|
331
|
+
if (step.credential || !step.ignoreFail) failures.push(`${step.label}: ${err.message}`);
|
|
118
332
|
}
|
|
119
|
-
return;
|
|
120
333
|
}
|
|
334
|
+
if (user.unresolvedUser) {
|
|
335
|
+
failures.push('could not determine the invoking user — run as that user or with sudo so their scan is torn down');
|
|
336
|
+
}
|
|
337
|
+
if (failures.length) {
|
|
338
|
+
throw new Error(`scheduled-scan teardown incomplete — ${failures.join('; ')}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
121
341
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
342
|
+
// A verify probe → 'present' | 'absent' | 'unknown'. Only 'absent' is success.
|
|
343
|
+
async function defaultProbe(step, timeoutMs) {
|
|
344
|
+
if (step.probeKind === 'fileExists') {
|
|
345
|
+
return fs.existsSync(step.target) ? 'present' : 'absent';
|
|
346
|
+
}
|
|
347
|
+
if (step.probeKind === 'securityFind') {
|
|
348
|
+
// exit 0 = item exists; 44 = gone. "No default keychain" (common for root)
|
|
349
|
+
// also means nothing to find, so treat as absent; a lookup that did not run
|
|
350
|
+
// (null) or any other status is unknown.
|
|
351
|
+
const { code, stderr } = await runForCode(step.cmd, step.args, timeoutMs);
|
|
352
|
+
if (code === null) return 'unknown';
|
|
353
|
+
if (code === 0) return 'present';
|
|
354
|
+
if (code === 44) return 'absent';
|
|
355
|
+
if (/no default keychain|default keychain could not be found/i.test(stderr || '')) return 'absent';
|
|
356
|
+
return 'unknown';
|
|
357
|
+
}
|
|
358
|
+
if (step.probeKind === 'cmdkeyList') {
|
|
359
|
+
// `cmdkey /list` exits 0 and prints a "Target: ...:api_key" line only while
|
|
360
|
+
// the credential exists; a non-zero exit means the list did not run, so its
|
|
361
|
+
// empty output must not be read as "absent".
|
|
362
|
+
const out = await captureAllowFail(step.cmd, step.args, timeoutMs);
|
|
363
|
+
if (out === null) return 'unknown';
|
|
364
|
+
return out.includes(step.match) ? 'present' : 'absent';
|
|
365
|
+
}
|
|
366
|
+
return 'unknown';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Remove a path (file, or a directory when `recursive`), tolerating absence.
|
|
370
|
+
async function defaultFileRm(target, { recursive } = {}) {
|
|
371
|
+
await fs.promises.rm(target, { force: true, recursive: Boolean(recursive) });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Rewrite the target user's crontab without the Unbound scheduled-scan lines.
|
|
375
|
+
// Reads the current table, drops the marker-tagged lines, writes it back — a
|
|
376
|
+
// no-op when there is no crontab or no such line.
|
|
377
|
+
async function defaultCronRemove(user, timeoutMs, platform) {
|
|
378
|
+
const listCmd = asUserCmd(user, platform, 'crontab', ['-l']);
|
|
379
|
+
let current;
|
|
380
|
+
try {
|
|
381
|
+
current = await captureCommand(listCmd.cmd, listCmd.args, timeoutMs);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
// "no crontab for <user>" is an empty table — nothing to remove. Any other
|
|
384
|
+
// failure (sudo/asuser error, timeout) must surface, not read as empty.
|
|
385
|
+
if (err && /no crontab for/i.test(err.stderr || '')) return;
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
388
|
+
const kept = current.split('\n').filter((line) => !line.includes(CRON_MARKER_TAG));
|
|
389
|
+
if (kept.join('\n') === current) return; // nothing tagged
|
|
390
|
+
const writeCmd = asUserCmd(user, platform, 'crontab', ['-']);
|
|
391
|
+
await feedCommand(writeCmd.cmd, writeCmd.args, kept.join('\n').replace(/\n+$/, '') + '\n', timeoutMs);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Resolve with { code, stderr }; code is null on spawn error / timeout.
|
|
395
|
+
function runForCode(cmd, args, timeoutMs) {
|
|
396
|
+
return new Promise((resolve) => {
|
|
397
|
+
let err = '';
|
|
398
|
+
const child = spawn(cmd, args, { detached: true, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
399
|
+
const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } resolve({ code: null, stderr: err }); }, timeoutMs);
|
|
400
|
+
child.stderr.on('data', (d) => { err += d; });
|
|
401
|
+
child.on('close', (code) => { clearTimeout(timer); resolve({ code, stderr: err }); });
|
|
402
|
+
child.on('error', () => { clearTimeout(timer); resolve({ code: null, stderr: err }); });
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Capture stdout on a clean (exit 0) run, or null if the command errored,
|
|
407
|
+
// timed out, or exited non-zero — an incomplete listing must not read as absent.
|
|
408
|
+
function captureAllowFail(cmd, args, timeoutMs) {
|
|
409
|
+
return new Promise((resolve) => {
|
|
410
|
+
let out = '';
|
|
411
|
+
const child = spawn(cmd, args, { detached: true });
|
|
412
|
+
const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* gone */ } resolve(null); }, timeoutMs);
|
|
413
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
414
|
+
child.on('close', (code) => { clearTimeout(timer); resolve(code === 0 ? out : null); });
|
|
415
|
+
child.on('error', () => { clearTimeout(timer); resolve(null); });
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function captureCommand(cmd, args, timeoutMs) {
|
|
420
|
+
return new Promise((resolve, reject) => {
|
|
421
|
+
const child = spawn(cmd, args, { detached: true });
|
|
422
|
+
let out = ''; let err = '';
|
|
423
|
+
const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); } reject(new Error('crontab -l timed out')); }, timeoutMs);
|
|
424
|
+
child.stdout.on('data', (d) => { out += d; });
|
|
425
|
+
child.stderr.on('data', (d) => { err += d; });
|
|
426
|
+
child.on('close', (code) => { clearTimeout(timer); code === 0 ? resolve(out) : reject(Object.assign(new Error(`crontab -l exited ${code}`), { stderr: err })); });
|
|
427
|
+
child.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function feedCommand(cmd, args, input, timeoutMs) {
|
|
432
|
+
return new Promise((resolve, reject) => {
|
|
433
|
+
const child = spawn(cmd, args, { detached: true });
|
|
434
|
+
const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); } }, timeoutMs);
|
|
435
|
+
child.on('close', (code) => { clearTimeout(timer); code === 0 ? resolve() : reject(new Error(`crontab - exited ${code}`)); });
|
|
436
|
+
child.on('error', (err) => { clearTimeout(timer); reject(err); });
|
|
437
|
+
child.stdin.end(input);
|
|
131
438
|
});
|
|
132
439
|
}
|
|
133
440
|
|
|
134
|
-
module.exports = {
|
|
441
|
+
module.exports = {
|
|
442
|
+
setupScheduledRun,
|
|
443
|
+
uninstallScheduledRun,
|
|
444
|
+
resolveScheduledUser,
|
|
445
|
+
asUserCmd,
|
|
446
|
+
unixTeardownSteps,
|
|
447
|
+
windowsTeardownSteps,
|
|
448
|
+
runCommand,
|
|
449
|
+
defaultProbe,
|
|
450
|
+
homeOf,
|
|
451
|
+
parseDsclHome,
|
|
452
|
+
};
|
package/src/toolHealth.js
CHANGED
|
@@ -192,11 +192,17 @@ function envKeyCheck(label, name, kind = 'aux') {
|
|
|
192
192
|
if (!r.found || !r.value) {
|
|
193
193
|
return { name: label, ok: false, kind, detail: `${name} is not set`, summary: `${base} not set` };
|
|
194
194
|
}
|
|
195
|
+
// Offer the rc-file keys as fallbacks so a shell opened before setup rewrote
|
|
196
|
+
// the rc (stale key in process.env) isn't misread as tampered. Only for
|
|
197
|
+
// Unbound-owned vars: a user-owned var like OPENAI_API_KEY may carry the
|
|
198
|
+
// user's personal key beside ours, and doctor must never upload that.
|
|
199
|
+
const rcFallback = name.startsWith('UNBOUND_') ? rcEnvValues(name).map((v) => v.value) : [];
|
|
200
|
+
const candidates = [...new Set([r.value, ...rcFallback].filter(Boolean))];
|
|
195
201
|
return {
|
|
196
202
|
name: label, ok: true, kind,
|
|
197
203
|
detail: `${name} set (${r.source})`,
|
|
198
204
|
summary: `${base} set`,
|
|
199
|
-
keyCheck: true, envName: name, value: r.value,
|
|
205
|
+
keyCheck: true, envName: name, value: r.value, candidates,
|
|
200
206
|
};
|
|
201
207
|
}
|
|
202
208
|
|
|
@@ -482,43 +488,53 @@ function detectTools({ gatewayUrl, _mdmDirs, _binaryPath } = {}) {
|
|
|
482
488
|
// client can never wipe out the local-state view callers depend on.
|
|
483
489
|
async function validateToolKeys(tools, validateKey) {
|
|
484
490
|
if (!tools?.length) return tools;
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
491
|
+
const verdict = new Map();
|
|
492
|
+
const check = async (keys) => {
|
|
493
|
+
const pending = [...keys].filter((k) => k && !verdict.has(k));
|
|
494
|
+
for (const [k, r] of await Promise.all(pending.map(async (k) => {
|
|
495
|
+
try { return [k, await validateKey(k)]; } catch { return [k, 'unknown']; }
|
|
496
|
+
}))) verdict.set(k, r);
|
|
497
|
+
};
|
|
498
|
+
// Validate the live value first; consult the rc-file fallbacks only when it is
|
|
499
|
+
// rejected. Avoids needless calls on the healthy path and, with the
|
|
500
|
+
// UNBOUND_-only candidate rule above, keeps rc siblings off the wire until a
|
|
501
|
+
// stale shell forces the search.
|
|
502
|
+
const live = new Set();
|
|
503
|
+
for (const t of tools) for (const c of t.checks || []) if (c.keyCheck && c.value) live.add(c.value);
|
|
504
|
+
if (!live.size) return tools;
|
|
505
|
+
await check(live);
|
|
506
|
+
const fallbacks = new Set();
|
|
507
|
+
for (const t of tools) for (const c of t.checks || []) {
|
|
508
|
+
if (c.keyCheck && c.value && verdict.get(c.value) === 'invalid') {
|
|
509
|
+
for (const v of c.candidates || []) if (v !== c.value) fallbacks.add(v);
|
|
489
510
|
}
|
|
490
511
|
}
|
|
491
|
-
|
|
492
|
-
const entries = await Promise.all(
|
|
493
|
-
[...unique].map(async (k) => {
|
|
494
|
-
try {
|
|
495
|
-
return [k, await validateKey(k)];
|
|
496
|
-
} catch {
|
|
497
|
-
return [k, 'unknown'];
|
|
498
|
-
}
|
|
499
|
-
})
|
|
500
|
-
);
|
|
501
|
-
const validity = new Map(entries);
|
|
512
|
+
await check(fallbacks);
|
|
502
513
|
for (const t of tools) {
|
|
503
514
|
if (!t.checks?.length) continue;
|
|
504
515
|
let okFlipped = false;
|
|
505
516
|
for (const c of t.checks) {
|
|
506
|
-
if (!c.keyCheck) continue;
|
|
507
|
-
const
|
|
508
|
-
if (
|
|
517
|
+
if (!c.keyCheck || !c.value) continue;
|
|
518
|
+
const liveVerdict = verdict.get(c.value);
|
|
519
|
+
if (liveVerdict === 'valid') continue;
|
|
520
|
+
// rc fallbacks were fetched only when the live key was invalid.
|
|
521
|
+
const others = liveVerdict === 'invalid'
|
|
522
|
+
? (c.candidates || []).filter((v) => v !== c.value).map((v) => verdict.get(v))
|
|
523
|
+
: [];
|
|
524
|
+
if (others.includes('valid')) continue; // a configured rc key is valid — wiring intact
|
|
525
|
+
if (liveVerdict !== 'invalid' || others.includes('unknown')) {
|
|
526
|
+
// Gateway unreachable for the live key, or for the only surviving rc
|
|
527
|
+
// candidates — fail open so an offline run isn't read as tampered.
|
|
528
|
+
c.validationSkipped = true;
|
|
529
|
+
c.detail = `${c.detail}; validation skipped — gateway unreachable`;
|
|
530
|
+
} else {
|
|
531
|
+
// Live key rejected and no configured key is valid — genuinely invalid.
|
|
509
532
|
c.ok = false;
|
|
510
533
|
c.warn = true;
|
|
511
534
|
const base = c.name.replace(/ env$/, '');
|
|
512
535
|
c.summary = `${base} invalid`;
|
|
513
536
|
c.detail = `${c.envName} set but not valid for this tenant`;
|
|
514
537
|
okFlipped = true;
|
|
515
|
-
} else if (verdict === 'unknown') {
|
|
516
|
-
// Stay healthy (fail-open), but record the skip so the caller can
|
|
517
|
-
// surface it. Without this tag the operator can't tell an offline
|
|
518
|
-
// run from a fully-validated one — that's the WEB-4922 lesson.
|
|
519
|
-
// Does NOT flip `ok`, so the `statusOf` re-derivation stays guarded.
|
|
520
|
-
c.validationSkipped = true;
|
|
521
|
-
c.detail = `${c.detail}; validation skipped — gateway unreachable`;
|
|
522
538
|
}
|
|
523
539
|
}
|
|
524
540
|
// ONLY re-derive when a check's `ok` actually flipped. Without this guard,
|
package/test/setup-args.test.js
CHANGED
|
@@ -3,7 +3,9 @@ const assert = require('node:assert/strict');
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, failureReason, NUKE_ENV_VARS, removeBinaryInstall, runBinaryClear, BINARY_INSTALL_PATHS, configDirForScript } = require('../src/commands/setup');
|
|
6
|
+
const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, failureReason, NUKE_ENV_VARS, removeBinaryInstall, removeScheduledRun, runBinaryClear, BINARY_INSTALL_PATHS, configDirForScript } = require('../src/commands/setup');
|
|
7
|
+
const scheduled = require('../src/scheduled');
|
|
8
|
+
const { uninstallScheduledRun } = scheduled;
|
|
7
9
|
|
|
8
10
|
// shellEscape single-quotes every value, so a real key surfaces as
|
|
9
11
|
// --api-key '<key>' at the head of the argv tail.
|
|
@@ -661,3 +663,307 @@ test('configDirForScript: returns undefined when CLAUDE_CONFIG_DIR is unset (def
|
|
|
661
663
|
if (prev !== undefined) process.env.CLAUDE_CONFIG_DIR = prev;
|
|
662
664
|
}
|
|
663
665
|
});
|
|
666
|
+
|
|
667
|
+
// `unbound nuke` must also tear down the scheduled discovery scan and its stored
|
|
668
|
+
// API key; before the fix it never called the teardown, so a scan kept running
|
|
669
|
+
// and a long-lived key stayed on the machine after "remove Unbound entirely".
|
|
670
|
+
|
|
671
|
+
// Behavioral: removeScheduledRun runs the teardown, and the teardown's removal
|
|
672
|
+
// lands — no scheduled agent (job marker) and no stored key file remain. The
|
|
673
|
+
// injected `uninstall` stands in for uninstallScheduledRun's OS/network work so
|
|
674
|
+
// the test is hermetic while still proving the removal reaches disk.
|
|
675
|
+
test('removeScheduledRun: runs teardown — leaves no scheduled agent and no stored key', async () => {
|
|
676
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-sched-'));
|
|
677
|
+
try {
|
|
678
|
+
const agentMarker = path.join(tmp, 'ai.getunbound.scheduled.plist'); // stands in for launchd/cron/Task Scheduler job
|
|
679
|
+
const credsFile = path.join(tmp, 'scheduled-creds.json'); // stands in for the stored API key
|
|
680
|
+
fs.writeFileSync(agentMarker, '<plist/>');
|
|
681
|
+
fs.writeFileSync(credsFile, JSON.stringify({ apiKey: 'sk-live-should-be-wiped' }));
|
|
682
|
+
|
|
683
|
+
let called = 0;
|
|
684
|
+
const failed = await removeScheduledRun({
|
|
685
|
+
uninstall: async () => {
|
|
686
|
+
called++;
|
|
687
|
+
fs.rmSync(agentMarker, { force: true });
|
|
688
|
+
fs.rmSync(credsFile, { force: true });
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
assert.equal(called, 1, 'nuke must invoke the scheduled-run teardown exactly once');
|
|
693
|
+
assert.deepEqual(failed, [], 'a clean teardown reports no failures');
|
|
694
|
+
assert.ok(!fs.existsSync(agentMarker), 'the scheduled agent must be gone after nuke');
|
|
695
|
+
assert.ok(!fs.existsSync(credsFile), 'the stored API key must be gone after nuke');
|
|
696
|
+
} finally {
|
|
697
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// Resilience: a partial teardown failure must land in the returned `failed` list
|
|
702
|
+
// (so nuke's summary reports it) and must NOT throw — nuke has to continue and
|
|
703
|
+
// still wipe credentials, exactly like removeBinaryInstall's failure path.
|
|
704
|
+
test('removeScheduledRun: teardown failure surfaces in `failed`, does not throw', async () => {
|
|
705
|
+
const failed = await removeScheduledRun({
|
|
706
|
+
uninstall: async () => { throw new Error('launchctl bootout failed'); },
|
|
707
|
+
});
|
|
708
|
+
assert.deepEqual(failed, ['scheduled scan'], 'a teardown failure is reported, not swallowed silently');
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
// Wiring: the nuke action must actually call removeScheduledRun and fold its
|
|
712
|
+
// result into the closing summary, so the teardown can never be silently
|
|
713
|
+
// dropped by a refactor. Source-parsed like the onboard cron wiring tests.
|
|
714
|
+
test('nuke action wires in removeScheduledRun and folds it into the failure summary', () => {
|
|
715
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'commands', 'setup.js'), 'utf8');
|
|
716
|
+
const actionStart = src.indexOf(".command('nuke')");
|
|
717
|
+
assert.notEqual(actionStart, -1, "expected a nuke command in setup.js");
|
|
718
|
+
const actionSrc = src.slice(actionStart, actionStart + 9000);
|
|
719
|
+
assert.match(actionSrc, /const\s+scheduledFailed\s*=\s*await\s+removeScheduledRun\(\)/, 'nuke must await removeScheduledRun()');
|
|
720
|
+
assert.match(actionSrc, /\.\.\.scheduledFailed/, 'nuke must fold scheduledFailed into the failed summary');
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
// The teardown is LOCAL — no download, so it works fully offline (an offboarding
|
|
724
|
+
// handover with no network is exactly when a stored key must not be left behind).
|
|
725
|
+
// These inject the OS-command / file / cron boundary so they stay hermetic while
|
|
726
|
+
// asserting the real step set uninstallScheduledRun runs on each platform.
|
|
727
|
+
|
|
728
|
+
// Records what the teardown would run without touching the real system.
|
|
729
|
+
// `verifyPresent(step)` decides whether a verify reports the key still there; by
|
|
730
|
+
// default every verify reports 'absent' (the good case).
|
|
731
|
+
function recordTeardown(opts) {
|
|
732
|
+
const cmds = []; const files = []; const cronCalls = []; const probes = [];
|
|
733
|
+
const failIf = opts.failIf || (() => false);
|
|
734
|
+
const p = scheduled.uninstallScheduledRun({
|
|
735
|
+
platform: opts.platform,
|
|
736
|
+
env: opts.env || {},
|
|
737
|
+
run: async (cmd, args) => {
|
|
738
|
+
const line = [cmd, ...args].join(' ');
|
|
739
|
+
cmds.push(line);
|
|
740
|
+
if (failIf(line)) throw new Error(`stub failure: ${line}`);
|
|
741
|
+
},
|
|
742
|
+
fileRm: async (target, o) => { files.push({ target, recursive: Boolean(o && o.recursive) }); },
|
|
743
|
+
cron: async (user) => { cronCalls.push(user); },
|
|
744
|
+
probe: async (step) => { probes.push(step); return (opts.verifyPresent && opts.verifyPresent(step)) ? 'present' : 'absent'; },
|
|
745
|
+
});
|
|
746
|
+
return { cmds, files, cronCalls, probes, promise: p };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
test('uninstallScheduledRun (macOS): boots out the LaunchAgent, clears + verifies the keychain, removes the plist and install dir — offline', async () => {
|
|
750
|
+
const r = recordTeardown({ platform: 'darwin' });
|
|
751
|
+
await r.promise;
|
|
752
|
+
const joined = r.cmds.join('\n');
|
|
753
|
+
assert.match(joined, /^launchctl bootout gui\/\d+\/ai\.getunbound\.scheduled$/m, 'boots out the user LaunchAgent directly (no sudo)');
|
|
754
|
+
for (const acct of ['command', 'api_key', 'discovery_key', 'domain']) {
|
|
755
|
+
assert.match(joined, new RegExp(`security delete-generic-password -s ai\\.getunbound\\.scheduled -a ${acct}\\b`), `clears keychain ${acct}`);
|
|
756
|
+
}
|
|
757
|
+
assert.ok(r.files.some((f) => f.target.endsWith('Library/LaunchAgents/ai.getunbound.scheduled.plist')), 'removes the plist');
|
|
758
|
+
assert.ok(r.files.some((f) => f.target.endsWith('.local/share/unbound') && f.recursive), 'removes the install dir');
|
|
759
|
+
assert.ok(r.probes.some((s) => s.probeKind === 'securityFind' && s.args.includes('api_key')), 'verifies the api key is gone');
|
|
760
|
+
assert.ok(!/curl|https?:|downloadToFile/.test(joined), 'the teardown must not hit the network');
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test('uninstallScheduledRun (Linux): disables + reloads the systemd timer, drops the cron entry, removes + verifies the creds file, removes the install dir', async () => {
|
|
764
|
+
const r = recordTeardown({ platform: 'linux' });
|
|
765
|
+
await r.promise;
|
|
766
|
+
const joined = r.cmds.join('\n');
|
|
767
|
+
assert.match(joined, /systemctl --user disable --now unbound-scheduled\.timer/);
|
|
768
|
+
assert.match(joined, /systemctl --user daemon-reload/, 'reloads systemd after removing the units');
|
|
769
|
+
assert.equal(r.cronCalls.length, 1, 'removes the crontab entry exactly once');
|
|
770
|
+
assert.ok(r.files.some((f) => f.target.endsWith('.unbound/scheduled-creds.json')), 'removes the stored-key file');
|
|
771
|
+
assert.ok(r.probes.some((s) => s.probeKind === 'fileExists' && s.target.endsWith('.unbound/scheduled-creds.json')), 'verifies the creds file is gone');
|
|
772
|
+
assert.ok(r.files.some((f) => f.target.endsWith('.local/share/unbound') && f.recursive));
|
|
773
|
+
assert.ok(!/curl|https?:/.test(joined), 'offline: no network');
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
test('uninstallScheduledRun (Windows): deletes the task + every credential, verifies the key via cmdkey /list, removes the install dir', async () => {
|
|
777
|
+
const r = recordTeardown({ platform: 'win32', env: { LOCALAPPDATA: 'C:\\Users\\Bob\\AppData\\Local' } });
|
|
778
|
+
await r.promise;
|
|
779
|
+
const joined = r.cmds.join('\n');
|
|
780
|
+
assert.match(joined, /schtasks \/Delete \/TN ai\.getunbound\.scheduled \/F/);
|
|
781
|
+
for (const acct of ['command', 'api_key', 'discovery_key', 'domain']) {
|
|
782
|
+
assert.match(joined, new RegExp(`cmdkey /delete:ai\\.getunbound\\.scheduled:${acct}\\b`), `clears credential ${acct}`);
|
|
783
|
+
}
|
|
784
|
+
assert.ok(r.probes.some((s) => s.probeKind === 'cmdkeyList' && s.match === 'ai.getunbound.scheduled:api_key'), 'verifies via cmdkey /list');
|
|
785
|
+
assert.ok(r.files.some((f) => /Unbound$/.test(f.target) && f.recursive), 'removes the install dir under LOCALAPPDATA');
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
// Finding: `sudo unbound nuke` runs as root, but the scan is a per-user artifact.
|
|
789
|
+
// The teardown must target the invoking user (SUDO_USER), not root's empty domain.
|
|
790
|
+
// Uses the current platform so the home lookup (dscl on macOS, getent on Linux)
|
|
791
|
+
// resolves the real user; skipped when already root (SUDO_USER=root isn't elevated).
|
|
792
|
+
test('resolveScheduledUser: under sudo targets the invoking user (SUDO_USER), not root',
|
|
793
|
+
{ skip: (typeof process.getuid === 'function' && process.getuid() === 0) ? 'running as root — SUDO_USER=root is the non-elevated path' : false },
|
|
794
|
+
() => {
|
|
795
|
+
const me = os.userInfo().username;
|
|
796
|
+
const origGetuid = process.getuid;
|
|
797
|
+
process.getuid = () => 0; // simulate elevated
|
|
798
|
+
try {
|
|
799
|
+
const u = scheduled.resolveScheduledUser({ SUDO_USER: me }, process.platform);
|
|
800
|
+
assert.equal(u.elevated, true, 'runs teardown in the invoking user context');
|
|
801
|
+
assert.equal(u.username, me);
|
|
802
|
+
assert.equal(u.home, os.homedir(), 'resolves the invoking user home, not /var/root');
|
|
803
|
+
assert.equal(u.uid, String(os.userInfo().uid));
|
|
804
|
+
} finally { process.getuid = origGetuid; }
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test('resolveScheduledUser: without sudo targets the current user, not elevated', () => {
|
|
808
|
+
const u = scheduled.resolveScheduledUser({}, 'darwin');
|
|
809
|
+
assert.equal(u.elevated, false);
|
|
810
|
+
assert.equal(u.home, os.homedir());
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
// Finding: `sudo -u` alone does not unlock the user's login keychain or enter
|
|
814
|
+
// their GUI domain. Elevated macOS keychain work must run via `launchctl asuser`,
|
|
815
|
+
// the LaunchAgent bootout targets gui/<uid> directly, and root's own keychain is
|
|
816
|
+
// cleared too (an elevated install stores the key there).
|
|
817
|
+
test('unixTeardownSteps: elevated macOS wraps user keychain ops in launchctl asuser, boots out gui/<uid> directly, and clears root too', () => {
|
|
818
|
+
const user = { home: '/Users/alice', uid: '501', username: 'alice', elevated: true };
|
|
819
|
+
const steps = scheduled.unixTeardownSteps(user, 'darwin');
|
|
820
|
+
const bootout = steps.find((s) => s.label === 'stop LaunchAgent');
|
|
821
|
+
assert.deepEqual([bootout.cmd, ...bootout.args], ['launchctl', 'bootout', 'gui/501/ai.getunbound.scheduled'], 'boots out gui/<uid> directly, no sudo');
|
|
822
|
+
const userDel = steps.find((s) => s.label === 'remove keychain api_key (user)');
|
|
823
|
+
assert.deepEqual(userDel.args.slice(0, 5), ['asuser', '501', 'sudo', '-u', 'alice'], 'user keychain op runs via launchctl asuser + sudo -u');
|
|
824
|
+
assert.equal(userDel.cmd, 'launchctl');
|
|
825
|
+
const rootDel = steps.find((s) => s.label === 'remove keychain api_key (root)');
|
|
826
|
+
assert.equal(rootDel.cmd, 'security', 'root keychain op runs as root directly');
|
|
827
|
+
assert.ok(steps.some((s) => s.kind === 'file' && s.target === '/Users/alice/Library/LaunchAgents/ai.getunbound.scheduled.plist'));
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
// The stored key is security-critical: if the delete silently fails and the
|
|
831
|
+
// verify still finds the key, the teardown must reject so nuke reports the miss.
|
|
832
|
+
test('uninstallScheduledRun: the key surviving the delete (verify reports present) rejects', async () => {
|
|
833
|
+
const r = recordTeardown({ platform: 'darwin', verifyPresent: () => true });
|
|
834
|
+
await assert.rejects(r.promise, /teardown incomplete/i);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
// Finding: `security find` failing for a reason OTHER than "not found" (locked
|
|
838
|
+
// keychain, no session) must NOT be read as removed — an 'unknown' probe rejects.
|
|
839
|
+
test('uninstallScheduledRun: an unverifiable key (probe unknown) rejects, not treated as removed', async () => {
|
|
840
|
+
await assert.rejects(scheduled.uninstallScheduledRun({
|
|
841
|
+
platform: 'darwin', env: {},
|
|
842
|
+
run: async () => {}, fileRm: async () => {}, cron: async () => {},
|
|
843
|
+
probe: async () => 'unknown',
|
|
844
|
+
}), /could not verify/i);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
// Finding: root often has no default keychain, and that error is not exit 44 —
|
|
848
|
+
// treat it as absent, but keep real "could not look" statuses as unknown.
|
|
849
|
+
test('defaultProbe securityFind: exit 0 present, 44 absent, no-keychain absent, else unknown', { skip: process.platform === 'win32' }, async () => {
|
|
850
|
+
const P = scheduled.defaultProbe;
|
|
851
|
+
const mk = (sh) => ({ probeKind: 'securityFind', cmd: 'sh', args: ['-c', sh] });
|
|
852
|
+
assert.equal(await P(mk('exit 0'), 5000), 'present');
|
|
853
|
+
assert.equal(await P(mk('exit 44'), 5000), 'absent');
|
|
854
|
+
assert.equal(await P(mk('echo "A default keychain could not be found" >&2; exit 51'), 5000), 'absent');
|
|
855
|
+
// A searched-but-empty keychain says "item could not be found" — that is not a
|
|
856
|
+
// missing default keychain, so it must stay unknown, not read as absent.
|
|
857
|
+
assert.equal(await P(mk('echo "The specified item could not be found in the keychain." >&2; exit 51'), 5000), 'unknown');
|
|
858
|
+
assert.equal(await P(mk('exit 51'), 5000), 'unknown');
|
|
859
|
+
assert.equal(await P(mk('sleep 5'), 120), 'unknown'); // timeout → null code → unknown
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
test('homeOf: resolves the standard home per OS (fallback when the lookup is unavailable)', () => {
|
|
863
|
+
assert.equal(scheduled.homeOf('root', 'darwin'), '/var/root');
|
|
864
|
+
assert.equal(scheduled.homeOf('root', 'linux'), '/root');
|
|
865
|
+
assert.equal(scheduled.homeOf('nobodyxyz', 'linux'), '/home/nobodyxyz');
|
|
866
|
+
assert.equal(scheduled.homeOf('nobodyxyz', 'darwin'), '/Users/nobodyxyz');
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// dscl lists root's home twice but a real home can contain a space.
|
|
870
|
+
test('parseDsclHome: takes the first of two absolute paths, keeps a spaced home intact', () => {
|
|
871
|
+
assert.equal(scheduled.parseDsclHome('/var/root /private/var/root'), '/var/root');
|
|
872
|
+
assert.equal(scheduled.parseDsclHome('/Users/John Doe'), '/Users/John Doe');
|
|
873
|
+
assert.equal(scheduled.parseDsclHome('/Users/alice'), '/Users/alice');
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
// Finding: a non-zero `cmdkey /list` must not be read as absent off its empty output.
|
|
877
|
+
test('defaultProbe cmdkeyList: non-zero exit is unknown, not absent', { skip: process.platform === 'win32' }, async () => {
|
|
878
|
+
const P = scheduled.defaultProbe;
|
|
879
|
+
assert.equal(await P({ probeKind: 'cmdkeyList', cmd: 'sh', args: ['-c', 'exit 1'], match: 'x' }, 5000), 'unknown');
|
|
880
|
+
assert.equal(await P({ probeKind: 'cmdkeyList', cmd: 'sh', args: ['-c', 'echo hi'], match: 'x' }, 5000), 'absent');
|
|
881
|
+
assert.equal(await P({ probeKind: 'cmdkeyList', cmd: 'sh', args: ['-c', 'echo x-target'], match: 'x-target' }, 5000), 'present');
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// Finding: a sudo install leaves copies in root's home, so an elevated Linux
|
|
885
|
+
// teardown must clear root's crontab / creds / units / install dir too.
|
|
886
|
+
test('unixTeardownSteps: elevated Linux tears down both the invoking user and root', () => {
|
|
887
|
+
const user = { home: '/home/alice', uid: '1000', username: 'alice', elevated: true };
|
|
888
|
+
const steps = scheduled.unixTeardownSteps(user, 'linux');
|
|
889
|
+
assert.ok(steps.some((s) => s.kind === 'file' && s.target === '/home/alice/.unbound/scheduled-creds.json'), 'clears the user creds');
|
|
890
|
+
assert.ok(steps.some((s) => s.kind === 'file' && s.target === '/root/.unbound/scheduled-creds.json'), 'clears root creds');
|
|
891
|
+
assert.ok(steps.some((s) => s.kind === 'verify' && s.target === '/root/.unbound/scheduled-creds.json'), 'verifies root creds gone');
|
|
892
|
+
assert.equal(steps.filter((s) => s.kind === 'cron').length, 2, 'rewrites both crontabs');
|
|
893
|
+
assert.ok(steps.some((s) => s.kind === 'file' && s.target === '/root/.local/share/unbound'), 'clears root install dir');
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// Finding: bare root with no SUDO_USER and no console user (headless / MDM) can't
|
|
897
|
+
// target a user's scan, so it must surface the miss, not report the device clean.
|
|
898
|
+
test('uninstallScheduledRun: an unresolvable invoking user surfaces the miss', async () => {
|
|
899
|
+
await assert.rejects(scheduled.uninstallScheduledRun({
|
|
900
|
+
platform: 'linux',
|
|
901
|
+
resolveUser: () => ({ username: 'root', home: '/root', uid: '0', elevated: false, unresolvedUser: true }),
|
|
902
|
+
run: async () => {}, fileRm: async () => {}, cron: async () => {}, probe: async () => 'absent',
|
|
903
|
+
}), /could not determine the invoking user/i);
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// A crontab failure that is not an empty table (sudo/asuser error, timeout) must
|
|
907
|
+
// surface — the cron line would otherwise be left behind while the key is gone.
|
|
908
|
+
test('uninstallScheduledRun: a crontab teardown failure surfaces, not swallowed', async () => {
|
|
909
|
+
await assert.rejects(scheduled.uninstallScheduledRun({
|
|
910
|
+
platform: 'linux', env: {},
|
|
911
|
+
run: async () => {}, fileRm: async () => {}, probe: async () => 'absent',
|
|
912
|
+
cron: async () => { throw new Error('crontab -l exited 1'); },
|
|
913
|
+
}), /crontab/i);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
// A best-effort job step that fails because the artifact was already gone (e.g.
|
|
917
|
+
// the agent isn't loaded) must be tolerated — teardown still succeeds overall.
|
|
918
|
+
test('uninstallScheduledRun: a job step failing because the artifact is absent is tolerated', async () => {
|
|
919
|
+
const r = recordTeardown({ platform: 'darwin', failIf: (l) => /launchctl bootout/.test(l) });
|
|
920
|
+
await r.promise; // resolves — no throw
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// removeScheduledRun still folds a real teardown failure into nuke's `failed`.
|
|
924
|
+
test('removeScheduledRun: a rejecting local teardown surfaces the scheduled scan in `failed`', async () => {
|
|
925
|
+
const failed = await removeScheduledRun({
|
|
926
|
+
uninstall: () => scheduled.uninstallScheduledRun({
|
|
927
|
+
platform: 'darwin', env: {},
|
|
928
|
+
run: async () => {}, fileRm: async () => {}, cron: async () => {},
|
|
929
|
+
probe: async () => 'present', // the key survived a locked-keychain delete
|
|
930
|
+
}),
|
|
931
|
+
});
|
|
932
|
+
assert.deepEqual(failed, ['scheduled scan'], 'a surviving credential must surface, not be swallowed');
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
// Finding: a wedged launchctl/security is a GRANDCHILD of the spawned process, so
|
|
936
|
+
// the timeout must kill the process GROUP, not just the direct child, or the hang
|
|
937
|
+
// outlives nuke. Proven with a real bash that backgrounds a grandchild sleep.
|
|
938
|
+
test('runCommand: the timeout kills the whole process group, so a grandchild does not survive', { skip: process.platform === 'win32' }, async () => {
|
|
939
|
+
const pidFile = path.join(os.tmpdir(), `unbound-pg-${Date.now()}-${Math.random().toString(36).slice(2)}.pid`);
|
|
940
|
+
const script = `sleep 300 & echo $! > "${pidFile}"; wait`;
|
|
941
|
+
const start = Date.now();
|
|
942
|
+
await assert.rejects(
|
|
943
|
+
scheduled.runCommand('bash', ['-c', script], { stdio: 'ignore' }, 300, 'timeout-test'),
|
|
944
|
+
/timed out/i,
|
|
945
|
+
'a hung command must reject with a timeout',
|
|
946
|
+
);
|
|
947
|
+
assert.ok(Date.now() - start < 5000, 'must reject promptly on timeout');
|
|
948
|
+
const gpid = Number(fs.readFileSync(pidFile, 'utf8').trim());
|
|
949
|
+
fs.rmSync(pidFile, { force: true });
|
|
950
|
+
// Poll briefly: the group SIGKILL must reap the grandchild sleep. kill(pid, 0)
|
|
951
|
+
// throws ESRCH once it is gone; on the old direct-child kill it stayed alive.
|
|
952
|
+
let alive = true;
|
|
953
|
+
for (let i = 0; i < 20 && alive; i++) {
|
|
954
|
+
try { process.kill(gpid, 0); await new Promise((r) => setTimeout(r, 50)); }
|
|
955
|
+
catch { alive = false; }
|
|
956
|
+
}
|
|
957
|
+
assert.equal(alive, false, 'the grandchild sleep must be killed with the group, not orphaned past nuke');
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
// Wiring: nuke's summary must not claim the credential is gone when the scheduled
|
|
961
|
+
// teardown reported an issue. Source-parsed like the other nuke wiring guard.
|
|
962
|
+
test('nuke summary is honest: no blanket "Credentials were still removed", and warns when the scheduled key may remain', () => {
|
|
963
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'commands', 'setup.js'), 'utf8');
|
|
964
|
+
assert.ok(!src.includes('Credentials were still removed.'), 'the misleading blanket claim must be gone');
|
|
965
|
+
const actionStart = src.indexOf(".command('nuke')");
|
|
966
|
+
const actionSrc = src.slice(actionStart, actionStart + 9000);
|
|
967
|
+
assert.match(actionSrc, /failed\.includes\('scheduled scan'\)/, 'the summary must special-case a scheduled-scan miss');
|
|
968
|
+
assert.match(actionSrc, /discover unschedule/, 'and point the user at the command that clears it');
|
|
969
|
+
});
|
package/test/tool-health.test.js
CHANGED
|
@@ -641,6 +641,98 @@ test('validateToolKeys: invalid verdict flips the tool to tampered', async () =>
|
|
|
641
641
|
});
|
|
642
642
|
});
|
|
643
643
|
|
|
644
|
+
test('validateToolKeys: stale live-env key invalid but rc file holds a valid key → healthy', async () => {
|
|
645
|
+
await withHome(async (tmp, th) => {
|
|
646
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
647
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
648
|
+
writeFile(script, '# unbound');
|
|
649
|
+
// A shell opened before setup rewrote the rc carries a stale key in the
|
|
650
|
+
// live env, while the valid key sits in a shell rc. Must not be tampered.
|
|
651
|
+
// .zshrc is on both the macOS and Linux rc lists, so the test is portable.
|
|
652
|
+
writeFile(path.join(tmp, '.zshrc'), 'export UNBOUND_CURSOR_API_KEY="rc-valid-key"\n');
|
|
653
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'stale-invalid-key';
|
|
654
|
+
try {
|
|
655
|
+
const seen = [];
|
|
656
|
+
const tools = th.detectTools();
|
|
657
|
+
await th.validateToolKeys(tools, async (k) => { seen.push(k); return k === 'rc-valid-key' ? 'valid' : 'invalid'; });
|
|
658
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
659
|
+
assert.equal(cursor.status, 'healthy');
|
|
660
|
+
assert.equal(cursor.checks.find((c) => c.keyCheck).ok, true);
|
|
661
|
+
// Guard the rc read itself: if the rc file were skipped the tool would
|
|
662
|
+
// still be tampered, so prove the rc key actually reached the validator.
|
|
663
|
+
assert.ok(seen.includes('rc-valid-key'), 'the rc-file key must be validated, not just the live env key');
|
|
664
|
+
} finally {
|
|
665
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test('validateToolKeys: live-env AND rc keys both invalid → tampered (no false clean)', async () => {
|
|
671
|
+
await withHome(async (tmp, th) => {
|
|
672
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
673
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
674
|
+
writeFile(script, '# unbound');
|
|
675
|
+
writeFile(path.join(tmp, '.zshrc'), 'export UNBOUND_CURSOR_API_KEY="rc-stale-key"\n');
|
|
676
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'env-stale-key';
|
|
677
|
+
try {
|
|
678
|
+
const seen = [];
|
|
679
|
+
const tools = th.detectTools();
|
|
680
|
+
await th.validateToolKeys(tools, async (k) => { seen.push(k); return 'invalid'; });
|
|
681
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
682
|
+
assert.equal(cursor.status, 'tampered');
|
|
683
|
+
assert.equal(cursor.checks.find((c) => c.keyCheck).ok, false);
|
|
684
|
+
// Both candidates must be validated — otherwise a skipped rc file lets
|
|
685
|
+
// this "every candidate rejected" case pass without ever reading the rc.
|
|
686
|
+
assert.ok(seen.includes('env-stale-key') && seen.includes('rc-stale-key'), 'both the live env and rc keys must be validated');
|
|
687
|
+
} finally {
|
|
688
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test('validateToolKeys: a valid live key short-circuits — rc siblings are never sent to the validator', async () => {
|
|
694
|
+
await withHome(async (tmp, th) => {
|
|
695
|
+
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|
|
696
|
+
writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
|
|
697
|
+
writeFile(script, '# unbound');
|
|
698
|
+
// A user-owned var (e.g. OPENAI_API_KEY for codex-gateway) can carry the
|
|
699
|
+
// user's personal key exported beside ours. A valid live key must stop the
|
|
700
|
+
// check before any rc sibling is uploaded to the gateway.
|
|
701
|
+
writeFile(path.join(tmp, '.zshrc'), 'export UNBOUND_CURSOR_API_KEY="personal-secret"\nexport UNBOUND_CURSOR_API_KEY="live-valid-key"\n');
|
|
702
|
+
process.env.UNBOUND_CURSOR_API_KEY = 'live-valid-key';
|
|
703
|
+
try {
|
|
704
|
+
const seen = [];
|
|
705
|
+
const tools = th.detectTools();
|
|
706
|
+
await th.validateToolKeys(tools, async (k) => { seen.push(k); return k === 'live-valid-key' ? 'valid' : 'invalid'; });
|
|
707
|
+
const cursor = tools.find((t) => t.key === 'cursor');
|
|
708
|
+
assert.equal(cursor.status, 'healthy');
|
|
709
|
+
assert.deepEqual(seen, ['live-valid-key'], 'only the live key is validated on the healthy path');
|
|
710
|
+
assert.ok(!seen.includes('personal-secret'), 'a sibling rc key must never be sent');
|
|
711
|
+
} finally {
|
|
712
|
+
delete process.env.UNBOUND_CURSOR_API_KEY;
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test('validateToolKeys: user-owned OPENAI_API_KEY never has its rc siblings sent, even when the live key is rejected', async () => {
|
|
718
|
+
await withHome(async (tmp, th) => {
|
|
719
|
+
writeFile(path.join(tmp, '.codex', 'config.toml'), 'openai_base_url = "https://gateway.example/v1"\n');
|
|
720
|
+
// The user's personal OpenAI key sits in the rc beside anything Unbound set.
|
|
721
|
+
// A rejected live key must NOT trigger a search that uploads the personal one.
|
|
722
|
+
writeFile(path.join(tmp, '.zshrc'), 'export OPENAI_API_KEY="sk-personal-secret"\n');
|
|
723
|
+
process.env.OPENAI_API_KEY = 'stale-live-key';
|
|
724
|
+
try {
|
|
725
|
+
const seen = [];
|
|
726
|
+
const tools = th.detectTools();
|
|
727
|
+
await th.validateToolKeys(tools, async (k) => { seen.push(k); return 'invalid'; });
|
|
728
|
+
assert.ok(!seen.includes('sk-personal-secret'), 'a personal OpenAI key in the rc must never be sent to the gateway');
|
|
729
|
+
assert.deepEqual(seen, ['stale-live-key'], 'only the live value is ever validated for a user-owned var');
|
|
730
|
+
} finally {
|
|
731
|
+
delete process.env.OPENAI_API_KEY;
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
644
736
|
test('validateToolKeys: unknown verdict fails open — tool stays healthy', async () => {
|
|
645
737
|
await withHome(async (tmp, th) => {
|
|
646
738
|
const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
|