unbound-cli 1.16.25 → 1.17.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 +5 -3
- package/package.json +1 -1
- package/src/auth.js +78 -3
- package/src/commands/doctor.js +11 -3
- package/src/commands/login.js +10 -3
- package/src/commands/logout.js +29 -6
- package/src/commands/oacb.js +1 -2
- package/src/commands/onboard.js +12 -12
- package/src/commands/setup.js +18 -16
- package/src/config.js +24 -2
- package/src/keychain.js +156 -0
- package/src/setup-report.js +1 -1
- package/test/cli-key.test.js +160 -0
- package/test/flags.test.js +1 -1
- package/test/onboard-cron.test.js +1 -1
- package/test/onboard-failure-exit.test.js +1 -1
- package/test/onboard-scope.test.js +5 -4
- package/test/setup-instruction-tools.test.js +1 -1
- package/test/setup-report.test.js +5 -5
- package/test/tool-detect.test.js +1 -1
package/README.md
CHANGED
|
@@ -14,8 +14,8 @@ npm install -g unbound-cli
|
|
|
14
14
|
# Login via browser
|
|
15
15
|
unbound login
|
|
16
16
|
|
|
17
|
-
# Or login with
|
|
18
|
-
unbound login --api-key <your-
|
|
17
|
+
# Or login with your CLI key from Configure → API Access (for CI/CD or headless environments)
|
|
18
|
+
unbound login --api-key <your-cli-key>
|
|
19
19
|
|
|
20
20
|
# Check your status, role, and connected tools
|
|
21
21
|
unbound status
|
|
@@ -54,7 +54,7 @@ Only your API key is needed. Run with `sudo` to let the discovery step scan all
|
|
|
54
54
|
| `unbound login` | Sign in via browser |
|
|
55
55
|
| `unbound login --api-key <key>` | Sign in with an API key (non-interactive) |
|
|
56
56
|
| `unbound login --domain <domain>` | Sign in via a custom domain |
|
|
57
|
-
| `unbound logout` | Remove
|
|
57
|
+
| `unbound logout` | Remove the CLI key from the OS keychain |
|
|
58
58
|
| `unbound status` | Show CLI status, role, and connected tools |
|
|
59
59
|
| `unbound doctor` | Diagnose per-tool health (config, hook, env) and API key |
|
|
60
60
|
|
|
@@ -286,6 +286,8 @@ unbound config show
|
|
|
286
286
|
|
|
287
287
|
Bare hostnames are accepted (`backend.acme.com` becomes `https://backend.acme.com`). Each URL also accepts a per-process override via env var.
|
|
288
288
|
|
|
289
|
+
**API keys.** The CLI key (Configure → API Access) can read your organization's data, so `unbound login` keeps it in the OS keychain: macOS Keychain, Windows Credential Manager, or the Linux Secret Service (`secret-tool`). Without a keychain, set `UNBOUND_TOKEN` instead; the CLI reads it only when the keychain has no key. The send-only device key that tools and hooks use is stored as `api_key` in `~/.unbound/config.json`. `unbound setup` fetches it from your login, and `--api-key` takes a device key. Run `unbound login` again after upgrading.
|
|
290
|
+
|
|
289
291
|
**Backend URL priority** (highest to lowest):
|
|
290
292
|
1. `UNBOUND_API_URL` environment variable
|
|
291
293
|
2. `base_url` in `~/.unbound/config.json` (set via `unbound config set-backend-url`)
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -10,6 +10,20 @@ const output = require('./output');
|
|
|
10
10
|
const api = require('./api');
|
|
11
11
|
const { getDeviceSerial, probeDeviceSerialLive } = require('./device-serial');
|
|
12
12
|
|
|
13
|
+
const NOT_A_CLI_KEY = "This key can't be used with the CLI. Copy it from Configure \u2192 API Access, or run `unbound login`.";
|
|
14
|
+
|
|
15
|
+
// 401/403 means the key is not a CLI key; anything else (offline, 5xx) is an outage, not an answer.
|
|
16
|
+
function isRejected(err) {
|
|
17
|
+
return err.statusCode === 401 || err.statusCode === 403;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function warnIfNotStored(stored) {
|
|
21
|
+
if (stored === false) {
|
|
22
|
+
output.warn('No OS keychain is available, so this login lasts for this run only. '
|
|
23
|
+
+ 'Set UNBOUND_TOKEN to your CLI key (Configure \u2192 API Access) to stay logged in.');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
/**
|
|
14
28
|
* Opens the browser for authentication and waits for the callback.
|
|
15
29
|
* Starts a local HTTP server, opens the frontend auth page, and waits
|
|
@@ -67,6 +81,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
67
81
|
// overwrite the just-persisted credentials after the login promise has
|
|
68
82
|
// settled — reject duplicates outright instead of processing them.
|
|
69
83
|
let callbackHandled = false;
|
|
84
|
+
let stored;
|
|
70
85
|
|
|
71
86
|
server.on('request', (req, res) => {
|
|
72
87
|
const url = new URL(req.url, `http://localhost`);
|
|
@@ -94,7 +109,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
94
109
|
return;
|
|
95
110
|
}
|
|
96
111
|
|
|
97
|
-
config.setApiKey(apiKey);
|
|
112
|
+
stored = config.setApiKey(apiKey);
|
|
98
113
|
const cfg = config.readConfig();
|
|
99
114
|
if (email) cfg.email = email;
|
|
100
115
|
if (orgName) cfg.org_name = orgName;
|
|
@@ -138,6 +153,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
|
|
|
138
153
|
const result = await callbackPromise;
|
|
139
154
|
clearTimeout(timeout);
|
|
140
155
|
clearInterval(reminder);
|
|
156
|
+
warnIfNotStored(stored);
|
|
141
157
|
return result;
|
|
142
158
|
} catch (err) {
|
|
143
159
|
clearTimeout(timeout);
|
|
@@ -181,7 +197,22 @@ async function loginWithApiKey(apiKey, { baseUrl } = {}) {
|
|
|
181
197
|
err.displayed = true;
|
|
182
198
|
throw err;
|
|
183
199
|
}
|
|
184
|
-
|
|
200
|
+
// Only a CLI key may fetch the device key, so this also rejects device and MDM keys.
|
|
201
|
+
let deviceKey;
|
|
202
|
+
try {
|
|
203
|
+
deviceKey = await fetchDeviceKey(apiKey, { baseUrl });
|
|
204
|
+
} catch (err) {
|
|
205
|
+
spin.fail(isRejected(err) ? NOT_A_CLI_KEY : 'Unable to fetch your device key. Please try again.');
|
|
206
|
+
err.displayed = true;
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
// Where the backend allows any key here, a device key comes back as itself.
|
|
210
|
+
if (deviceKey === apiKey) {
|
|
211
|
+
spin.fail(NOT_A_CLI_KEY);
|
|
212
|
+
throw Object.assign(new Error(NOT_A_CLI_KEY), { displayed: true });
|
|
213
|
+
}
|
|
214
|
+
const stored = config.setApiKey(apiKey);
|
|
215
|
+
config.setDeviceKey(deviceKey);
|
|
185
216
|
config.backfillUserInfo(response);
|
|
186
217
|
|
|
187
218
|
const parts = [];
|
|
@@ -189,10 +220,52 @@ async function loginWithApiKey(apiKey, { baseUrl } = {}) {
|
|
|
189
220
|
const orgName = response.org_name || response.organization_name || response.organization;
|
|
190
221
|
if (orgName) parts.push(orgName);
|
|
191
222
|
spin.succeed(parts.length > 0 ? `Authenticated as ${parts.join(' \u00b7 ')}` : 'Authenticated');
|
|
223
|
+
warnIfNotStored(stored);
|
|
192
224
|
|
|
193
225
|
return true;
|
|
194
226
|
}
|
|
195
227
|
|
|
228
|
+
/** The send-only device key that the given CLI key's owner installs into tools. */
|
|
229
|
+
async function fetchDeviceKey(cliKey, { baseUrl } = {}) {
|
|
230
|
+
const resp = await api.get('/api/v1/automations/get_application_api_key/', {
|
|
231
|
+
apiKey: cliKey, baseUrl, query: { app_type: 'default' },
|
|
232
|
+
});
|
|
233
|
+
if (!resp || !resp.api_key) {
|
|
234
|
+
throw new Error('The backend did not return a device key.');
|
|
235
|
+
}
|
|
236
|
+
return resp.api_key;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The send-only key setup installs into tools. A CLI key, passed or stored, is swapped for its
|
|
241
|
+
* device key; a passed --api-key is never saved as the login.
|
|
242
|
+
*/
|
|
243
|
+
async function resolveDeviceKey({ apiKey, baseUrl, frontendUrl } = {}) {
|
|
244
|
+
if (!apiKey && !config.getApiKey() && !config.getDeviceKey()) {
|
|
245
|
+
await ensureLoggedIn({ baseUrl, frontendUrl });
|
|
246
|
+
}
|
|
247
|
+
// A CLI from before the keychain kept its login in config.json, so that key is tried too.
|
|
248
|
+
const candidates = apiKey ? [apiKey] : [config.getApiKey(), config.getDeviceKey()];
|
|
249
|
+
for (const key of new Set(candidates.filter(Boolean))) {
|
|
250
|
+
let deviceKey;
|
|
251
|
+
try {
|
|
252
|
+
deviceKey = await fetchDeviceKey(key, { baseUrl });
|
|
253
|
+
} catch (err) {
|
|
254
|
+
// Only a rejection proves this is not a CLI key; an outage must not let a CLI key reach a tool.
|
|
255
|
+
if (isRejected(err)) continue;
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
if (deviceKey === key) continue;
|
|
259
|
+
if (!apiKey) {
|
|
260
|
+
// Swapped even when the keychain refuses: setup scripts overwrite config.json anyway, and Configure shows the key again.
|
|
261
|
+
if (key !== config.getApiKey()) warnIfNotStored(config.setApiKey(key));
|
|
262
|
+
config.setDeviceKey(deviceKey);
|
|
263
|
+
}
|
|
264
|
+
return deviceKey;
|
|
265
|
+
}
|
|
266
|
+
return apiKey || config.getDeviceKey();
|
|
267
|
+
}
|
|
268
|
+
|
|
196
269
|
/**
|
|
197
270
|
* Ensures the user is logged in. If an apiKey is provided, validates and
|
|
198
271
|
* stores it first. If not logged in, triggers browser-based login.
|
|
@@ -250,4 +323,6 @@ async function fetchDeviceOwnerKey(adminApiKey, { baseUrl } = {}) {
|
|
|
250
323
|
return ownerKey;
|
|
251
324
|
}
|
|
252
325
|
|
|
253
|
-
module.exports = {
|
|
326
|
+
module.exports = {
|
|
327
|
+
loginWithBrowser, loginWithApiKey, ensureLoggedIn, fetchDeviceOwnerKey, fetchDeviceKey, resolveDeviceKey,
|
|
328
|
+
};
|
package/src/commands/doctor.js
CHANGED
|
@@ -56,9 +56,11 @@ doctor can gate scripts/CI.
|
|
|
56
56
|
Examples:
|
|
57
57
|
$ unbound doctor
|
|
58
58
|
$ unbound doctor --fix Reinstall every tampered tool
|
|
59
|
+
$ sudo unbound doctor --fix --api-key <MDM_KEY> Reinstall org-wide tools
|
|
59
60
|
$ unbound doctor --json
|
|
60
61
|
`)
|
|
61
62
|
.option('--fix', 'Reinstall every tool reported as tampered')
|
|
63
|
+
.option('--api-key <key>', 'MDM key, required to reinstall org-wide tools with sudo')
|
|
62
64
|
.option('--json', 'Output raw JSON')
|
|
63
65
|
.action(async (opts) => {
|
|
64
66
|
try {
|
|
@@ -153,8 +155,7 @@ Examples:
|
|
|
153
155
|
const userNames = userTampered.map((t) => t.label).join(', ');
|
|
154
156
|
const mdmNames = mdmTampered.map((t) => t.label).join(', ');
|
|
155
157
|
const allFix = tampered.map((t) => t.key);
|
|
156
|
-
|
|
157
|
-
const mdmCmd = elevated(mdmTampered.some((t) => t.notEnforced) ? `unbound setup ${mdmFix.join(' ')}` : 'unbound doctor --fix');
|
|
158
|
+
const mdmCmd = elevated(`unbound setup ${mdmFix.join(' ')} --api-key <MDM_KEY>`);
|
|
158
159
|
const anyInstalled = tools.some((t) => t.status !== 'not-installed');
|
|
159
160
|
|
|
160
161
|
if (opts.fix) {
|
|
@@ -183,11 +184,18 @@ Examples:
|
|
|
183
184
|
process.exitCode = 1;
|
|
184
185
|
return;
|
|
185
186
|
}
|
|
187
|
+
// Org-wide setup takes only the MDM key, never the CLI login.
|
|
188
|
+
if (root && !opts.apiKey) {
|
|
189
|
+
output.error('--api-key is required to reinstall tools for all users: use the MDM key from Configure \u2192 My Org.');
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
186
193
|
const fixNow = root ? allFix : userFix;
|
|
187
194
|
const fixDisplay = (root ? tampered : userTampered).map(labelOf).join(', ');
|
|
188
195
|
output.info(`Reinstalling: ${fixDisplay}${root ? ' (org-wide, all users)' : ''}`);
|
|
189
196
|
console.log('');
|
|
190
|
-
const
|
|
197
|
+
const keyArgs = root ? ['--api-key', opts.apiKey] : [];
|
|
198
|
+
const r = spawnSync(process.argv[0], [process.argv[1], 'setup', ...fixNow, ...keyArgs], { stdio: 'inherit' });
|
|
191
199
|
if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold(mdmCmd)} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
192
200
|
process.exitCode = fixExitCode(r.status, root, mdmFix.length > 0) || ((configMismatch || blankConfigDir) ? 1 : 0);
|
|
193
201
|
return;
|
package/src/commands/login.js
CHANGED
|
@@ -2,7 +2,7 @@ const { Option } = require('commander');
|
|
|
2
2
|
const config = require('../config');
|
|
3
3
|
const output = require('../output');
|
|
4
4
|
const api = require('../api');
|
|
5
|
-
const { loginWithBrowser, loginWithApiKey } = require('../auth');
|
|
5
|
+
const { loginWithBrowser, loginWithApiKey, fetchDeviceKey } = require('../auth');
|
|
6
6
|
const { getDeviceSerial } = require('../device-serial');
|
|
7
7
|
|
|
8
8
|
function register(program) {
|
|
@@ -23,8 +23,9 @@ Authentication methods:
|
|
|
23
23
|
Times out after 120 seconds if no callback is received.
|
|
24
24
|
|
|
25
25
|
API key login (--api-key):
|
|
26
|
-
Non-interactive login for CI/CD or automation.
|
|
27
|
-
API
|
|
26
|
+
Non-interactive login for CI/CD or automation. Takes the CLI key from
|
|
27
|
+
Configure → API Access and keeps it in the OS keychain (or set
|
|
28
|
+
UNBOUND_TOKEN where there is none). Device and MDM keys are refused.
|
|
28
29
|
|
|
29
30
|
Tenant deployments (URL flags persist to ~/.unbound/config.json):
|
|
30
31
|
Pass --gateway-url, --frontend-url, --backend-url at login time and the
|
|
@@ -84,6 +85,12 @@ Examples:
|
|
|
84
85
|
await api.get('/api/v1/users/privileges/', {
|
|
85
86
|
baseUrl: explicitBaseUrl, query: { device_serial: deviceSerial },
|
|
86
87
|
});
|
|
88
|
+
// Replace whatever config.json held (an old CLI login, another org's key) with this login's device key.
|
|
89
|
+
try {
|
|
90
|
+
config.setDeviceKey(await fetchDeviceKey(config.getApiKey(), { baseUrl: explicitBaseUrl }));
|
|
91
|
+
} catch {
|
|
92
|
+
output.warn('Could not fetch your device key; `unbound setup` will fetch it.');
|
|
93
|
+
}
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
const cfg = config.readConfig();
|
package/src/commands/logout.js
CHANGED
|
@@ -1,26 +1,49 @@
|
|
|
1
1
|
const config = require('../config');
|
|
2
2
|
const output = require('../output');
|
|
3
|
+
const auth = require('../auth');
|
|
3
4
|
|
|
4
5
|
function register(program) {
|
|
5
6
|
program
|
|
6
7
|
.command('logout')
|
|
7
|
-
.description('Log out of Unbound. Removes
|
|
8
|
+
.description('Log out of Unbound. Removes the CLI key from the OS keychain while preserving custom URL settings.')
|
|
8
9
|
.addHelpText('after', `
|
|
9
10
|
What this does:
|
|
10
|
-
|
|
11
|
-
~/.unbound/config.json.
|
|
12
|
-
|
|
11
|
+
Removes the CLI key from the OS keychain and clears email and organization
|
|
12
|
+
from ~/.unbound/config.json. The device key in config.json stays, because
|
|
13
|
+
installed hooks use it. Custom URL settings are preserved.
|
|
13
14
|
|
|
14
15
|
Examples:
|
|
15
16
|
$ unbound logout
|
|
16
17
|
`)
|
|
17
|
-
.action(() => {
|
|
18
|
+
.action(async () => {
|
|
18
19
|
try {
|
|
20
|
+
// A CLI from before the keychain left its login in config.json; keep only its device key there.
|
|
21
|
+
const stored = config.getDeviceKey();
|
|
22
|
+
let unchecked = false;
|
|
23
|
+
if (stored) {
|
|
24
|
+
try {
|
|
25
|
+
const deviceKey = await auth.fetchDeviceKey(stored);
|
|
26
|
+
if (deviceKey !== stored) config.setDeviceKey(deviceKey);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
// 401/403 means it is already a device key; any other failure leaves a possible CLI key unchecked.
|
|
29
|
+
unchecked = err.statusCode !== 401 && err.statusCode !== 403;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!config.clearApiKey()) {
|
|
33
|
+
output.warn('Could not remove the CLI key from the OS keychain. Unlock it and run `unbound logout` again.');
|
|
34
|
+
}
|
|
35
|
+
if (process.env.UNBOUND_TOKEN) {
|
|
36
|
+
output.warn('UNBOUND_TOKEN is still set, so CLI commands stay logged in. Unset it to log out.');
|
|
37
|
+
}
|
|
19
38
|
const cfg = config.readConfig();
|
|
20
|
-
delete cfg.api_key;
|
|
21
39
|
delete cfg.email;
|
|
22
40
|
delete cfg.org_name;
|
|
23
41
|
config.writeConfig(cfg);
|
|
42
|
+
if (unchecked) {
|
|
43
|
+
output.error('Could not reach Unbound to check the key in ~/.unbound/config.json. Run `unbound logout` again when online.');
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
24
47
|
output.success('Logged out successfully. Custom URL settings preserved.');
|
|
25
48
|
} catch (err) {
|
|
26
49
|
output.error(err.message);
|
package/src/commands/oacb.js
CHANGED
|
@@ -159,8 +159,7 @@ Read the framework at https://github.com/websentry-ai/oacb before applying basel
|
|
|
159
159
|
// ─── Handlers ────────────────────────────────────────────────────────────────
|
|
160
160
|
|
|
161
161
|
async function handleCheck() {
|
|
162
|
-
const
|
|
163
|
-
const hasUnboundSession = !!cfg.api_key;
|
|
162
|
+
const hasUnboundSession = config.isLoggedIn();
|
|
164
163
|
|
|
165
164
|
const { execSync } = require('node:child_process');
|
|
166
165
|
|
package/src/commands/onboard.js
CHANGED
|
@@ -2,7 +2,7 @@ const { Option } = require('commander');
|
|
|
2
2
|
const config = require('../config');
|
|
3
3
|
const output = require('../output');
|
|
4
4
|
const telemetry = require('../telemetry');
|
|
5
|
-
const {
|
|
5
|
+
const { resolveDeviceKey, fetchDeviceOwnerKey } = require('../auth');
|
|
6
6
|
const { runSetupAllBundle, runMdmSetupAllBundle, hasRootPrivileges, elevated, scopeNotice, ALL_TOOLS, MDM_ALL_TOOLS, SETUP_TOOL_MAP, MDM_TOOLS } = require('./setup');
|
|
7
7
|
const { runDiscoveryScan } = require('./discover');
|
|
8
8
|
const toolDetect = require('../toolDetect');
|
|
@@ -54,7 +54,7 @@ function register(program) {
|
|
|
54
54
|
'Scope is auto-detected from your privileges — run with sudo to enroll every ' +
|
|
55
55
|
'user on the device (MDM/org scope), or without sudo to set up just the current user.'
|
|
56
56
|
)
|
|
57
|
-
.option('--api-key <key>', 'API key (
|
|
57
|
+
.option('--api-key <key>', 'API key (device key, or the MDM key when run with sudo). Without sudo, falls back to UNBOUND_API_KEY or the key from `unbound login`')
|
|
58
58
|
.addOption(new Option('--admin-api-key <key>', 'Alias for --api-key (back-compat)').hideHelp())
|
|
59
59
|
.addOption(new Option('--discovery-key <key>', 'Deprecated and ignored. The scan uses your API key.').hideHelp())
|
|
60
60
|
.option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
|
|
@@ -96,10 +96,8 @@ Examples:
|
|
|
96
96
|
const isMdm = hasRootPrivileges();
|
|
97
97
|
output.info(scopeNotice(isMdm));
|
|
98
98
|
|
|
99
|
-
// --api-key
|
|
100
|
-
|
|
101
|
-
// stored login credential).
|
|
102
|
-
if (!isMdm && !apiKeyOpt && !config.isLoggedIn()) {
|
|
99
|
+
// User scope needs --api-key, a login, or a stored device key; MDM scope requires --api-key below.
|
|
100
|
+
if (!isMdm && !apiKeyOpt && !config.isLoggedIn() && !config.getDeviceKey()) {
|
|
103
101
|
output.error('--api-key is required (or set UNBOUND_API_KEY env var, or run `unbound login` first)');
|
|
104
102
|
process.exitCode = 1;
|
|
105
103
|
return;
|
|
@@ -115,6 +113,7 @@ Examples:
|
|
|
115
113
|
let skippedTools;
|
|
116
114
|
let piFailed = false;
|
|
117
115
|
let mdmScanKey; // device-owner key resolved from the serial (MDM scope only)
|
|
116
|
+
let userKey; // the user's device key (user scope only)
|
|
118
117
|
try {
|
|
119
118
|
// Persist URLs first, then login, then setup — order matters so the
|
|
120
119
|
// login validates against the new backend and setup wires tools at the
|
|
@@ -136,10 +135,10 @@ Examples:
|
|
|
136
135
|
discoveryDomain = opts.domain ? config.normalizeUrl(opts.domain) : backendUrl;
|
|
137
136
|
|
|
138
137
|
if (isMdm) {
|
|
139
|
-
//
|
|
140
|
-
const adminApiKey = apiKeyOpt
|
|
138
|
+
// The MDM key must be passed; the CLI login is never used to deploy.
|
|
139
|
+
const adminApiKey = apiKeyOpt;
|
|
141
140
|
if (!adminApiKey) {
|
|
142
|
-
output.error('--api-key is required
|
|
141
|
+
output.error('--api-key is required: use the MDM key from Configure \u2192 My Org.');
|
|
143
142
|
process.exitCode = 1;
|
|
144
143
|
return;
|
|
145
144
|
}
|
|
@@ -177,12 +176,13 @@ Examples:
|
|
|
177
176
|
discoverySucceeded = true;
|
|
178
177
|
skippedTools = skipped;
|
|
179
178
|
} else {
|
|
180
|
-
await
|
|
179
|
+
const apiKey = await resolveDeviceKey({
|
|
181
180
|
apiKey: apiKeyOpt,
|
|
182
181
|
baseUrl: written.base_url,
|
|
183
182
|
frontendUrl: written.frontend_url,
|
|
184
183
|
});
|
|
185
|
-
|
|
184
|
+
userKey = apiKey;
|
|
185
|
+
telemetry.rememberSecret(apiKey);
|
|
186
186
|
|
|
187
187
|
console.log('');
|
|
188
188
|
output.info('Step 1/2: Installing tool bundle');
|
|
@@ -237,7 +237,7 @@ Examples:
|
|
|
237
237
|
output.info('Setting up daily scheduled run');
|
|
238
238
|
await setupScheduledRun({
|
|
239
239
|
command: 'onboard',
|
|
240
|
-
apiKey:
|
|
240
|
+
apiKey: userKey,
|
|
241
241
|
domain: discoveryDomain,
|
|
242
242
|
skipRunAtLoad: true,
|
|
243
243
|
});
|
package/src/commands/setup.js
CHANGED
|
@@ -6,7 +6,7 @@ const path = require('path');
|
|
|
6
6
|
const config = require('../config');
|
|
7
7
|
const output = require('../output');
|
|
8
8
|
const telemetry = require('../telemetry');
|
|
9
|
-
const
|
|
9
|
+
const auth = require('../auth');
|
|
10
10
|
const { confirm, downloadToFile, withSecureTempFile } = require('../utils');
|
|
11
11
|
const { reportSetupFailure, exitCodeForError } = require('../setup-report');
|
|
12
12
|
const claudePaths = require('../claudePaths');
|
|
@@ -790,11 +790,11 @@ Examples:
|
|
|
790
790
|
|
|
791
791
|
Install the default bundle (Cursor + Copilot + Augment + Claude Code hooks + Codex hooks):
|
|
792
792
|
$ unbound setup --all Set up the default bundle
|
|
793
|
-
$ unbound setup --all --api-key <key>
|
|
793
|
+
$ unbound setup --all --api-key <key> Set up the bundle with a device key
|
|
794
794
|
|
|
795
795
|
Configure all users on the device (MDM/org scope — run with sudo):
|
|
796
|
-
$ sudo unbound setup --all
|
|
797
|
-
$ sudo unbound setup cursor codex-subscription
|
|
796
|
+
$ sudo unbound setup --all --api-key <MDM_KEY> Configure all users (MDM)
|
|
797
|
+
$ sudo unbound setup cursor codex-subscription --api-key <MDM_KEY> Configure specific tools for all users
|
|
798
798
|
$ sudo unbound setup --clear --all Remove config for all users
|
|
799
799
|
|
|
800
800
|
Seed historical sessions (Claude Code / Codex subscription mode + Copilot):
|
|
@@ -856,9 +856,10 @@ must update the MDM configuration.
|
|
|
856
856
|
output.info(scopeNotice(isMdm));
|
|
857
857
|
if (isMdm) {
|
|
858
858
|
const mdmToolNames = [...Object.keys(MDM_TOOLS), 'claude-code', 'codex'].join(', ');
|
|
859
|
-
|
|
859
|
+
// The MDM key must be passed; the CLI login is never used to deploy.
|
|
860
|
+
const adminApiKey = opts.adminApiKey || opts.apiKey;
|
|
860
861
|
if (!opts.clear && !adminApiKey) {
|
|
861
|
-
output.error('--api-key is required to set up tools
|
|
862
|
+
output.error('--api-key is required to set up tools for all users: use the MDM key from Configure \u2192 My Org.');
|
|
862
863
|
process.exitCode = 1;
|
|
863
864
|
return;
|
|
864
865
|
}
|
|
@@ -942,14 +943,12 @@ must update the MDM configuration.
|
|
|
942
943
|
|
|
943
944
|
// Clearing config needs no credentials — the setup scripts remove
|
|
944
945
|
// files without calling the API — so don't force a login for --clear.
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
}
|
|
952
|
-
const apiKey = config.getApiKey();
|
|
946
|
+
const apiKey = opts.clear ? config.getDeviceKey() : await auth.resolveDeviceKey({
|
|
947
|
+
apiKey: opts.apiKey,
|
|
948
|
+
baseUrl: written.base_url,
|
|
949
|
+
frontendUrl: written.frontend_url,
|
|
950
|
+
});
|
|
951
|
+
telemetry.rememberSecret(apiKey);
|
|
953
952
|
const urlOpts = { backendUrl, frontendUrl, gatewayUrl };
|
|
954
953
|
|
|
955
954
|
// --all expands to the default bundle. Cannot be combined with explicit tool names.
|
|
@@ -1176,7 +1175,7 @@ What it clears:
|
|
|
1176
1175
|
scheduled task, and the key in Keychain / creds file / Credential Manager. Under
|
|
1177
1176
|
sudo this is torn down for the invoking user (the scan is a per-user artifact).
|
|
1178
1177
|
- MDM (system-level) tool config across all users [only when run as root]
|
|
1179
|
-
- Stored credentials and settings (~/.unbound/config.json).
|
|
1178
|
+
- Stored credentials and settings (~/.unbound/config.json and the CLI key in the OS keychain).
|
|
1180
1179
|
|
|
1181
1180
|
After it finishes, run \`unbound login\` (or \`unbound onboard\`) to set things up
|
|
1182
1181
|
fresh. Clearing never contacts the Unbound API, so no API key is needed. The
|
|
@@ -1263,8 +1262,11 @@ Examples:
|
|
|
1263
1262
|
}
|
|
1264
1263
|
|
|
1265
1264
|
// Wipe credentials + settings last, regardless of tool-clear outcomes.
|
|
1266
|
-
config.clearConfig();
|
|
1265
|
+
const keyRemoved = config.clearConfig();
|
|
1267
1266
|
output.success('Stored credentials and settings removed.');
|
|
1267
|
+
// As root, the keychain reached is root's, not the invoking user's.
|
|
1268
|
+
if (hasRootPrivileges()) output.info('Run `unbound logout` without sudo to remove your CLI key from the keychain.');
|
|
1269
|
+
else if (!keyRemoved) output.warn('Could not remove the CLI key from the OS keychain. Unlock it and run `unbound logout`.');
|
|
1268
1270
|
|
|
1269
1271
|
console.log('');
|
|
1270
1272
|
const failed = [...userFailed, ...mdmFailed, ...binaryFailed, ...scheduledFailed];
|
package/src/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const os = require('os');
|
|
4
|
+
const keychain = require('./keychain');
|
|
4
5
|
|
|
5
6
|
const CONFIG_DIR = path.join(os.homedir(), '.unbound');
|
|
6
7
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
@@ -59,12 +60,27 @@ function writeConfig(config) {
|
|
|
59
60
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
// The CLI login key reads org data, so it lives in the OS keychain; UNBOUND_TOKEN is read only when the keychain has none.
|
|
62
64
|
function getApiKey() {
|
|
63
|
-
|
|
64
|
-
return config.api_key || null;
|
|
65
|
+
return keychain.get() || process.env.UNBOUND_TOKEN || null;
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
/** Stores the CLI login key; false when no keychain could keep it past this run. */
|
|
67
69
|
function setApiKey(apiKey) {
|
|
70
|
+
return keychain.set(apiKey);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True once no CLI key is left in the keychain. */
|
|
74
|
+
function clearApiKey() {
|
|
75
|
+
return keychain.remove();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The send-only device key, where setup scripts and hooks read it.
|
|
79
|
+
function getDeviceKey() {
|
|
80
|
+
return readConfig().api_key || null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function setDeviceKey(apiKey) {
|
|
68
84
|
const config = readConfig();
|
|
69
85
|
config.api_key = apiKey;
|
|
70
86
|
writeConfig(config);
|
|
@@ -127,10 +143,13 @@ function setUrls({ gateway, frontend, backend } = {}) {
|
|
|
127
143
|
return normalized;
|
|
128
144
|
}
|
|
129
145
|
|
|
146
|
+
/** Removes config.json and the keychain CLI key; false when the keychain kept the key. */
|
|
130
147
|
function clearConfig() {
|
|
148
|
+
const keyRemoved = clearApiKey();
|
|
131
149
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
132
150
|
fs.unlinkSync(CONFIG_FILE);
|
|
133
151
|
}
|
|
152
|
+
return keyRemoved;
|
|
134
153
|
}
|
|
135
154
|
|
|
136
155
|
function isLoggedIn() {
|
|
@@ -174,6 +193,9 @@ module.exports = {
|
|
|
174
193
|
writeConfig,
|
|
175
194
|
getApiKey,
|
|
176
195
|
setApiKey,
|
|
196
|
+
clearApiKey,
|
|
197
|
+
getDeviceKey,
|
|
198
|
+
setDeviceKey,
|
|
177
199
|
getBaseUrl,
|
|
178
200
|
setBaseUrl,
|
|
179
201
|
getFrontendUrl,
|
package/src/keychain.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI login key lives in the OS keychain, never in a file. The secret only
|
|
3
|
+
* ever travels on stdin, so it never shows up in `ps` or process-audit logs.
|
|
4
|
+
*/
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
const SERVICE = 'ai.getunbound.cli';
|
|
8
|
+
const ACCOUNT = 'api_key';
|
|
9
|
+
const TIMEOUT_MS = 15_000;
|
|
10
|
+
// Keys are url-safe tokens; this also keeps `security -i` from reading a second command.
|
|
11
|
+
const KEY_PATTERN = /^[A-Za-z0-9._~-]{8,512}$/;
|
|
12
|
+
|
|
13
|
+
const WINDOWS_CRED = `
|
|
14
|
+
$ErrorActionPreference = 'Stop'
|
|
15
|
+
Add-Type -TypeDefinition @'
|
|
16
|
+
using System;
|
|
17
|
+
using System.Runtime.InteropServices;
|
|
18
|
+
using System.Text;
|
|
19
|
+
public static class UnboundCred {
|
|
20
|
+
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
|
21
|
+
public struct CREDENTIAL {
|
|
22
|
+
public int Flags; public int Type;
|
|
23
|
+
[MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
|
|
24
|
+
[MarshalAs(UnmanagedType.LPWStr)] public string Comment;
|
|
25
|
+
public long LastWritten; public int CredentialBlobSize; public IntPtr CredentialBlob;
|
|
26
|
+
public int Persist; public int AttributeCount; public IntPtr Attributes;
|
|
27
|
+
[MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias;
|
|
28
|
+
[MarshalAs(UnmanagedType.LPWStr)] public string UserName;
|
|
29
|
+
}
|
|
30
|
+
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool CredWriteW(ref CREDENTIAL c, uint f);
|
|
31
|
+
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool CredReadW(string t, int type, int f, out IntPtr c);
|
|
32
|
+
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool CredDeleteW(string t, int type, int f);
|
|
33
|
+
[DllImport("advapi32.dll")] static extern void CredFree(IntPtr c);
|
|
34
|
+
public static bool Write(string t, string u, string s) {
|
|
35
|
+
var b = Encoding.Unicode.GetBytes(s); var p = GCHandle.Alloc(b, GCHandleType.Pinned);
|
|
36
|
+
try {
|
|
37
|
+
var c = new CREDENTIAL { Type = 1, TargetName = t, UserName = u, CredentialBlob = p.AddrOfPinnedObject(), CredentialBlobSize = b.Length, Persist = 2 };
|
|
38
|
+
return CredWriteW(ref c, 0);
|
|
39
|
+
} finally { p.Free(); }
|
|
40
|
+
}
|
|
41
|
+
public static string Read(string t) {
|
|
42
|
+
IntPtr ptr; if (!CredReadW(t, 1, 0, out ptr)) return "";
|
|
43
|
+
try { var c = (CREDENTIAL)Marshal.PtrToStructure(ptr, typeof(CREDENTIAL)); return Marshal.PtrToStringUni(c.CredentialBlob, c.CredentialBlobSize / 2); }
|
|
44
|
+
finally { CredFree(ptr); }
|
|
45
|
+
}
|
|
46
|
+
public static bool Delete(string t) { return CredDeleteW(t, 1, 0); }
|
|
47
|
+
}
|
|
48
|
+
'@
|
|
49
|
+
$target = '${SERVICE}:${ACCOUNT}'
|
|
50
|
+
switch ($args[0]) {
|
|
51
|
+
'read' { [Console]::Out.Write([UnboundCred]::Read($target)) }
|
|
52
|
+
'write' { if (-not [UnboundCred]::Write($target, '${ACCOUNT}', [Console]::In.ReadToEnd())) { exit 1 } }
|
|
53
|
+
'delete' { [void][UnboundCred]::Delete($target) }
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
function run(cmd, args, input) {
|
|
58
|
+
return execFileSync(cmd, args, {
|
|
59
|
+
input: input === undefined ? '' : input,
|
|
60
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
61
|
+
timeout: TIMEOUT_MS,
|
|
62
|
+
windowsHide: true,
|
|
63
|
+
}).toString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function windows(action, input) {
|
|
67
|
+
const script = `& { ${WINDOWS_CRED} } ${action}`;
|
|
68
|
+
return run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], input);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const backends = {
|
|
72
|
+
darwin: {
|
|
73
|
+
read: () => run('security', ['find-generic-password', '-s', SERVICE, '-a', ACCOUNT, '-w']),
|
|
74
|
+
write: (key) => {
|
|
75
|
+
// With no default keychain (sudo, odd HOME), add-generic-password pops a "Keychain Not Found" dialog; this fails quietly first.
|
|
76
|
+
run('security', ['default-keychain']);
|
|
77
|
+
return run('security', ['-i'], `add-generic-password -U -s ${SERVICE} -a ${ACCOUNT} -w ${key}\n`);
|
|
78
|
+
},
|
|
79
|
+
remove: () => run('security', ['delete-generic-password', '-s', SERVICE, '-a', ACCOUNT]),
|
|
80
|
+
},
|
|
81
|
+
linux: {
|
|
82
|
+
read: () => run('secret-tool', ['lookup', 'service', SERVICE, 'account', ACCOUNT]),
|
|
83
|
+
write: (key) => run('secret-tool', ['store', '--label=Unbound CLI', 'service', SERVICE, 'account', ACCOUNT], key),
|
|
84
|
+
remove: () => run('secret-tool', ['clear', 'service', SERVICE, 'account', ACCOUNT]),
|
|
85
|
+
},
|
|
86
|
+
win32: {
|
|
87
|
+
read: () => windows('read'),
|
|
88
|
+
write: (key) => windows('write', key),
|
|
89
|
+
remove: () => windows('delete'),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
let cached;
|
|
94
|
+
|
|
95
|
+
function backend() {
|
|
96
|
+
return backends[process.platform] || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The stored CLI key, or null when there is none or no keychain is reachable. */
|
|
100
|
+
function get() {
|
|
101
|
+
if (cached !== undefined) return cached;
|
|
102
|
+
let value = null;
|
|
103
|
+
try {
|
|
104
|
+
const b = backend();
|
|
105
|
+
value = (b && b.read().trim()) || null;
|
|
106
|
+
} catch {
|
|
107
|
+
value = null;
|
|
108
|
+
}
|
|
109
|
+
cached = value;
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Stores the CLI key; true only once it reads back, since a locked keychain can report success and store nothing. */
|
|
114
|
+
function set(key) {
|
|
115
|
+
if (!KEY_PATTERN.test(String(key || ''))) return false;
|
|
116
|
+
let stored = false;
|
|
117
|
+
try {
|
|
118
|
+
const b = backend();
|
|
119
|
+
if (b) {
|
|
120
|
+
b.write(key);
|
|
121
|
+
cached = undefined;
|
|
122
|
+
stored = get() === key;
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
stored = false;
|
|
126
|
+
}
|
|
127
|
+
// Usable for the rest of this run even when no keychain kept it.
|
|
128
|
+
cached = key;
|
|
129
|
+
return stored;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Exit codes that mean "no such item", as opposed to a locked or unreachable keychain.
|
|
133
|
+
const NOT_FOUND_STATUS = { darwin: 44, linux: 1 };
|
|
134
|
+
|
|
135
|
+
function isAbsent() {
|
|
136
|
+
try {
|
|
137
|
+
const b = backend();
|
|
138
|
+
return !b || !b.read().trim();
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return err.status === NOT_FOUND_STATUS[process.platform];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Removes the CLI key; true only once the keychain confirms it is gone. */
|
|
145
|
+
function remove() {
|
|
146
|
+
try {
|
|
147
|
+
const b = backend();
|
|
148
|
+
if (b) b.remove();
|
|
149
|
+
} catch {
|
|
150
|
+
// Already absent, or the keychain refused: the check below decides.
|
|
151
|
+
}
|
|
152
|
+
cached = undefined;
|
|
153
|
+
return isAbsent();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = { get, set, remove, SERVICE, ACCOUNT, _backends: backends, _reset: () => { cached = undefined; } };
|
package/src/setup-report.js
CHANGED
|
@@ -84,7 +84,7 @@ function exitCodeForError(err) {
|
|
|
84
84
|
*/
|
|
85
85
|
async function reportSetupFailure({ step, exitCode, apiKey, backendUrl } = {}) {
|
|
86
86
|
try {
|
|
87
|
-
const key = apiKey || config.
|
|
87
|
+
const key = apiKey || config.getDeviceKey();
|
|
88
88
|
// No key → nothing to authenticate with; the ledger upserts by org, so a
|
|
89
89
|
// keyless report can't be attributed. Skip quietly.
|
|
90
90
|
if (!key) return false;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
const DEVICE_KEY_PATH = '/api/v1/automations/get_application_api_key/';
|
|
5
|
+
const CLI_KEY = 'cli-key-123';
|
|
6
|
+
|
|
7
|
+
function unauthorized() {
|
|
8
|
+
return Object.assign(new Error('Invalid API key. Run `unbound login`.'), { statusCode: 401 });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// A fake backend: only the CLI key may fetch a device key; any key passes privileges.
|
|
12
|
+
function freshAuth({ keychainKey = null, configKey = null } = {}) {
|
|
13
|
+
for (const m of ['../src/auth', '../src/api', '../src/config', '../src/output', '../src/device-serial']) {
|
|
14
|
+
delete require.cache[require.resolve(m)];
|
|
15
|
+
}
|
|
16
|
+
const api = require('../src/api');
|
|
17
|
+
const config = require('../src/config');
|
|
18
|
+
const output = require('../src/output');
|
|
19
|
+
const deviceSerial = require('../src/device-serial');
|
|
20
|
+
const state = { keychain: keychainKey, config: configKey, warns: [] };
|
|
21
|
+
|
|
22
|
+
deviceSerial.getDeviceSerial = async () => 'SERIAL';
|
|
23
|
+
api.get = async (path, opts) => {
|
|
24
|
+
if (path !== DEVICE_KEY_PATH) return { email: 'a@b.co', org_name: 'Acme' };
|
|
25
|
+
if (opts.apiKey !== CLI_KEY) throw unauthorized();
|
|
26
|
+
return { api_key: 'device-key' };
|
|
27
|
+
};
|
|
28
|
+
config.getApiKey = () => state.keychain;
|
|
29
|
+
config.setApiKey = (k) => { state.keychain = k; return true; };
|
|
30
|
+
config.getDeviceKey = () => state.config;
|
|
31
|
+
config.setDeviceKey = (k) => { state.config = k; };
|
|
32
|
+
config.backfillUserInfo = () => {};
|
|
33
|
+
output.spinner = () => ({ succeed() {}, fail(msg) { state.failed = msg; } });
|
|
34
|
+
output.warn = (m) => state.warns.push(m);
|
|
35
|
+
return { auth: require('../src/auth'), state };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('login --api-key keeps the CLI key in the keychain and the device key in config.json', async () => {
|
|
39
|
+
const { auth, state } = freshAuth();
|
|
40
|
+
await auth.loginWithApiKey(CLI_KEY);
|
|
41
|
+
assert.equal(state.keychain, CLI_KEY);
|
|
42
|
+
assert.equal(state.config, 'device-key');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('login --api-key rejects a device or MDM key and stores nothing', async () => {
|
|
46
|
+
const { auth, state } = freshAuth();
|
|
47
|
+
await assert.rejects(() => auth.loginWithApiKey('device-key'));
|
|
48
|
+
assert.match(state.failed, /can't be used with the CLI/);
|
|
49
|
+
assert.equal(state.keychain, null);
|
|
50
|
+
assert.equal(state.config, null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('setup --api-key with a device key installs it as-is and never saves it as the login', async () => {
|
|
54
|
+
const { auth, state } = freshAuth();
|
|
55
|
+
assert.equal(await auth.resolveDeviceKey({ apiKey: 'device-key' }), 'device-key');
|
|
56
|
+
assert.equal(state.keychain, null);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('setup --api-key with a CLI key installs the device key instead', async () => {
|
|
60
|
+
const { auth, state } = freshAuth();
|
|
61
|
+
assert.equal(await auth.resolveDeviceKey({ apiKey: CLI_KEY }), 'device-key');
|
|
62
|
+
assert.equal(state.keychain, null, 'a passed key is never saved as the login');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('setup when logged in refreshes the device key from the login', async () => {
|
|
66
|
+
const { auth, state } = freshAuth({ keychainKey: CLI_KEY, configKey: 'stale-key' });
|
|
67
|
+
assert.equal(await auth.resolveDeviceKey({}), 'device-key');
|
|
68
|
+
assert.equal(state.config, 'device-key');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('an old CLI login in config.json moves to the keychain and is replaced by the device key', async () => {
|
|
72
|
+
const { auth, state } = freshAuth({ configKey: CLI_KEY });
|
|
73
|
+
assert.equal(await auth.resolveDeviceKey({}), 'device-key');
|
|
74
|
+
assert.equal(state.keychain, CLI_KEY);
|
|
75
|
+
assert.equal(state.config, 'device-key');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the keychain wins; UNBOUND_TOKEN is only a fallback, and malformed keys never reach the keychain', () => {
|
|
79
|
+
delete require.cache[require.resolve('../src/config')];
|
|
80
|
+
delete require.cache[require.resolve('../src/keychain')];
|
|
81
|
+
const keychain = require('../src/keychain');
|
|
82
|
+
const config = require('../src/config');
|
|
83
|
+
const prev = process.env.UNBOUND_TOKEN;
|
|
84
|
+
process.env.UNBOUND_TOKEN = 'from-env';
|
|
85
|
+
try {
|
|
86
|
+
keychain.get = () => 'from-keychain';
|
|
87
|
+
assert.equal(config.getApiKey(), 'from-keychain');
|
|
88
|
+
keychain.get = () => null;
|
|
89
|
+
assert.equal(config.getApiKey(), 'from-env');
|
|
90
|
+
} finally {
|
|
91
|
+
if (prev === undefined) delete process.env.UNBOUND_TOKEN; else process.env.UNBOUND_TOKEN = prev;
|
|
92
|
+
}
|
|
93
|
+
delete require.cache[require.resolve('../src/keychain')];
|
|
94
|
+
assert.equal(require('../src/keychain').set('bad key\nadd-generic-password'), false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('an outage while checking a passed key fails setup instead of installing a possible CLI key', async () => {
|
|
98
|
+
const { auth } = freshAuth();
|
|
99
|
+
require('../src/api').get = async () => { throw Object.assign(new Error('Service unavailable'), { statusCode: 503 }); };
|
|
100
|
+
await assert.rejects(() => auth.resolveDeviceKey({ apiKey: CLI_KEY }), /Service unavailable/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('where the backend hands a device key back as itself, it is never stored as the login', async () => {
|
|
104
|
+
const { auth, state } = freshAuth({ configKey: 'device-key' });
|
|
105
|
+
require('../src/api').get = async (path, opts) => (path === DEVICE_KEY_PATH ? { api_key: opts.apiKey } : {});
|
|
106
|
+
await assert.rejects(() => auth.loginWithApiKey('device-key'));
|
|
107
|
+
assert.equal(state.keychain, null);
|
|
108
|
+
assert.equal(await auth.resolveDeviceKey({}), 'device-key');
|
|
109
|
+
assert.equal(state.keychain, null, 'a device key is not migrated into the keychain');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
async function runLogout(fetchDeviceKey) {
|
|
113
|
+
for (const m of ['../src/commands/logout', '../src/auth', '../src/config', '../src/output']) {
|
|
114
|
+
delete require.cache[require.resolve(m)];
|
|
115
|
+
}
|
|
116
|
+
const auth = require('../src/auth');
|
|
117
|
+
const config = require('../src/config');
|
|
118
|
+
const output = require('../src/output');
|
|
119
|
+
const state = { config: CLI_KEY, errors: [], success: 0 };
|
|
120
|
+
auth.fetchDeviceKey = fetchDeviceKey;
|
|
121
|
+
config.getDeviceKey = () => state.config;
|
|
122
|
+
config.setDeviceKey = (k) => { state.config = k; };
|
|
123
|
+
config.clearApiKey = () => true;
|
|
124
|
+
config.readConfig = () => ({});
|
|
125
|
+
config.writeConfig = () => {};
|
|
126
|
+
output.error = (m) => state.errors.push(m);
|
|
127
|
+
output.warn = () => {};
|
|
128
|
+
output.success = () => { state.success++; };
|
|
129
|
+
const { Command } = require('commander');
|
|
130
|
+
const program = new Command();
|
|
131
|
+
program.exitOverride();
|
|
132
|
+
require('../src/commands/logout').register(program);
|
|
133
|
+
const prev = process.exitCode;
|
|
134
|
+
process.exitCode = 0;
|
|
135
|
+
await program.parseAsync(['node', 'unbound', 'logout']);
|
|
136
|
+
state.exitCode = process.exitCode;
|
|
137
|
+
process.exitCode = prev;
|
|
138
|
+
return state;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test('logout swaps an old CLI login in config.json for its device key', async () => {
|
|
142
|
+
const state = await runLogout(async () => 'device-key');
|
|
143
|
+
assert.equal(state.config, 'device-key');
|
|
144
|
+
assert.equal(state.success, 1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('logout offline does not claim success while config.json may still hold a CLI key', async () => {
|
|
148
|
+
const state = await runLogout(async () => { throw Object.assign(new Error('offline'), { code: 'ECONNREFUSED' }); });
|
|
149
|
+
assert.equal(state.exitCode, 1);
|
|
150
|
+
assert.equal(state.success, 0);
|
|
151
|
+
assert.match(state.errors[0], /again when online/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('moving an old CLI login warns when no keychain can keep it', async () => {
|
|
155
|
+
const { auth, state } = freshAuth({ configKey: CLI_KEY });
|
|
156
|
+
require('../src/config').setApiKey = () => false;
|
|
157
|
+
assert.equal(await auth.resolveDeviceKey({}), 'device-key');
|
|
158
|
+
assert.equal(state.config, 'device-key', 'the read-access key never stays where tools read it');
|
|
159
|
+
assert.match(state.warns[0], /UNBOUND_TOKEN/);
|
|
160
|
+
});
|
package/test/flags.test.js
CHANGED
|
@@ -136,7 +136,7 @@ function runCli(port, args) {
|
|
|
136
136
|
JSON.stringify({ api_key: 'test-key', base_url: `http://127.0.0.1:${port}` }));
|
|
137
137
|
return new Promise((resolve) => {
|
|
138
138
|
const child = spawn('node', [path.join(__dirname, '..', 'src', 'index.js'), ...args], {
|
|
139
|
-
env: { ...process.env, HOME: home, UNBOUND_API_URL: '', UNBOUND_TELEMETRY: '0', UNBOUND_NO_UPDATE_CHECK: '1' },
|
|
139
|
+
env: { ...process.env, HOME: home, UNBOUND_TOKEN: 'test-key', UNBOUND_API_URL: '', UNBOUND_TELEMETRY: '0', UNBOUND_NO_UPDATE_CHECK: '1' },
|
|
140
140
|
});
|
|
141
141
|
let stdout = '';
|
|
142
142
|
let stderr = '';
|
|
@@ -122,7 +122,7 @@ test('per-user onboard cron passes apiKey but NOT discoveryKey to setupScheduled
|
|
|
122
122
|
// key; the MDM (sudo) branch resolves the device-owner key from the serial and
|
|
123
123
|
// scans with THAT. Neither scan uses a separate org discovery key.
|
|
124
124
|
test('per-user scans with the user key; MDM scans with the resolved device-owner key; no discovery key', () => {
|
|
125
|
-
const userBranchStart = onboardSrc.indexOf('const apiKey =
|
|
125
|
+
const userBranchStart = onboardSrc.indexOf('const apiKey = await resolveDeviceKey(');
|
|
126
126
|
assert.notEqual(userBranchStart, -1, 'expected the per-user branch in onboard.js');
|
|
127
127
|
const userBranch = onboardSrc.slice(userBranchStart);
|
|
128
128
|
assert.match(
|
|
@@ -46,7 +46,7 @@ function loadOnboard({ isRoot, setupOk, reportThrows }) {
|
|
|
46
46
|
setup.runSetupAllBundle = bundle;
|
|
47
47
|
setup.runMdmSetupAllBundle = bundle;
|
|
48
48
|
discover.runDiscoveryScan = async () => { calls.discovery++; };
|
|
49
|
-
auth.
|
|
49
|
+
auth.resolveDeviceKey = async () => 'resolved-key';
|
|
50
50
|
config.setUrls = () => ({});
|
|
51
51
|
config.getApiKey = () => 'resolved-key';
|
|
52
52
|
config.getBaseUrl = () => 'https://b.acme';
|
|
@@ -63,7 +63,8 @@ async function runOnboard(isRoot, { setCron = false, discoveryKey = 'd', domain,
|
|
|
63
63
|
setup.runMdmSetupAllBundle = async () => { calls.mdm++; return { ok: true, skipped: [] }; };
|
|
64
64
|
setup.runSetupAllBundle = async () => { calls.user++; return { ok: true, skipped: [] }; };
|
|
65
65
|
discover.runDiscoveryScan = async (o) => { calls.discovery++; calls.discoveryArgs = o; };
|
|
66
|
-
|
|
66
|
+
// User scope resolves the device key (the stored or passed key, swapped for a device key).
|
|
67
|
+
auth.resolveDeviceKey = async () => { calls.loggedIn++; return 'resolved-key'; };
|
|
67
68
|
// MDM scope resolves the device-owner key from the serial and scans with it.
|
|
68
69
|
auth.fetchDeviceOwnerKey = async (adminKey, opts) => {
|
|
69
70
|
calls.ownerFetchedFrom = adminKey;
|
|
@@ -101,7 +102,7 @@ test('onboard with sudo/root → installs the MDM bundle (all users), no login',
|
|
|
101
102
|
const calls = await runOnboard(true);
|
|
102
103
|
assert.equal(calls.mdm, 1, 'MDM bundle installed');
|
|
103
104
|
assert.equal(calls.user, 0, 'user bundle NOT installed');
|
|
104
|
-
assert.equal(calls.loggedIn, 0, 'MDM scope does not
|
|
105
|
+
assert.equal(calls.loggedIn, 0, 'MDM scope does not resolve a user device key');
|
|
105
106
|
assert.equal(calls.discovery, 1, 'discovery still runs');
|
|
106
107
|
assert.equal(calls.ownerFetchedFrom, 'k', 'MDM scope resolves the owner key from the admin key');
|
|
107
108
|
assert.equal(calls.discoveryArgs.apiKey, 'owner-key', 'MDM scope scans with the device-owner key resolved from the serial');
|
|
@@ -133,7 +134,7 @@ test('onboard without sudo → logs in and installs the user bundle', async () =
|
|
|
133
134
|
const calls = await runOnboard(false);
|
|
134
135
|
assert.equal(calls.user, 1, 'user bundle installed');
|
|
135
136
|
assert.equal(calls.mdm, 0, 'MDM bundle NOT installed');
|
|
136
|
-
assert.equal(calls.loggedIn, 1, 'user scope
|
|
137
|
+
assert.equal(calls.loggedIn, 1, 'user scope resolves the device key');
|
|
137
138
|
assert.equal(calls.discovery, 1, 'discovery still runs');
|
|
138
139
|
// WEB-4891: per-user scan uses the resolved user key, NOT the org discovery key.
|
|
139
140
|
assert.equal(calls.discoveryArgs.apiKey, 'resolved-key', 'user scope scans with the user API key');
|
|
@@ -156,7 +157,7 @@ test('onboard --set-cron without sudo → schedules the full onboard run', async
|
|
|
156
157
|
assert.equal(calls.cron.command, 'onboard', 'user scope keeps the full onboard cron');
|
|
157
158
|
// WEB-4891: the per-user cron re-runs onboard, which scans with the user's own
|
|
158
159
|
// key — so the user key is stored and no separate discovery key is.
|
|
159
|
-
assert.equal(calls.cron.apiKey, '
|
|
160
|
+
assert.equal(calls.cron.apiKey, 'resolved-key', 'per-user cron stores the resolved device key, never the CLI login');
|
|
160
161
|
assert.equal(calls.cron.discoveryKey, undefined, 'per-user onboard cron no longer forwards a discovery key');
|
|
161
162
|
});
|
|
162
163
|
|
|
@@ -70,7 +70,7 @@ async function driveSetup(tools) {
|
|
|
70
70
|
// non-root and the run reaches the instruction-tool call sites on any host.
|
|
71
71
|
const origGetuid = process.getuid;
|
|
72
72
|
process.getuid = () => 1000;
|
|
73
|
-
auth.
|
|
73
|
+
auth.resolveDeviceKey = async () => 'resolved-key';
|
|
74
74
|
config.setUrls = () => ({});
|
|
75
75
|
config.getBaseUrl = () => 'https://backend.acme';
|
|
76
76
|
config.getFrontendUrl = () => 'https://frontend.acme';
|
|
@@ -20,7 +20,7 @@ test('reportSetupFailure: posts the contract payload to /api/v1/setup/failed/',
|
|
|
20
20
|
const { api, config, report } = freshReport();
|
|
21
21
|
let captured = null;
|
|
22
22
|
api.post = async (path, opts) => { captured = { path, opts }; return { ok: true }; };
|
|
23
|
-
config.
|
|
23
|
+
config.getDeviceKey = () => 'stored-key';
|
|
24
24
|
|
|
25
25
|
const ok = await report.reportSetupFailure({ step: 'cursor', exitCode: 2, backendUrl: 'https://b.acme' });
|
|
26
26
|
|
|
@@ -42,7 +42,7 @@ test('reportSetupFailure: bounds the POST with a 3s timeout (never blocks on def
|
|
|
42
42
|
const { api, config, report } = freshReport();
|
|
43
43
|
let captured = null;
|
|
44
44
|
api.post = async (path, opts) => { captured = opts; return { ok: true }; };
|
|
45
|
-
config.
|
|
45
|
+
config.getDeviceKey = () => 'k';
|
|
46
46
|
|
|
47
47
|
await report.reportSetupFailure({ step: 'cursor', exitCode: 1 });
|
|
48
48
|
assert.equal(captured.timeoutMs, 3000, 'failure report must pass a tight 3s timeout');
|
|
@@ -51,7 +51,7 @@ test('reportSetupFailure: bounds the POST with a 3s timeout (never blocks on def
|
|
|
51
51
|
test('reportSetupFailure: swallows a POST rejection and returns false (best-effort)', async () => {
|
|
52
52
|
const { api, config, report } = freshReport();
|
|
53
53
|
api.post = async () => { throw new Error('ECONNREFUSED / offline'); };
|
|
54
|
-
config.
|
|
54
|
+
config.getDeviceKey = () => 'k';
|
|
55
55
|
|
|
56
56
|
let result;
|
|
57
57
|
await assert.doesNotReject(async () => { result = await report.reportSetupFailure({ step: 'codex', exitCode: 1 }); });
|
|
@@ -61,7 +61,7 @@ test('reportSetupFailure: swallows a POST rejection and returns false (best-effo
|
|
|
61
61
|
test('reportSetupFailure: a 429 (rate-limited) is swallowed too', async () => {
|
|
62
62
|
const { api, config, report } = freshReport();
|
|
63
63
|
api.post = async () => { const e = new Error('Too many requests'); e.statusCode = 429; throw e; };
|
|
64
|
-
config.
|
|
64
|
+
config.getDeviceKey = () => 'k';
|
|
65
65
|
|
|
66
66
|
const result = await report.reportSetupFailure({ step: 'cursor', exitCode: 1 });
|
|
67
67
|
assert.equal(result, false);
|
|
@@ -71,7 +71,7 @@ test('reportSetupFailure: no API key → skips quietly (false), no POST attempte
|
|
|
71
71
|
const { api, config, report } = freshReport();
|
|
72
72
|
let called = false;
|
|
73
73
|
api.post = async () => { called = true; return {}; };
|
|
74
|
-
config.
|
|
74
|
+
config.getDeviceKey = () => null;
|
|
75
75
|
|
|
76
76
|
const result = await report.reportSetupFailure({ step: 'cursor', exitCode: 1 });
|
|
77
77
|
assert.equal(result, false);
|
package/test/tool-detect.test.js
CHANGED
|
@@ -309,7 +309,7 @@ async function runOnboard({ isRoot, detect, fail, discoveryThrows }) {
|
|
|
309
309
|
calls.discovery++;
|
|
310
310
|
if (discoveryThrows) throw new Error('scan failed');
|
|
311
311
|
};
|
|
312
|
-
auth.
|
|
312
|
+
auth.resolveDeviceKey = async () => 'resolved-key';
|
|
313
313
|
auth.fetchDeviceOwnerKey = async () => 'owner-key';
|
|
314
314
|
config.setUrls = () => ({});
|
|
315
315
|
config.getApiKey = () => 'resolved-key';
|