unbound-cli 1.16.24 → 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 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.24",
3
+ "version": "1.17.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
+ };
@@ -41,6 +41,12 @@ const DATASETS = {
41
41
  summary: 'MCP calls to servers your org has not sanctioned (blocked or warned).',
42
42
  tableColumns: ['Timestamp', 'User', 'MCP Server', 'MCP Tool', 'Status'],
43
43
  },
44
+ unofficial: {
45
+ key: 'unofficial',
46
+ title: 'Unofficial MCP Usage',
47
+ summary: 'MCP calls blocked because the publisher is not an official one.',
48
+ tableColumns: ['Timestamp', 'User', 'MCP Server', 'MCP Tool', 'Status'],
49
+ },
44
50
  'mcp-servers': {
45
51
  key: 'mcp-servers',
46
52
  title: 'MCP Servers',
@@ -272,6 +278,7 @@ DATASETS
272
278
  terminal ${DATASETS.terminal.summary}
273
279
  mcp-actions ${DATASETS['mcp-actions'].summary}
274
280
  unsanctioned ${DATASETS.unsanctioned.summary}
281
+ unofficial ${DATASETS.unofficial.summary}
275
282
  mcp-servers ${DATASETS['mcp-servers'].summary}
276
283
 
277
284
  Examples:
@@ -326,6 +333,21 @@ Examples:
326
333
  $ unbound analytics tool-use unsanctioned --format csv -o unsanctioned.csv
327
334
  ${FILTER_HELP}${AGENT_HELP}`, { server: true });
328
335
 
336
+ attach(toolUse, 'unofficial', DATASETS.unofficial, `
337
+ Columns:
338
+ Timestamp, User, Email, AI Tool, MCP Server, MCP Tool,
339
+ Status, Sanction Message, User Prompt
340
+
341
+ A narrower slice of unsanctioned: only the calls blocked because the MCP
342
+ publisher is not an official one. No dashboard page shows this on its own,
343
+ so the CLI and the API are the only ways to read it.
344
+
345
+ Examples:
346
+ $ unbound analytics tool-use unofficial
347
+ $ unbound analytics tool-use unofficial --time-window THIRTY_DAYS
348
+ $ unbound analytics tool-use unofficial --format csv -o unofficial.csv
349
+ ${FILTER_HELP}${AGENT_HELP}`, { server: true });
350
+
329
351
  attach(toolUse, 'mcp-servers', DATASETS['mcp-servers'], `
330
352
  Columns:
331
353
  MCP Server, Actions, Average Risk Score
@@ -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;
@@ -57,6 +57,36 @@ FILTERS
57
57
  than failing — 'tool_type=CURSOR' returns every tool. An invalid VALUE for a
58
58
  known key IS rejected, with the supported values listed. Run --check first and
59
59
  confirm the count moved.
60
+
61
+ FILTER VALUES
62
+ Most filters take IDs or names. These take a fixed vocabulary:
63
+
64
+ weeks ai-usage kinds only: 4, 8 or 12 (default 8). These cover
65
+ whole weeks, so --last and --start/--end do not apply.
66
+ status ai-usage-users-export: active | silent
67
+ band ai-usage-teams-export: high | mid | low
68
+ trend ai-usage-teams-export: up | down | flat
69
+ enforcement users-export, analytics-tasks-export. Repeatable:
70
+ data:audit, data:block, mcp:audit, mcp:block, mcp:warn,
71
+ mcp:approval, terminal:audit, terminal:block,
72
+ terminal:warn, terminal:approval, skills:inject
73
+ policy users-export, analytics-tasks-export: cmd:<id>, data:<id>,
74
+ finding:<name>, sanction:unsanctioned, sanction:unofficial
75
+ sort_field users-export: total_cost (default), total_tokens,
76
+ task_count, avg_cost_per_task, cache_rate, models_used,
77
+ policy_enforcements
78
+ risk_level discovery-risky-users-export. Repeatable:
79
+ low | medium | high | critical
80
+ An unrecognised value is DROPPED, not rejected, so a typo
81
+ exports every risk level. Confirm with --check.
82
+ autonomy_level discovery-risky-users-export. Repeatable:
83
+ read_only | ask_first | auto_edit | full_auto
84
+ Dropped on a typo, same as risk_level above.
85
+ columns discovery-risky-users-export. Repeatable; omit for the
86
+ default seven. user, email, system_user, device_id,
87
+ last_sync, risk_score, risk_level, risk_factors,
88
+ autonomy_level, tools, tool_count, permission_risk,
89
+ config_file_risk. The file keeps that order whatever you ask for.
60
90
  `;
61
91
 
62
92
  /** `--last 7d` / `--last 24h` -> an absolute window the server understands. */
@@ -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');
@@ -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> Login + set up the bundle
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 Configure all users (MDM)
797
- $ sudo unbound setup cursor codex-subscription Configure specific tools for all users
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
- const adminApiKey = opts.adminApiKey || opts.apiKey || config.getApiKey();
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 (or run `unbound login` first).');
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
- 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();
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
- 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
+ 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,
@@ -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; } };
@@ -35,24 +35,29 @@ const KINDS = {
35
35
  'logs-export': {
36
36
  summary: 'Every LLM call: prompt, model, tokens, status, user and task ID.',
37
37
  filters: ['model_ids', 'application_ids', 'status_codes', 'application_owner_email',
38
- 'application_types', 'tool_types'],
38
+ 'application_types', 'tool_types', 'search'],
39
39
  },
40
40
  'logs-tasks-export': {
41
41
  summary: 'One row per task (agent session), with its thread ID.',
42
- filters: ['model_ids', 'tool_types', 'application_owner_email', 'user_ids'],
42
+ filters: ['model_ids', 'tool_types', 'application_owner_email', 'user_ids', 'search'],
43
43
  },
44
44
  'analytics-export': {
45
- summary: 'Tool Use rows. Needs dataset=terminal-runs|mcp-actions|unsanctioned|mcp-servers.',
46
- filters: ['dataset', 'risk_levels', 'command_families', 'server', 'intent_attribution',
47
- 'policy_ids', 'tool_types', 'user_ids'],
45
+ summary: 'Tool Use rows. Needs dataset=terminal-runs|mcp-actions|unsanctioned|unofficial|mcp-servers.',
46
+ filters: ['dataset', 'risk_levels', 'command_families', 'policy_ids', 'policy_actions',
47
+ 'server', 'server_name', 'canonical_group_id', 'grouped', 'intent_attribution',
48
+ 'has_policy_match', 'application_types', 'application_ids', 'application_owner_email',
49
+ 'tool_types', 'user_ids', 'department_names', 'manager_names', 'search',
50
+ 'sort_field', 'sort_order'],
48
51
  },
49
52
  'analytics-tasks-export': {
50
53
  summary: 'Analytics > Tasks, optionally narrowed to one heatmap day.',
51
- filters: ['user_ids', 'tool_types', 'date', 'tz'],
54
+ filters: ['user_ids', 'department_names', 'manager_names', 'tool_types', 'date', 'tz',
55
+ 'enforcement', 'policy'],
52
56
  },
53
57
  'users-export': {
54
58
  summary: 'Analytics > Users: per-user cost, tokens and activity.',
55
- filters: ['user_ids', 'sort_field', 'sort_order'],
59
+ filters: ['user_ids', 'department_names', 'manager_names', 'tool_types', 'search',
60
+ 'enforcement', 'policy', 'sort_field', 'sort_order'],
56
61
  },
57
62
  'ai-usage-teams-export': {
58
63
  summary: 'Analytics > Usage: weekly AI adoption per team, one column per week.',
@@ -64,6 +69,11 @@ const KINDS = {
64
69
  filters: ['weeks', 'department_names', 'manager_names', 'search', 'status',
65
70
  'sort_by', 'sort_dir'],
66
71
  },
72
+ 'discovery-risky-users-export': {
73
+ summary: 'AI Tools Discovery > Users Risk Leaderboard. Pick your own columns.',
74
+ filters: ['search', 'filter', 'high_risk', 'risk_level', 'autonomy_level', 'tool',
75
+ 'sort_by', 'sort_direction', 'columns'],
76
+ },
67
77
  };
68
78
 
69
79
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -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;
@@ -48,6 +48,9 @@ test('every dataset names only columns the export actually returns', () => {
48
48
  'Targets', 'User Prompt', 'Risk Score', 'Risk Reason', 'Initiator', 'Matched Policies'],
49
49
  unsanctioned: ['Timestamp', 'User', 'Email', 'AI Tool', 'MCP Server', 'MCP Tool',
50
50
  'Status', 'Sanction Message', 'User Prompt'],
51
+ // The server builds unofficial from the same column set as unsanctioned.
52
+ unofficial: ['Timestamp', 'User', 'Email', 'AI Tool', 'MCP Server', 'MCP Tool',
53
+ 'Status', 'Sanction Message', 'User Prompt'],
51
54
  'mcp-servers': ['MCP Server', 'Actions', 'Average Risk Score'],
52
55
  };
53
56
 
@@ -66,3 +69,16 @@ test('every dataset names only columns the export actually returns', () => {
66
69
  test('--json works when --format was never passed', () => {
67
70
  assert.strictEqual(resolveFormat({ json: true, format: undefined }), 'json');
68
71
  });
72
+
73
+ test('every Tool Use dataset the server has is reachable as a subcommand', () => {
74
+ // 'unofficial' has no dashboard page, so the CLI and the API are the only
75
+ // ways to read it; dropping it here makes it unreachable rather than awkward.
76
+ assert.deepEqual(Object.keys(DATASETS), [
77
+ 'terminal', 'mcp-actions', 'unsanctioned', 'unofficial', 'mcp-servers',
78
+ ]);
79
+ for (const [name, meta] of Object.entries(DATASETS)) {
80
+ assert.ok(meta.key, `${name} needs the server's dataset key`);
81
+ assert.ok(meta.summary && meta.summary.length > 10, `${name} needs a summary`);
82
+ assert.ok(meta.tableColumns.length > 0, `${name} needs table columns`);
83
+ }
84
+ });
@@ -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
+ });
@@ -69,6 +69,44 @@ test('every advertised kind names the filters it accepts', () => {
69
69
  }
70
70
  });
