unbound-cli 1.16.25 → 1.18.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 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 an API key (for CI/CD or headless environments)
18
- unbound login --api-key <your-api-key>
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 stored credentials |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.16.25",
3
+ "version": "1.18.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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
- config.setApiKey(apiKey);
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 = { loginWithBrowser, loginWithApiKey, ensureLoggedIn, fetchDeviceOwnerKey };
326
+ module.exports = {
327
+ loginWithBrowser, loginWithApiKey, ensureLoggedIn, fetchDeviceOwnerKey, fetchDeviceKey, resolveDeviceKey,
328
+ };
@@ -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
- // A codex policy can live only in the user's cache, which a sudo re-detection may not see.
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 r = spawnSync(process.argv[0], [process.argv[1], 'setup', ...fixNow], { stdio: 'inherit' });
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;
@@ -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. Stores the provided
27
- API key directly without browser interaction.
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();
@@ -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 stored credentials from ~/.unbound/config.json while preserving custom URL settings.')
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
- Clears stored credentials (API key, email, organization) from
11
- ~/.unbound/config.json. Custom URL settings (base_url, frontend_url)
12
- are preserved so you don't need to reconfigure them.
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);
@@ -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 cfg = config.readConfig();
163
- const hasUnboundSession = !!cfg.api_key;
162
+ const hasUnboundSession = config.isLoggedIn();
164
163
 
165
164
  const { execSync } = require('node:child_process');
166
165
 
@@ -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 { ensureLoggedIn, fetchDeviceOwnerKey } = require('../auth');
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 (user key, or admin key when run with sudo). Falls back to UNBOUND_API_KEY or a stored `unbound login` 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 (or a stored `unbound login` key) is all that's required, for
100
- // both scopes. MDM scope resolves the admin key below (it may come from a
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
- // Reuse the key stored by `unbound login` when no key was passed.
140
- const adminApiKey = apiKeyOpt || config.getApiKey();
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 (or run `unbound login` first).');
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 ensureLoggedIn({
179
+ const apiKey = await resolveDeviceKey({
181
180
  apiKey: apiKeyOpt,
182
181
  baseUrl: written.base_url,
183
182
  frontendUrl: written.frontend_url,
184
183
  });
185
- const apiKey = config.getApiKey();
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: apiKeyOpt || config.getApiKey(),
240
+ apiKey: userKey,
241
241
  domain: discoveryDomain,
242
242
  skipRunAtLoad: true,
243
243
  });
