unbound-cli 1.15.1 → 1.16.7
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/LOCAL_DEV.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/auth-pages.js +34 -31
- package/src/auth.js +29 -2
- package/src/claudePaths.js +82 -0
- package/src/commands/analytics.js +1 -1
- package/src/commands/chat.js +2 -2
- package/src/commands/discover.js +17 -6
- package/src/commands/doctor.js +32 -8
- package/src/commands/download.js +2 -2
- package/src/commands/oacb.js +37 -11
- package/src/commands/onboard.js +34 -39
- package/src/commands/setup.js +62 -5
- package/src/commands/status.js +13 -1
- package/src/device-serial.js +17 -1
- package/src/index.js +3 -3
- package/src/lib/downloads.js +2 -2
- package/src/lib/policy-ai-assist.js +4 -2
- package/src/scheduled.js +3 -6
- package/src/toolHealth.js +115 -40
- package/test/analytics-export-job.test.js +1 -1
- package/test/chat-internals.test.js +1 -1
- package/test/claude-paths.test.js +182 -0
- package/test/discover-exit-code.test.js +16 -1
- package/test/onboard-cron.test.js +13 -11
- package/test/onboard-owner-key.test.js +38 -0
- package/test/onboard-scope.test.js +41 -6
- package/test/setup-args.test.js +44 -1
- package/test/setup-scope.test.js +69 -0
- package/test/tool-health-mdm-paths.test.js +206 -0
- package/test/tool-health.test.js +185 -0
package/src/commands/onboard.js
CHANGED
|
@@ -2,8 +2,8 @@ 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 } = require('../auth');
|
|
6
|
-
const { runSetupAllBundle, runMdmSetupAllBundle, hasRootPrivileges, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
|
|
5
|
+
const { ensureLoggedIn, fetchDeviceOwnerKey } = require('../auth');
|
|
6
|
+
const { runSetupAllBundle, runMdmSetupAllBundle, hasRootPrivileges, elevated, scopeNotice, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
|
|
7
7
|
const { runDiscoveryScan } = require('./discover');
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -27,7 +27,7 @@ function register(program) {
|
|
|
27
27
|
)
|
|
28
28
|
.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)')
|
|
29
29
|
.addOption(new Option('--admin-api-key <key>', 'Alias for --api-key (back-compat)').hideHelp())
|
|
30
|
-
.
|
|
30
|
+
.addOption(new Option('--discovery-key <key>', 'Deprecated and ignored. The scan uses your API key.').hideHelp())
|
|
31
31
|
.option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
|
|
32
32
|
.option('--set-cron', 'Set up a daily background job to keep governance up to date (under sudo, schedules a discovery scan only — not a full tool re-install)')
|
|
33
33
|
.option('--backfill', 'Seed historical Claude Code / Codex / Copilot sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
|
|
@@ -36,26 +36,22 @@ function register(program) {
|
|
|
36
36
|
.addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
|
|
37
37
|
.addHelpText('after', `
|
|
38
38
|
Runs the full onboarding flow, auto-detecting scope from your privileges:
|
|
39
|
-
With sudo, installs the MDM tool bundle (${MDM_ALL_TOOLS.join(', ')}) and
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
your own API key.
|
|
39
|
+
With sudo, installs the MDM tool bundle (${MDM_ALL_TOOLS.join(', ')}) and scans
|
|
40
|
+
every user on the device. Without sudo, installs the user bundle
|
|
41
|
+
(${ALL_TOOLS.join(', ')}) and scans just the current user.
|
|
43
42
|
|
|
44
43
|
1. Logs in / resolves the API key (or reuses a stored \`unbound login\` key
|
|
45
44
|
when --api-key is omitted).
|
|
46
45
|
2. Installs the tool bundle for the detected scope.
|
|
47
|
-
3. Runs device discovery.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
scheduled scan (cross-platform) instead of a one-time scan.
|
|
46
|
+
3. Runs device discovery. The device is attributed to the right user
|
|
47
|
+
automatically — no separate discovery key needed. With --set-cron, sets up
|
|
48
|
+
a recurring daily scan instead of a one-time scan.
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
required only under sudo (MDM/org enrollment), where the scan covers every user
|
|
54
|
-
on the device and cannot be attributed from a single user's key.
|
|
50
|
+
Only your API key is required — for both per-user and sudo/MDM onboarding.
|
|
55
51
|
|
|
56
52
|
Examples:
|
|
57
53
|
$ unbound onboard --api-key <USER_KEY>
|
|
58
|
-
$ sudo unbound onboard --api-key <ADMIN_KEY>
|
|
54
|
+
$ sudo unbound onboard --api-key <ADMIN_KEY>
|
|
59
55
|
$ unbound onboard --api-key <USER_KEY> --backfill
|
|
60
56
|
$ unbound onboard --api-key <USER_KEY> --set-cron
|
|
61
57
|
`)
|
|
@@ -67,35 +63,26 @@ Examples:
|
|
|
67
63
|
telemetry.rememberSecret(apiKeyOpt);
|
|
68
64
|
telemetry.rememberSecret(discoveryKeyOpt);
|
|
69
65
|
const isMdm = hasRootPrivileges();
|
|
66
|
+
output.info(scopeNotice(isMdm));
|
|
70
67
|
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
// separate discovery key is neither needed nor used.
|
|
75
|
-
if (isMdm && !discoveryKeyOpt) {
|
|
76
|
-
output.error('--discovery-key is required under sudo (MDM/org enrollment), or set UNBOUND_DISCOVERY_KEY env var');
|
|
77
|
-
process.exitCode = 1;
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
// User scope needs a key or an existing login; MDM scope resolves the
|
|
81
|
-
// admin key below (it may come from a stored `unbound login` credential).
|
|
68
|
+
// --api-key (or a stored `unbound login` key) is all that's required, for
|
|
69
|
+
// both scopes. MDM scope resolves the admin key below (it may come from a
|
|
70
|
+
// stored login credential).
|
|
82
71
|
if (!isMdm && !apiKeyOpt && !config.isLoggedIn()) {
|
|
83
72
|
output.error('--api-key is required (or set UNBOUND_API_KEY env var, or run `unbound login` first)');
|
|
84
73
|
process.exitCode = 1;
|
|
85
74
|
return;
|
|
86
75
|
}
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
// (WEB-4891). Warn rather than fail so existing scripts keep working.
|
|
91
|
-
if (!isMdm && discoveryKeyOpt) {
|
|
92
|
-
output.warn('--discovery-key is deprecated for per-user onboarding and is ignored; your API key is used for the device scan, so the device is attributed to you. You can drop --discovery-key (and unset UNBOUND_DISCOVERY_KEY if it is set in your environment).');
|
|
76
|
+
// --discovery-key is accepted but ignored, so existing scripts don't break.
|
|
77
|
+
if (discoveryKeyOpt) {
|
|
78
|
+
output.warn('--discovery-key is deprecated and ignored — the scan uses your API key.');
|
|
93
79
|
}
|
|
94
80
|
|
|
95
81
|
let setupSucceeded = false;
|
|
96
82
|
let discoverySucceeded = false;
|
|
97
83
|
let discoveryDomain;
|
|
98
84
|
let skippedTools;
|
|
85
|
+
let mdmScanKey; // device-owner key resolved from the serial (MDM scope only)
|
|
99
86
|
try {
|
|
100
87
|
// Persist URLs first, then login, then setup — order matters so the
|
|
101
88
|
// login validates against the new backend and setup wires tools at the
|
|
@@ -112,7 +99,9 @@ Examples:
|
|
|
112
99
|
const backendUrl = written.base_url || config.getBaseUrl();
|
|
113
100
|
const frontendUrl = written.frontend_url || config.getFrontendUrl();
|
|
114
101
|
const gatewayUrl = written.gateway_url || config.getGatewayUrl();
|
|
115
|
-
|
|
102
|
+
// Normalize --domain (it may be scheme-less) so it's a valid absolute
|
|
103
|
+
// base URL for both the owner-key lookup and the scan.
|
|
104
|
+
discoveryDomain = opts.domain ? config.normalizeUrl(opts.domain) : backendUrl;
|
|
116
105
|
|
|
117
106
|
if (isMdm) {
|
|
118
107
|
// Reuse the key stored by `unbound login` when no key was passed.
|
|
@@ -123,6 +112,12 @@ Examples:
|
|
|
123
112
|
return;
|
|
124
113
|
}
|
|
125
114
|
|
|
115
|
+
// Resolve the owner key before the setup bundle, so a failure aborts
|
|
116
|
+
// before anything is installed. Fetch it from discoveryDomain, the same
|
|
117
|
+
// endpoint the scan reports to, so the key matches the tenant.
|
|
118
|
+
mdmScanKey = await fetchDeviceOwnerKey(adminApiKey, { baseUrl: discoveryDomain });
|
|
119
|
+
telemetry.rememberSecret(mdmScanKey);
|
|
120
|
+
|
|
126
121
|
console.log('');
|
|
127
122
|
output.info('Step 1/2: Installing MDM tool bundle (all users)');
|
|
128
123
|
const { ok, skipped } = await runMdmSetupAllBundle(adminApiKey, {
|
|
@@ -137,7 +132,7 @@ Examples:
|
|
|
137
132
|
console.log('');
|
|
138
133
|
output.info('Step 2/2: Running device discovery');
|
|
139
134
|
console.log('');
|
|
140
|
-
await runDiscoveryScan({ apiKey:
|
|
135
|
+
await runDiscoveryScan({ apiKey: mdmScanKey, domain: discoveryDomain });
|
|
141
136
|
discoverySucceeded = true;
|
|
142
137
|
skippedTools = skipped;
|
|
143
138
|
} else {
|
|
@@ -177,11 +172,12 @@ Examples:
|
|
|
177
172
|
if (isMdm) {
|
|
178
173
|
// Under sudo, the recurring job refreshes the device/tool inventory
|
|
179
174
|
// only — it does NOT re-push the full MDM tool bundle daily, and it
|
|
180
|
-
// stores the
|
|
175
|
+
// stores the device-owner key (resolved from the serial), not the
|
|
176
|
+
// admin key.
|
|
181
177
|
output.info('Setting up daily scheduled discovery scan');
|
|
182
178
|
await setupScheduledRun({
|
|
183
179
|
command: 'discover',
|
|
184
|
-
apiKey:
|
|
180
|
+
apiKey: mdmScanKey,
|
|
185
181
|
domain: discoveryDomain,
|
|
186
182
|
skipRunAtLoad: true,
|
|
187
183
|
});
|
|
@@ -208,7 +204,6 @@ Examples:
|
|
|
208
204
|
telemetry.captureError(err, { tags: { flow: 'onboard' } });
|
|
209
205
|
if (!err.displayed) output.error(err.message);
|
|
210
206
|
const suffix = domainHintSuffix(discoveryDomain);
|
|
211
|
-
const sudo = isMdm ? 'sudo ' : '';
|
|
212
207
|
if (discoverySucceeded && opts.setCron) {
|
|
213
208
|
// Both setup and discovery completed; only the optional --set-cron
|
|
214
209
|
// step failed. Retry just the cron: under sudo the scheduled job is a
|
|
@@ -216,14 +211,14 @@ Examples:
|
|
|
216
211
|
// scope it re-runs the full onboard (the bundle reinstall is intended).
|
|
217
212
|
console.error(' Setup and discovery completed successfully — only scheduled-run setup failed.');
|
|
218
213
|
if (isMdm) {
|
|
219
|
-
console.error(` Re-run
|
|
214
|
+
console.error(` Re-run with: ${elevated(`unbound onboard --api-key <ADMIN_KEY> --set-cron${suffix}`)}`);
|
|
220
215
|
} else {
|
|
221
216
|
console.error(` Re-run cron setup with: unbound onboard --api-key <KEY> --set-cron${suffix}`);
|
|
222
217
|
}
|
|
223
218
|
} else if (setupSucceeded && !discoverySucceeded) {
|
|
224
219
|
console.error(' Tool setup completed successfully — only discovery failed.');
|
|
225
220
|
if (isMdm) {
|
|
226
|
-
console.error(` Re-run
|
|
221
|
+
console.error(` Re-run onboarding with: ${elevated(`unbound onboard --api-key <ADMIN_KEY>${suffix}`)}`);
|
|
227
222
|
} else {
|
|
228
223
|
console.error(` Re-run discovery only with: unbound discover --api-key <KEY>${suffix}`);
|
|
229
224
|
}
|
package/src/commands/setup.js
CHANGED
|
@@ -9,6 +9,7 @@ const telemetry = require('../telemetry');
|
|
|
9
9
|
const { ensureLoggedIn } = require('../auth');
|
|
10
10
|
const { confirm, downloadToFile, withSecureTempFile } = require('../utils');
|
|
11
11
|
const { reportSetupFailure, exitCodeForError } = require('../setup-report');
|
|
12
|
+
const claudePaths = require('../claudePaths');
|
|
12
13
|
|
|
13
14
|
const SETUP_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main';
|
|
14
15
|
|
|
@@ -245,18 +246,45 @@ async function runPythonScript(scriptPath, args, { capture }) {
|
|
|
245
246
|
* their browser-auth host) and to MDM scripts as --frontend-url (persist-only,
|
|
246
247
|
* no auth flow). Either way it lands in ~/.unbound/config.json as frontend_url.
|
|
247
248
|
*/
|
|
248
|
-
function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill } = {}) {
|
|
249
|
+
function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill, configDir } = {}) {
|
|
249
250
|
// --clear runs no auth in the Python scripts, so the key may be absent. Omit
|
|
250
251
|
// the flag entirely rather than passing --api-key 'undefined'.
|
|
251
252
|
let args = apiKey ? `--api-key ${shellEscape(apiKey)}` : '';
|
|
252
253
|
if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
|
|
253
254
|
if (gatewayUrl) args += ` --gateway-url ${shellEscape(gatewayUrl)}`;
|
|
254
255
|
if (frontendUrl) args += mdm ? ` --frontend-url ${shellEscape(frontendUrl)}` : ` --domain ${shellEscape(frontendUrl)}`;
|
|
256
|
+
if (configDir) args += ` --config-dir ${shellEscape(configDir)}`;
|
|
255
257
|
if (clear) args += ' --clear';
|
|
256
258
|
if (backfill) args += ' --backfill';
|
|
257
259
|
return args.trim();
|
|
258
260
|
}
|
|
259
261
|
|
|
262
|
+
// User-level Claude install scripts that honor CLAUDE_CONFIG_DIR. MDM variants are
|
|
263
|
+
// excluded — they install per managed-user home, not the admin's env.
|
|
264
|
+
const CLAUDE_CONFIG_DIR_SCRIPTS = new Set([
|
|
265
|
+
'claude-code/hooks/setup.py',
|
|
266
|
+
'claude-code/gateway/setup.py',
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
function configDirForScript(scriptPath) {
|
|
270
|
+
if (!CLAUDE_CONFIG_DIR_SCRIPTS.has(scriptPath)) return undefined;
|
|
271
|
+
return claudePaths.isNonDefaultConfigDir() ? claudePaths.resolveClaudeConfigDir() : undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function warnIfHooksMisplaced(scriptPath) {
|
|
275
|
+
if (!CLAUDE_CONFIG_DIR_SCRIPTS.has(scriptPath)) return;
|
|
276
|
+
if (claudePaths.isBlankConfigDir()) {
|
|
277
|
+
output.warn(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const mismatch = claudePaths.detectConfigDirMismatch();
|
|
281
|
+
if (!mismatch) return;
|
|
282
|
+
output.warn(
|
|
283
|
+
`Claude install landed in ${mismatch.staleDir} but CLAUDE_CONFIG_DIR points at ${mismatch.activeDir}; ` +
|
|
284
|
+
'Claude will not read it. Re-run setup with CLAUDE_CONFIG_DIR set.'
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
260
288
|
// Backfill only applies to the hooks variants of Claude Code / Codex / Copilot;
|
|
261
289
|
// gateway mode and Cursor have no local transcripts to seed.
|
|
262
290
|
function scriptSupportsBackfill(scriptPath) {
|
|
@@ -289,9 +317,11 @@ function noteBackfillUnsupported(label, scriptPath) {
|
|
|
289
317
|
* script signals an MDM skip; throws on download or script failure.
|
|
290
318
|
*/
|
|
291
319
|
async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
|
|
292
|
-
const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill });
|
|
320
|
+
const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill, configDir: configDirForScript(scriptPath) });
|
|
293
321
|
console.log('');
|
|
294
|
-
|
|
322
|
+
const r = await runPythonScript(scriptPath, args, { capture: false });
|
|
323
|
+
if (!clear && !(r && r.mdmSkipped)) warnIfHooksMisplaced(scriptPath);
|
|
324
|
+
return r;
|
|
295
325
|
}
|
|
296
326
|
|
|
297
327
|
/**
|
|
@@ -302,7 +332,11 @@ async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, f
|
|
|
302
332
|
* download exited 0 because dash has no pipefail — is closed.
|
|
303
333
|
*/
|
|
304
334
|
function runScriptPiped(scriptPath, args) {
|
|
305
|
-
|
|
335
|
+
const isClear = /(^|\s)--clear(\s|$)/.test(args);
|
|
336
|
+
return runPythonScript(scriptPath, args, { capture: true }).then((r) => {
|
|
337
|
+
if (!isClear && !(r && r.mdmSkipped)) warnIfHooksMisplaced(scriptPath);
|
|
338
|
+
return r;
|
|
339
|
+
});
|
|
306
340
|
}
|
|
307
341
|
|
|
308
342
|
/**
|
|
@@ -494,6 +528,20 @@ function runBinaryClear({
|
|
|
494
528
|
}
|
|
495
529
|
}
|
|
496
530
|
|
|
531
|
+
// Windows has no `sudo`: elevation is a property of the terminal, not a command
|
|
532
|
+
// prefix. Advise the platform's real mechanism instead of one that fails to run.
|
|
533
|
+
function elevated(cmd) {
|
|
534
|
+
return process.platform === 'win32' ? `${cmd} in an Administrator terminal` : `sudo ${cmd}`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Elevation alone selects org-wide scope. On Windows a terminal is routinely elevated
|
|
538
|
+
// for unrelated reasons, so the scope has to be stated rather than inferred silently.
|
|
539
|
+
function scopeNotice(isMdm) {
|
|
540
|
+
return isMdm
|
|
541
|
+
? 'Scope: organization-wide, every user on this device (this terminal is elevated).'
|
|
542
|
+
: 'Scope: your account only.';
|
|
543
|
+
}
|
|
544
|
+
|
|
497
545
|
/**
|
|
498
546
|
* Returns true when the process has the privileges needed to touch system-level
|
|
499
547
|
* (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
|
|
@@ -650,7 +698,7 @@ async function clearToolsBestEffort(layer, tools, { mdm, backendUrl, gatewayUrl,
|
|
|
650
698
|
try {
|
|
651
699
|
// frontendUrl is forwarded for parity with `setup --clear`; buildScriptArgs
|
|
652
700
|
// only appends --domain for non-MDM tools, so MDM clears stay unaffected.
|
|
653
|
-
const args = buildScriptArgs(null, { backendUrl, gatewayUrl, frontendUrl, clear: true, mdm });
|
|
701
|
+
const args = buildScriptArgs(null, { backendUrl, gatewayUrl, frontendUrl, clear: true, mdm, configDir: configDirForScript(tool.script) });
|
|
654
702
|
await runScriptPiped(tool.script, args);
|
|
655
703
|
s.succeed(`${layer}: ${tool.label}`);
|
|
656
704
|
} catch (err) {
|
|
@@ -778,6 +826,7 @@ must update the MDM configuration.
|
|
|
778
826
|
// every user on the device (MDM scope); without it, just the current
|
|
779
827
|
// user. URLs were already persisted above and apply to both scopes.
|
|
780
828
|
const isMdm = hasRootPrivileges();
|
|
829
|
+
output.info(scopeNotice(isMdm));
|
|
781
830
|
if (isMdm) {
|
|
782
831
|
const mdmToolNames = [...Object.keys(MDM_TOOLS), 'claude-code', 'codex'].join(', ');
|
|
783
832
|
const adminApiKey = opts.adminApiKey || opts.apiKey || config.getApiKey();
|
|
@@ -854,6 +903,7 @@ must update the MDM configuration.
|
|
|
854
903
|
clear: opts.clear,
|
|
855
904
|
mdm: true,
|
|
856
905
|
backfill: opts.backfill && scriptSupportsBackfill(tool.script),
|
|
906
|
+
configDir: configDirForScript(tool.script),
|
|
857
907
|
});
|
|
858
908
|
return runScriptPiped(tool.script, toolArgs);
|
|
859
909
|
},
|
|
@@ -910,6 +960,7 @@ must update the MDM configuration.
|
|
|
910
960
|
...urlOpts,
|
|
911
961
|
clear: opts.clear,
|
|
912
962
|
backfill: opts.backfill && scriptSupportsBackfill(tool.script),
|
|
963
|
+
configDir: configDirForScript(tool.script),
|
|
913
964
|
});
|
|
914
965
|
return runScriptPiped(tool.script, toolArgs);
|
|
915
966
|
}, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured', report: { apiKey, backendUrl } });
|
|
@@ -1048,6 +1099,7 @@ must update the MDM configuration.
|
|
|
1048
1099
|
...urlOpts,
|
|
1049
1100
|
clear: opts.clear,
|
|
1050
1101
|
backfill: opts.backfill && scriptSupportsBackfill(tool.script),
|
|
1102
|
+
configDir: configDirForScript(tool.script),
|
|
1051
1103
|
});
|
|
1052
1104
|
return runScriptPiped(tool.script, toolArgs);
|
|
1053
1105
|
}, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured', report: { apiKey, backendUrl } });
|
|
@@ -1207,6 +1259,7 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
|
|
|
1207
1259
|
const args = buildScriptArgs(apiKey, {
|
|
1208
1260
|
backendUrl, frontendUrl, gatewayUrl,
|
|
1209
1261
|
backfill: backfill && scriptSupportsBackfill(tool.script),
|
|
1262
|
+
configDir: configDirForScript(tool.script),
|
|
1210
1263
|
});
|
|
1211
1264
|
return runScriptPiped(tool.script, args);
|
|
1212
1265
|
}, { report: { apiKey, backendUrl } });
|
|
@@ -1228,6 +1281,7 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
|
|
|
1228
1281
|
const args = buildScriptArgs(adminApiKey, {
|
|
1229
1282
|
backendUrl, frontendUrl, gatewayUrl, mdm: true,
|
|
1230
1283
|
backfill: backfill && scriptSupportsBackfill(tool.script),
|
|
1284
|
+
configDir: configDirForScript(tool.script),
|
|
1231
1285
|
});
|
|
1232
1286
|
return runScriptPiped(tool.script, args);
|
|
1233
1287
|
}, { report: { apiKey: adminApiKey, backendUrl } });
|
|
@@ -1235,12 +1289,15 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
|
|
|
1235
1289
|
|
|
1236
1290
|
module.exports = {
|
|
1237
1291
|
register,
|
|
1292
|
+
elevated,
|
|
1293
|
+
scopeNotice,
|
|
1238
1294
|
runSetupAllBundle,
|
|
1239
1295
|
runMdmSetupAllBundle,
|
|
1240
1296
|
hasRootPrivileges,
|
|
1241
1297
|
ALL_TOOLS,
|
|
1242
1298
|
MDM_ALL_TOOLS,
|
|
1243
1299
|
buildScriptArgs,
|
|
1300
|
+
configDirForScript,
|
|
1244
1301
|
scriptSupportsBackfill,
|
|
1245
1302
|
resolveSetupAllTools,
|
|
1246
1303
|
clearUnboundEnvsEverywhere,
|
package/src/commands/status.js
CHANGED
|
@@ -3,10 +3,12 @@ const api = require('../api');
|
|
|
3
3
|
const output = require('../output');
|
|
4
4
|
const { getDeviceSerial } = require('../device-serial');
|
|
5
5
|
const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
|
|
6
|
+
const claudePaths = require('../claudePaths');
|
|
6
7
|
|
|
7
8
|
function roleFromPrivileges(p) {
|
|
8
9
|
if (!p) return null;
|
|
9
10
|
if (p.is_admin) return 'Admin';
|
|
11
|
+
if (p.is_analytics_viewer) return 'Analytics Viewer';
|
|
10
12
|
if (p.is_manager) return 'Manager';
|
|
11
13
|
if (p.is_member) return 'Member';
|
|
12
14
|
return 'Unknown';
|
|
@@ -23,7 +25,7 @@ Output fields:
|
|
|
23
25
|
Logged in - Whether credentials are stored (Yes/No)
|
|
24
26
|
Email - The authenticated user's email (if logged in)
|
|
25
27
|
Organization - The organization name (if logged in)
|
|
26
|
-
Role - Admin / Manager / Member (if logged in)
|
|
28
|
+
Role - Admin / Analytics Viewer / Manager / Member (if logged in)
|
|
27
29
|
Backend URL - REST API host (configurable for tenant deployments)
|
|
28
30
|
Frontend URL - Browser login host
|
|
29
31
|
Gateway URL - AI gateway host (used by tool setup)
|
|
@@ -87,6 +89,8 @@ Examples:
|
|
|
87
89
|
keySpin.stop();
|
|
88
90
|
const connected = detected.filter((t) => t.status !== 'not-installed');
|
|
89
91
|
const skippedCount = countValidationSkipped(connected);
|
|
92
|
+
const configMismatch = claudePaths.detectConfigDirMismatch();
|
|
93
|
+
const blankConfigDir = claudePaths.isBlankConfigDir();
|
|
90
94
|
|
|
91
95
|
if (opts.json) {
|
|
92
96
|
const cfg = loggedIn ? config.readConfig() : {};
|
|
@@ -102,6 +106,8 @@ Examples:
|
|
|
102
106
|
api_status: connectivity,
|
|
103
107
|
connected_tools: connected.map((t) => ({ tool: t.key, label: t.label, mode: t.mode, status: t.status })),
|
|
104
108
|
key_validations_skipped: skippedCount,
|
|
109
|
+
claude_config_dir_mismatch: configMismatch,
|
|
110
|
+
claude_config_dir_blank: blankConfigDir,
|
|
105
111
|
});
|
|
106
112
|
return;
|
|
107
113
|
}
|
|
@@ -128,6 +134,12 @@ Examples:
|
|
|
128
134
|
if (skippedCount > 0) {
|
|
129
135
|
console.log(` ${C.dim(`Note: ${skippedCount} API key validation${skippedCount === 1 ? '' : 's'} skipped (gateway unreachable).`)}`);
|
|
130
136
|
}
|
|
137
|
+
if (blankConfigDir) {
|
|
138
|
+
console.log(` ${C.yellow('\u26a0')} CLAUDE_CONFIG_DIR is set but empty — Claude Code will not load hooks from ~/.claude. Unset it, or set a real path.`);
|
|
139
|
+
}
|
|
140
|
+
if (configMismatch) {
|
|
141
|
+
console.log(` ${C.yellow('⚠')} Claude Code hooks are in ${configMismatch.staleDir}, but CLAUDE_CONFIG_DIR points at ${configMismatch.activeDir} — run ${C.bold('unbound doctor')}.`);
|
|
142
|
+
}
|
|
131
143
|
console.log('');
|
|
132
144
|
} catch (err) {
|
|
133
145
|
output.error(err.message);
|
package/src/device-serial.js
CHANGED
|
@@ -156,4 +156,20 @@ async function getDeviceSerial() {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
/**
|
|
160
|
+
* Live hardware serial, bypassing the user-writable cache. Use it for
|
|
161
|
+
* security-sensitive decisions like minting the device-owner key, where a
|
|
162
|
+
* planted cache value must not be trusted. Never throws; returns null when the
|
|
163
|
+
* serial can't be determined.
|
|
164
|
+
*/
|
|
165
|
+
async function probeDeviceSerialLive() {
|
|
166
|
+
try {
|
|
167
|
+
const serial = await probeSerial();
|
|
168
|
+
return isValidSerial(serial) ? serial.trim() : null;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
debug(`probeDeviceSerialLive failed: ${err && err.message}`);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { getDeviceSerial, probeDeviceSerialLive, isValidSerial };
|
package/src/index.js
CHANGED
|
@@ -56,7 +56,7 @@ AUTHENTICATION
|
|
|
56
56
|
|
|
57
57
|
ONBOARDING (one-step install + discover; scope auto-detected from sudo)
|
|
58
58
|
$ unbound onboard --api-key <USER_KEY> Current user
|
|
59
|
-
$ sudo unbound onboard --api-key <ADMIN_KEY>
|
|
59
|
+
$ sudo unbound onboard --api-key <ADMIN_KEY> All users (MDM)
|
|
60
60
|
|
|
61
61
|
TOOL SETUP
|
|
62
62
|
$ unbound setup Select and install multiple tools interactively
|
|
@@ -155,7 +155,7 @@ TOOL CONNECTIONS
|
|
|
155
155
|
$ unbound tools connect <type> Connect a new tool
|
|
156
156
|
$ unbound tools approved List approved tool types
|
|
157
157
|
|
|
158
|
-
ANALYTICS (Admin/
|
|
158
|
+
ANALYTICS (Admin/Analytics Viewer only, for listing as well as CSV export)
|
|
159
159
|
$ unbound analytics tool-use terminal Terminal commands agents ran
|
|
160
160
|
$ unbound analytics tool-use mcp-servers MCP servers seen (find --server values here)
|
|
161
161
|
$ unbound analytics tool-use mcp-actions --server Notion
|
|
@@ -167,7 +167,7 @@ ANALYTICS (Admin/Manager; CSV export is Admin-only)
|
|
|
167
167
|
csv exports every matching row, not just the page.
|
|
168
168
|
See "unbound analytics tool-use --help" for the JSON contract used by agents.
|
|
169
169
|
|
|
170
|
-
CHAT (Admin/Manager)
|
|
170
|
+
CHAT (Admin/Analytics Viewer/Manager)
|
|
171
171
|
$ unbound chat Interactive REPL (multi-turn, in-memory history)
|
|
172
172
|
$ unbound chat -m "show cost by provider for the last 30 days"
|
|
173
173
|
One-shot: render a chart in the terminal
|
package/src/lib/downloads.js
CHANGED
|
@@ -306,8 +306,8 @@ function describeError(err, what = 'export', { byJobId = false, listing = false
|
|
|
306
306
|
}
|
|
307
307
|
if (err.statusCode === 403) {
|
|
308
308
|
return listing
|
|
309
|
-
? '
|
|
310
|
-
: '
|
|
309
|
+
? 'Analytics > Tool Use is not available to your role.'
|
|
310
|
+
: 'Exporting data is not available to your role.';
|
|
311
311
|
}
|
|
312
312
|
if (err.statusCode === 422) {
|
|
313
313
|
const rows = Number(err.body?.row_count);
|
|
@@ -261,7 +261,8 @@ async function runTerminalPromptCreate(opts) {
|
|
|
261
261
|
}
|
|
262
262
|
if (!privileges || !privileges.is_admin) {
|
|
263
263
|
let role = 'unknown';
|
|
264
|
-
if (privileges && privileges.
|
|
264
|
+
if (privileges && privileges.is_analytics_viewer) role = 'Analytics Viewer';
|
|
265
|
+
else if (privileges && privileges.is_manager) role = 'Manager';
|
|
265
266
|
else if (privileges && privileges.is_member) role = 'Member';
|
|
266
267
|
const e = new Error(`Tool policy creation requires admin role; current role: ${role}.`);
|
|
267
268
|
e.exitCode = 3;
|
|
@@ -425,7 +426,8 @@ async function runMcpPromptCreate(opts) {
|
|
|
425
426
|
}
|
|
426
427
|
if (!privileges || !privileges.is_admin) {
|
|
427
428
|
let role = 'unknown';
|
|
428
|
-
if (privileges && privileges.
|
|
429
|
+
if (privileges && privileges.is_analytics_viewer) role = 'Analytics Viewer';
|
|
430
|
+
else if (privileges && privileges.is_manager) role = 'Manager';
|
|
429
431
|
else if (privileges && privileges.is_member) role = 'Member';
|
|
430
432
|
const e = new Error(`Tool policy creation requires admin role; current role: ${role}.`);
|
|
431
433
|
e.exitCode = 3;
|
package/src/scheduled.js
CHANGED
|
@@ -16,13 +16,12 @@ const { shellEscape, downloadToFile, DISCOVER_BASE_URL } = require('./utils');
|
|
|
16
16
|
*
|
|
17
17
|
* @param {object} opts
|
|
18
18
|
* @param {'onboard'|'discover'} opts.command - which subcommand the cron should run
|
|
19
|
-
* @param {string} opts.apiKey - api key
|
|
20
|
-
* @param {string} [opts.discoveryKey] - separate discovery key (onboard only)
|
|
19
|
+
* @param {string} opts.apiKey - api key to run the scheduled scan with (the device-owner key for MDM discover)
|
|
21
20
|
* @param {string} [opts.domain] - backend domain to pass to the scheduled run
|
|
22
21
|
* @param {boolean} [opts.skipRunAtLoad] - macOS: set RunAtLoad=false (scan already ran)
|
|
23
22
|
*/
|
|
24
23
|
async function setupScheduledRun(opts) {
|
|
25
|
-
const { command, apiKey,
|
|
24
|
+
const { command, apiKey, domain, skipRunAtLoad } = opts;
|
|
26
25
|
if (!command || !apiKey) {
|
|
27
26
|
throw new Error('setupScheduledRun: command and apiKey are required');
|
|
28
27
|
}
|
|
@@ -36,7 +35,6 @@ async function setupScheduledRun(opts) {
|
|
|
36
35
|
'--command', shellEscape(command),
|
|
37
36
|
'--api-key', shellEscape(apiKey),
|
|
38
37
|
];
|
|
39
|
-
if (discoveryKey) args.push('--discovery-key', shellEscape(discoveryKey));
|
|
40
38
|
if (domain) args.push('--domain', shellEscape(domain));
|
|
41
39
|
if (skipRunAtLoad) args.push('--no-run-at-load');
|
|
42
40
|
|
|
@@ -54,7 +52,7 @@ async function setupScheduledRun(opts) {
|
|
|
54
52
|
}
|
|
55
53
|
|
|
56
54
|
async function setupScheduledRunWindows(opts) {
|
|
57
|
-
const { command, apiKey,
|
|
55
|
+
const { command, apiKey, domain } = opts;
|
|
58
56
|
const url = `${DISCOVER_BASE_URL}/setup-scheduled-scan.ps1`;
|
|
59
57
|
const tmp = path.join(os.tmpdir(), `unbound-scheduled-${Date.now()}-${Math.random().toString(36).slice(2)}.ps1`);
|
|
60
58
|
// Single try/finally covers both download and spawn so a mid-stream
|
|
@@ -73,7 +71,6 @@ async function setupScheduledRunWindows(opts) {
|
|
|
73
71
|
'-Command', command,
|
|
74
72
|
'-ApiKey', apiKey,
|
|
75
73
|
];
|
|
76
|
-
if (discoveryKey) psArgs.push('-DiscoveryKey', discoveryKey);
|
|
77
74
|
if (domain) psArgs.push('-Domain', domain);
|
|
78
75
|
|
|
79
76
|
await new Promise((resolve, reject) => {
|