71
71
 
72
+ test('every kind the backend serves is listed', () => {
73
+ // Literal list, in the order help prints them. The backend registry cannot be
74
+ // queried, so a kind added there is only discoverable once it is added here
75
+ // too — this is what makes that omission a failing test rather than silence.
76
+ assert.deepEqual(Object.keys(KINDS), [
77
+ 'logs-export',
78
+ 'logs-tasks-export',
79
+ 'analytics-export',
80
+ 'analytics-tasks-export',
81
+ 'users-export',
82
+ 'ai-usage-teams-export',
83
+ 'ai-usage-users-export',
84
+ 'discovery-risky-users-export',
85
+ ]);
86
+ });
87
+
88
+ test('the filters a kind advertises include the ones that change what is exported', () => {
89
+ // An unknown filter key is ignored by the server rather than rejected, so a
90
+ // missing entry here reads to the caller as "this kind cannot be narrowed"
91
+ // and they take a wider file than they asked for.
92
+ const required = {
93
+ 'logs-export': ['search', 'tool_types'],
94
+ 'logs-tasks-export': ['search', 'user_ids'],
95
+ 'analytics-export': ['dataset', 'department_names', 'manager_names', 'search'],
96
+ 'analytics-tasks-export': ['department_names', 'manager_names', 'enforcement', 'policy'],
97
+ 'users-export': ['department_names', 'manager_names', 'tool_types', 'search',
98
+ 'enforcement', 'policy'],
99
+ 'ai-usage-teams-export': ['weeks', 'band', 'trend'],
100
+ 'ai-usage-users-export': ['weeks', 'department_names', 'status'],
101
+ 'discovery-risky-users-export': ['risk_level', 'autonomy_level', 'columns'],
102
+ };
103
+ for (const [kind, filters] of Object.entries(required)) {
104
+ for (const filter of filters) {
105
+ assert.ok(KINDS[kind].filters.includes(filter), `${kind} must advertise ${filter}`);
106
+ }
107
+ }
108
+ });
109
+
72
110
  test('isJobId accepts a UUID and rejects anything else', () => {
73
111
  assert.ok(isJobId('4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6'));
74
112
  assert.ok(isJobId('4F2A1C9E-1B2C-4D5E-8F90-A1B2C3D4E5F6'));
@@ -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';