@@ -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 { ensureLoggedIn } = require('../auth');
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');
@@ -377,6 +377,7 @@ async function runSetupScriptReported(script, stepName, apiKey, opts = {}) {
377
377
  // — it's a generic user-owned var that can have non-Unbound uses.
378
378
  const NUKE_ENV_VARS = [
379
379
  'UNBOUND_API_KEY',
380
+ 'UNBOUND_DEVICE_TOKEN',
380
381
  'UNBOUND_CLAUDE_API_KEY',
381
382
  'UNBOUND_CODEX_API_KEY',
382
383
  'UNBOUND_COPILOT_API_KEY',
@@ -790,11 +791,11 @@ Examples:
790
791
 
791
792
  Install the default bundle (Cursor + Copilot + Augment + Claude Code hooks + Codex hooks):
792
793
  $ unbound setup --all Set up the default bundle
793
- $ unbound setup --all --api-key <key> Login + set up the bundle
794
+ $ unbound setup --all --api-key <key> Set up the bundle with a device key
794
795
 
795
796
  Configure all users on the device (MDM/org scope — run with sudo):
796
- $ sudo unbound setup --all Configure all users (MDM)
797
- $ sudo unbound setup cursor codex-subscription Configure specific tools for all users
797
+ $ sudo unbound setup --all --api-key <MDM_KEY> Configure all users (MDM)
798
+ $ sudo unbound setup cursor codex-subscription --api-key <MDM_KEY> Configure specific tools for all users
798
799
  $ sudo unbound setup --clear --all Remove config for all users
799
800
 
800
801
  Seed historical sessions (Claude Code / Codex subscription mode + Copilot):
@@ -856,9 +857,10 @@ must update the MDM configuration.
856
857
  output.info(scopeNotice(isMdm));
857
858
  if (isMdm) {
858
859
  const mdmToolNames = [...Object.keys(MDM_TOOLS), 'claude-code', 'codex'].join(', ');
859
- const adminApiKey = opts.adminApiKey || opts.apiKey || config.getApiKey();
860
+ // The MDM key must be passed; the CLI login is never used to deploy.
861
+ const adminApiKey = opts.adminApiKey || opts.apiKey;
860
862
  if (!opts.clear && !adminApiKey) {
861
- output.error('--api-key is required to set up tools (or run `unbound login` first).');
863
+ output.error('--api-key is required to set up tools for all users: use the MDM key from Configure \u2192 My Org.');
862
864
  process.exitCode = 1;
863
865
  return;
864
866
  }
@@ -942,14 +944,12 @@ must update the MDM configuration.
942
944
 
943
945
  // Clearing config needs no credentials — the setup scripts remove
944
946
  // files without calling the API — so don't force a login for --clear.
945
- if (!opts.clear) {
946
- await ensureLoggedIn({
947
- apiKey: opts.apiKey,
948
- baseUrl: written.base_url,
949
- frontendUrl: written.frontend_url,
950
- });
951
- }
952
- const apiKey = config.getApiKey();
947
+ const apiKey = opts.clear ? config.getDeviceKey() : await auth.resolveDeviceKey({
948
+ apiKey: opts.apiKey,
949
+ baseUrl: written.base_url,
950
+ frontendUrl: written.frontend_url,
951
+ });
952
+ telemetry.rememberSecret(apiKey);
953
953
  const urlOpts = { backendUrl, frontendUrl, gatewayUrl };
954
954
 
955
955
  // --all expands to the default bundle. Cannot be combined with explicit tool names.
@@ -1176,7 +1176,7 @@ What it clears:
1176
1176
  scheduled task, and the key in Keychain / creds file / Credential Manager. Under
1177
1177
  sudo this is torn down for the invoking user (the scan is a per-user artifact).
1178
1178
  - MDM (system-level) tool config across all users [only when run as root]
1179
- - Stored credentials and settings (~/.unbound/config.json).
1179
+ - Stored credentials and settings (~/.unbound/config.json and the CLI key in the OS keychain).
1180
1180
 
1181
1181
  After it finishes, run \`unbound login\` (or \`unbound onboard\`) to set things up
1182
1182
  fresh. Clearing never contacts the Unbound API, so no API key is needed. The
@@ -1263,8 +1263,11 @@ Examples:
1263
1263
  }
1264
1264
 
1265
1265
  // Wipe credentials + settings last, regardless of tool-clear outcomes.
1266
- config.clearConfig();
1266
+ const keyRemoved = config.clearConfig();
1267
1267
  output.success('Stored credentials and settings removed.');
1268
+ // As root, the keychain reached is root's, not the invoking user's.
1269
+ if (hasRootPrivileges()) output.info('Run `unbound logout` without sudo to remove your CLI key from the keychain.');
1270
+ else if (!keyRemoved) output.warn('Could not remove the CLI key from the OS keychain. Unlock it and run `unbound logout`.');
1268
1271
 
1269
1272
  console.log('');
1270
1273
  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,13 +60,32 @@ 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
- const config = readConfig();
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
+ const config = readConfig();
81
+ // api_key first: every writer still sets it, and older writers set only it.
82
+ return config.api_key || config.device_token || null;
83
+ }
84
+
85
+ function setDeviceKey(apiKey) {
68
86
  const config = readConfig();
87
+ config.device_token = apiKey;
88
+ // Older CLIs and hooks read only `api_key`; stop writing it once they are gone.
69
89
  config.api_key = apiKey;
70
90
  writeConfig(config);
71
91
  }
@@ -127,10 +147,13 @@ function setUrls({ gateway, frontend, backend } = {}) {
127
147
  return normalized;
128
148
  }
129
149
 
150
+ /** Removes config.json and the keychain CLI key; false when the keychain kept the key. */
130
151
  function clearConfig() {
152
+ const keyRemoved = clearApiKey();
131
153
  if (fs.existsSync(CONFIG_FILE)) {
132
154
  fs.unlinkSync(CONFIG_FILE);
133
155
  }
156
+ return keyRemoved;
134
157
  }
135
158
 
136
159
  function isLoggedIn() {
@@ -174,6 +197,9 @@ module.exports = {
174
197
  writeConfig,
175
198
  getApiKey,
176
199
  setApiKey,
200
+ clearApiKey,
201
+ getDeviceKey,
202
+ setDeviceKey,
177
203
  getBaseUrl,
178
204
  setBaseUrl,
179
205
  getFrontendUrl,
@@ -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; } };
@@ -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.getApiKey();
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;
package/src/toolHealth.js CHANGED
@@ -206,6 +206,14 @@ function envKeyCheck(label, name, kind = 'aux') {
206
206
  };
207
207
  }
208
208
 
209
+ // Setup writes UNBOUND_DEVICE_TOKEN; installs from before it carry only the per-tool name.
210
+ function deviceTokenCheck(legacyName) {
211
+ const check = envKeyCheck('API key env', 'UNBOUND_DEVICE_TOKEN');
212
+ if (check.ok) return check;
213
+ const legacy = envKeyCheck('API key env', legacyName);
214
+ return legacy.ok ? legacy : check;
215
+ }
216
+
209
217
  function envCheck(label, name, expected, kind = 'aux') {
210
218
  const base = label.replace(/ env$/, '');
211
219
  const r = readEnvVar(name);
@@ -334,7 +342,7 @@ function buildVariants(gatewayUrl, binaryPath) {
334
342
  checks: () => [
335
343
  configCheck('Config', '~/.cursor/hooks.json', { json: true, test: refsUnbound }),
336
344
  scriptCheck('Hook script', '~/.cursor/hooks/unbound.py'),
337
- envKeyCheck('API key env', 'UNBOUND_CURSOR_API_KEY'),
345
+ deviceTokenCheck('UNBOUND_CURSOR_API_KEY'),
338
346
  ],
339
347
  },
340
348
  {
@@ -342,7 +350,7 @@ function buildVariants(gatewayUrl, binaryPath) {
342
350
  checks: () => [
343
351
  configCheck('Config', claudePath('settings.json'), { json: true, test: refsUnbound }),
344
352
  scriptCheck('Hook script', claudePath('hooks', 'unbound.py')),
345
- envKeyCheck('API key env', 'UNBOUND_CLAUDE_API_KEY'),
353
+ deviceTokenCheck('UNBOUND_CLAUDE_API_KEY'),
346
354
  ],
347
355
  },
348
356
  {
@@ -373,7 +381,7 @@ function buildVariants(gatewayUrl, binaryPath) {
373
381
  return features.hooks === undefined ? features.codex_hooks === true : features.hooks === true;
374
382
  } }, { short: 'codex hooks not enabled', kind: 'aux' }), // aux: an org install leaves it set with no user hook
375
383
  scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
376
- envKeyCheck('API key env', 'UNBOUND_CODEX_API_KEY'),
384
+ deviceTokenCheck('UNBOUND_CODEX_API_KEY'),
377
385
  ],
378
386
  },
379
387
  {
@@ -397,7 +405,7 @@ function buildVariants(gatewayUrl, binaryPath) {
397
405
  const checks = [configCheck('Config', '~/.copilot/hooks/unbound.json', { json: true, test: refsUnbound })];
398
406
  if (hasBinary) checks.push(scriptCheck('Hook binary', binaryPath || BINARY_PATH));
399
407
  else checks.push(scriptCheck('Hook script', '~/.copilot/hooks/unbound.py'));
400
- checks.push(envKeyCheck('API key env', 'UNBOUND_COPILOT_API_KEY'));
408
+ checks.push(deviceTokenCheck('UNBOUND_COPILOT_API_KEY'));
401
409
  return checks;
402
410
  },
403
411
  },
@@ -414,7 +422,7 @@ function buildVariants(gatewayUrl, binaryPath) {
414
422
  const checks = [configCheck('Config', '~/.augment/settings.json', { json: false, test: (t) => t.includes('unbound.py') || t.includes(BINARY_MARKER) })];
415
423
  if (hasBinary) checks.push(scriptCheck('Hook binary', binaryPath || BINARY_PATH));
416
424
  else checks.push(scriptCheck('Hook script', '~/.augment/hooks/unbound.py'));
417
- checks.push(envKeyCheck('API key env', 'UNBOUND_AUGMENT_API_KEY'));
425
+ checks.push(deviceTokenCheck('UNBOUND_AUGMENT_API_KEY'));
418
426
  return checks;
419
427
  },
420
428
  },
@@ -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
+ });
@@ -0,0 +1,46 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ function withConfig(contents, fn) {
8
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-token-'));
9
+ const origHome = process.env.HOME;
10
+ process.env.HOME = tmp;
11
+ const file = path.join(tmp, '.unbound', 'config.json');
12
+ if (contents) {
13
+ fs.mkdirSync(path.dirname(file), { recursive: true });
14
+ fs.writeFileSync(file, JSON.stringify(contents));
15
+ }
16
+ delete require.cache[require.resolve('../src/config')];
17
+ try {
18
+ return fn(require('../src/config'), file);
19
+ } finally {
20
+ process.env.HOME = origHome;
21
+ delete require.cache[require.resolve('../src/config')];
22
+ fs.rmSync(tmp, { recursive: true, force: true });
23
+ }
24
+ }
25
+
26
+ test('getDeviceKey prefers api_key, which an older writer may have updated alone', () => {
27
+ withConfig({ device_token: 'stale', api_key: 'relogged' }, (config) => {
28
+ assert.equal(config.getDeviceKey(), 'relogged');
29
+ });
30
+ });
31
+
32
+ test('getDeviceKey falls back to device_token', () => {
33
+ withConfig({ device_token: 'new' }, (config) => {
34
+ assert.equal(config.getDeviceKey(), 'new');
35
+ });
36
+ });
37
+
38
+ test('setDeviceKey writes both fields so older CLIs and hooks still read it', () => {
39
+ withConfig({ api_key: 'old', base_url: 'https://api.acme.com' }, (config, file) => {
40
+ config.setDeviceKey('k2');
41
+ const written = JSON.parse(fs.readFileSync(file, 'utf8'));
42
+ assert.equal(written.device_token, 'k2');
43
+ assert.equal(written.api_key, 'k2');
44
+ assert.equal(written.base_url, 'https://api.acme.com');
45
+ });
46
+ });
@@ -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 = config.getApiKey();');
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.ensureLoggedIn = async () => {};
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
- auth.ensureLoggedIn = async () => { calls.loggedIn++; };
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 run ensureLoggedIn');
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 runs ensureLoggedIn');
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, 'k', 'per-user cron stores the user API key');
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.ensureLoggedIn = async () => {};
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.getApiKey = () => 'stored-key';
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.getApiKey = () => 'k';
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.getApiKey = () => 'k';
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.getApiKey = () => 'k';
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.getApiKey = () => null;
74
+ config.getDeviceKey = () => null;
75
75
 
76
76
  const result = await report.reportSetupFailure({ step: 'cursor', exitCode: 1 });
77
77
  assert.equal(result, false);
@@ -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.ensureLoggedIn = async () => {};
312
+ auth.resolveDeviceKey = async () => 'resolved-key';
313
313
  auth.fetchDeviceOwnerKey = async () => 'owner-key';
314
314
  config.setUrls = () => ({});
315
315
  config.getApiKey = () => 'resolved-key';
@@ -1085,3 +1085,34 @@ test('isUnboundKeyHelper: a helper under a relocated CLAUDE_CONFIG_DIR is still
1085
1085
  delete require.cache[require.resolve('../src/toolHealth')];
1086
1086
  }
1087
1087
  });
1088
+
1089
+ // The per-tool env check accepts the shared UNBOUND_DEVICE_TOKEN or the per-tool name from older installs.
1090
+ function claudeWithEnv(env) {
1091
+ return withHome((tmp, th) => {
1092
+ writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify({ hooks: { x: [{ command: path.join(tmp, '.claude', 'hooks', 'unbound.py') }] } }));
1093
+ writeFile(path.join(tmp, '.claude', 'hooks', 'unbound.py'), '#');
1094
+ const names = ['UNBOUND_DEVICE_TOKEN', 'UNBOUND_CLAUDE_API_KEY'];
1095
+ const saved = names.map((n) => process.env[n]);
1096
+ names.forEach((n) => delete process.env[n]);
1097
+ Object.assign(process.env, env);
1098
+ try {
1099
+ return th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'claude-code');
1100
+ } finally {
1101
+ names.forEach((n, i) => { if (saved[i] === undefined) delete process.env[n]; else process.env[n] = saved[i]; });
1102
+ }
1103
+ });
1104
+ }
1105
+
1106
+ test('claude-code: UNBOUND_DEVICE_TOKEN alone → healthy', () => {
1107
+ assert.equal(claudeWithEnv({ UNBOUND_DEVICE_TOKEN: 'k' }).status, 'healthy');
1108
+ });
1109
+
1110
+ test('claude-code: only the per-tool UNBOUND_CLAUDE_API_KEY → healthy', () => {
1111
+ assert.equal(claudeWithEnv({ UNBOUND_CLAUDE_API_KEY: 'k' }).status, 'healthy');
1112
+ });
1113
+
1114
+ test('claude-code: neither env set → tampered, naming UNBOUND_DEVICE_TOKEN', () => {
1115
+ const t = claudeWithEnv({});
1116
+ assert.equal(t.status, 'tampered');
1117
+ assert.ok(t.checks.some((c) => !c.ok && /UNBOUND_DEVICE_TOKEN is not set/.test(c.detail)));
1118
+ });