unbound-cli 1.15.2 → 1.16.8
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.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 +74 -9
- 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-instruction-tools.test.js +105 -0
- 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
|
|
|
@@ -85,12 +86,19 @@ function resolveSetupAllTools(clear) {
|
|
|
85
86
|
return clear ? Object.keys(SETUP_TOOL_MAP) : [...SETUP_ALL_TOOLS];
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
// OpenAI-compatible Base URL: the gateway host with the /v1 path these clients
|
|
90
|
+
// POST to. Strips a trailing slash because getGatewayUrl() returns env/config
|
|
91
|
+
// values unnormalized, which would otherwise double the slash.
|
|
92
|
+
function openAiBaseUrl(gatewayUrl) {
|
|
93
|
+
return `${String(gatewayUrl).replace(/\/+$/, '')}/v1`;
|
|
94
|
+
}
|
|
95
|
+
|
|
88
96
|
// Instruction-only tools (display config values, no setup scripts)
|
|
89
97
|
const INSTRUCTION_TOOLS = {
|
|
90
98
|
'roo-code': { label: 'Roo Code', values: (apiKey) => [['API Provider', 'unbound'], ['API Key', apiKey]] },
|
|
91
|
-
'cline': { label: 'Cline', values: (apiKey,
|
|
99
|
+
'cline': { label: 'Cline', values: (apiKey, gatewayUrl) => [['API Provider', 'OpenAI Compatible'], ['Base URL', openAiBaseUrl(gatewayUrl)], ['API Key', apiKey]] },
|
|
92
100
|
'kilo-code': { label: 'Kilo Code', values: (apiKey) => [['API Provider', 'Unbound'], ['API Key', apiKey]] },
|
|
93
|
-
'custom-access': { label: 'Custom Access', values: (apiKey,
|
|
101
|
+
'custom-access': { label: 'Custom Access', values: (apiKey, gatewayUrl) => [['API Key', apiKey], ['Base URL', openAiBaseUrl(gatewayUrl)]] },
|
|
94
102
|
};
|
|
95
103
|
|
|
96
104
|
// Tools that need interactive mode selection (claude-code, codex without suffix)
|
|
@@ -245,18 +253,45 @@ async function runPythonScript(scriptPath, args, { capture }) {
|
|
|
245
253
|
* their browser-auth host) and to MDM scripts as --frontend-url (persist-only,
|
|
246
254
|
* no auth flow). Either way it lands in ~/.unbound/config.json as frontend_url.
|
|
247
255
|
*/
|
|
248
|
-
function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill } = {}) {
|
|
256
|
+
function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill, configDir } = {}) {
|
|
249
257
|
// --clear runs no auth in the Python scripts, so the key may be absent. Omit
|
|
250
258
|
// the flag entirely rather than passing --api-key 'undefined'.
|
|
251
259
|
let args = apiKey ? `--api-key ${shellEscape(apiKey)}` : '';
|
|
252
260
|
if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
|
|
253
261
|
if (gatewayUrl) args += ` --gateway-url ${shellEscape(gatewayUrl)}`;
|
|
254
262
|
if (frontendUrl) args += mdm ? ` --frontend-url ${shellEscape(frontendUrl)}` : ` --domain ${shellEscape(frontendUrl)}`;
|
|
263
|
+
if (configDir) args += ` --config-dir ${shellEscape(configDir)}`;
|
|
255
264
|
if (clear) args += ' --clear';
|
|
256
265
|
if (backfill) args += ' --backfill';
|
|
257
266
|
return args.trim();
|
|
258
267
|
}
|
|
259
268
|
|
|
269
|
+
// User-level Claude install scripts that honor CLAUDE_CONFIG_DIR. MDM variants are
|
|
270
|
+
// excluded — they install per managed-user home, not the admin's env.
|
|
271
|
+
const CLAUDE_CONFIG_DIR_SCRIPTS = new Set([
|
|
272
|
+
'claude-code/hooks/setup.py',
|
|
273
|
+
'claude-code/gateway/setup.py',
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
function configDirForScript(scriptPath) {
|
|
277
|
+
if (!CLAUDE_CONFIG_DIR_SCRIPTS.has(scriptPath)) return undefined;
|
|
278
|
+
return claudePaths.isNonDefaultConfigDir() ? claudePaths.resolveClaudeConfigDir() : undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function warnIfHooksMisplaced(scriptPath) {
|
|
282
|
+
if (!CLAUDE_CONFIG_DIR_SCRIPTS.has(scriptPath)) return;
|
|
283
|
+
if (claudePaths.isBlankConfigDir()) {
|
|
284
|
+
output.warn(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const mismatch = claudePaths.detectConfigDirMismatch();
|
|
288
|
+
if (!mismatch) return;
|
|
289
|
+
output.warn(
|
|
290
|
+
`Claude install landed in ${mismatch.staleDir} but CLAUDE_CONFIG_DIR points at ${mismatch.activeDir}; ` +
|
|
291
|
+
'Claude will not read it. Re-run setup with CLAUDE_CONFIG_DIR set.'
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
260
295
|
// Backfill only applies to the hooks variants of Claude Code / Codex / Copilot;
|
|
261
296
|
// gateway mode and Cursor have no local transcripts to seed.
|
|
262
297
|
function scriptSupportsBackfill(scriptPath) {
|
|
@@ -289,9 +324,11 @@ function noteBackfillUnsupported(label, scriptPath) {
|
|
|
289
324
|
* script signals an MDM skip; throws on download or script failure.
|
|
290
325
|
*/
|
|
291
326
|
async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
|
|
292
|
-
const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill });
|
|
327
|
+
const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill, configDir: configDirForScript(scriptPath) });
|
|
293
328
|
console.log('');
|
|
294
|
-
|
|
329
|
+
const r = await runPythonScript(scriptPath, args, { capture: false });
|
|
330
|
+
if (!clear && !(r && r.mdmSkipped)) warnIfHooksMisplaced(scriptPath);
|
|
331
|
+
return r;
|
|
295
332
|
}
|
|
296
333
|
|
|
297
334
|
/**
|
|
@@ -302,7 +339,11 @@ async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, f
|
|
|
302
339
|
* download exited 0 because dash has no pipefail — is closed.
|
|
303
340
|
*/
|
|
304
341
|
function runScriptPiped(scriptPath, args) {
|
|
305
|
-
|
|
342
|
+
const isClear = /(^|\s)--clear(\s|$)/.test(args);
|
|
343
|
+
return runPythonScript(scriptPath, args, { capture: true }).then((r) => {
|
|
344
|
+
if (!isClear && !(r && r.mdmSkipped)) warnIfHooksMisplaced(scriptPath);
|
|
345
|
+
return r;
|
|
346
|
+
});
|
|
306
347
|
}
|
|
307
348
|
|
|
308
349
|
/**
|
|
@@ -494,6 +535,20 @@ function runBinaryClear({
|
|
|
494
535
|
}
|
|
495
536
|
}
|
|
496
537
|
|
|
538
|
+
// Windows has no `sudo`: elevation is a property of the terminal, not a command
|
|
539
|
+
// prefix. Advise the platform's real mechanism instead of one that fails to run.
|
|
540
|
+
function elevated(cmd) {
|
|
541
|
+
return process.platform === 'win32' ? `${cmd} in an Administrator terminal` : `sudo ${cmd}`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Elevation alone selects org-wide scope. On Windows a terminal is routinely elevated
|
|
545
|
+
// for unrelated reasons, so the scope has to be stated rather than inferred silently.
|
|
546
|
+
function scopeNotice(isMdm) {
|
|
547
|
+
return isMdm
|
|
548
|
+
? 'Scope: organization-wide, every user on this device (this terminal is elevated).'
|
|
549
|
+
: 'Scope: your account only.';
|
|
550
|
+
}
|
|
551
|
+
|
|
497
552
|
/**
|
|
498
553
|
* Returns true when the process has the privileges needed to touch system-level
|
|
499
554
|
* (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
|
|
@@ -650,7 +705,7 @@ async function clearToolsBestEffort(layer, tools, { mdm, backendUrl, gatewayUrl,
|
|
|
650
705
|
try {
|
|
651
706
|
// frontendUrl is forwarded for parity with `setup --clear`; buildScriptArgs
|
|
652
707
|
// only appends --domain for non-MDM tools, so MDM clears stay unaffected.
|
|
653
|
-
const args = buildScriptArgs(null, { backendUrl, gatewayUrl, frontendUrl, clear: true, mdm });
|
|
708
|
+
const args = buildScriptArgs(null, { backendUrl, gatewayUrl, frontendUrl, clear: true, mdm, configDir: configDirForScript(tool.script) });
|
|
654
709
|
await runScriptPiped(tool.script, args);
|
|
655
710
|
s.succeed(`${layer}: ${tool.label}`);
|
|
656
711
|
} catch (err) {
|
|
@@ -778,6 +833,7 @@ must update the MDM configuration.
|
|
|
778
833
|
// every user on the device (MDM scope); without it, just the current
|
|
779
834
|
// user. URLs were already persisted above and apply to both scopes.
|
|
780
835
|
const isMdm = hasRootPrivileges();
|
|
836
|
+
output.info(scopeNotice(isMdm));
|
|
781
837
|
if (isMdm) {
|
|
782
838
|
const mdmToolNames = [...Object.keys(MDM_TOOLS), 'claude-code', 'codex'].join(', ');
|
|
783
839
|
const adminApiKey = opts.adminApiKey || opts.apiKey || config.getApiKey();
|
|
@@ -854,6 +910,7 @@ must update the MDM configuration.
|
|
|
854
910
|
clear: opts.clear,
|
|
855
911
|
mdm: true,
|
|
856
912
|
backfill: opts.backfill && scriptSupportsBackfill(tool.script),
|
|
913
|
+
configDir: configDirForScript(tool.script),
|
|
857
914
|
});
|
|
858
915
|
return runScriptPiped(tool.script, toolArgs);
|
|
859
916
|
},
|
|
@@ -910,6 +967,7 @@ must update the MDM configuration.
|
|
|
910
967
|
...urlOpts,
|
|
911
968
|
clear: opts.clear,
|
|
912
969
|
backfill: opts.backfill && scriptSupportsBackfill(tool.script),
|
|
970
|
+
configDir: configDirForScript(tool.script),
|
|
913
971
|
});
|
|
914
972
|
return runScriptPiped(tool.script, toolArgs);
|
|
915
973
|
}, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured', report: { apiKey, backendUrl } });
|
|
@@ -1003,7 +1061,7 @@ must update the MDM configuration.
|
|
|
1003
1061
|
if (r && r.mdmSkipped) reportMdmSkips([label]);
|
|
1004
1062
|
}
|
|
1005
1063
|
} else if (INSTRUCTION_TOOLS[toolName]) {
|
|
1006
|
-
output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey,
|
|
1064
|
+
output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, gatewayUrl));
|
|
1007
1065
|
}
|
|
1008
1066
|
return;
|
|
1009
1067
|
}
|
|
@@ -1048,6 +1106,7 @@ must update the MDM configuration.
|
|
|
1048
1106
|
...urlOpts,
|
|
1049
1107
|
clear: opts.clear,
|
|
1050
1108
|
backfill: opts.backfill && scriptSupportsBackfill(tool.script),
|
|
1109
|
+
configDir: configDirForScript(tool.script),
|
|
1051
1110
|
});
|
|
1052
1111
|
return runScriptPiped(tool.script, toolArgs);
|
|
1053
1112
|
}, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured', report: { apiKey, backendUrl } });
|
|
@@ -1058,7 +1117,7 @@ must update the MDM configuration.
|
|
|
1058
1117
|
for (const toolName of instructionResults) {
|
|
1059
1118
|
console.log('');
|
|
1060
1119
|
output.info(`${INSTRUCTION_TOOLS[toolName].label}:`);
|
|
1061
|
-
output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey,
|
|
1120
|
+
output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, gatewayUrl));
|
|
1062
1121
|
}
|
|
1063
1122
|
|
|
1064
1123
|
} catch (err) {
|
|
@@ -1207,6 +1266,7 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
|
|
|
1207
1266
|
const args = buildScriptArgs(apiKey, {
|
|
1208
1267
|
backendUrl, frontendUrl, gatewayUrl,
|
|
1209
1268
|
backfill: backfill && scriptSupportsBackfill(tool.script),
|
|
1269
|
+
configDir: configDirForScript(tool.script),
|
|
1210
1270
|
});
|
|
1211
1271
|
return runScriptPiped(tool.script, args);
|
|
1212
1272
|
}, { report: { apiKey, backendUrl } });
|
|
@@ -1228,6 +1288,7 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
|
|
|
1228
1288
|
const args = buildScriptArgs(adminApiKey, {
|
|
1229
1289
|
backendUrl, frontendUrl, gatewayUrl, mdm: true,
|
|
1230
1290
|
backfill: backfill && scriptSupportsBackfill(tool.script),
|
|
1291
|
+
configDir: configDirForScript(tool.script),
|
|
1231
1292
|
});
|
|
1232
1293
|
return runScriptPiped(tool.script, args);
|
|
1233
1294
|
}, { report: { apiKey: adminApiKey, backendUrl } });
|
|
@@ -1235,12 +1296,16 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
|
|
|
1235
1296
|
|
|
1236
1297
|
module.exports = {
|
|
1237
1298
|
register,
|
|
1299
|
+
elevated,
|
|
1300
|
+
INSTRUCTION_TOOLS,
|
|
1301
|
+
scopeNotice,
|
|
1238
1302
|
runSetupAllBundle,
|
|
1239
1303
|
runMdmSetupAllBundle,
|
|
1240
1304
|
hasRootPrivileges,
|
|
1241
1305
|
ALL_TOOLS,
|
|
1242
1306
|
MDM_ALL_TOOLS,
|
|
1243
1307
|
buildScriptArgs,
|
|
1308
|
+
configDirForScript,
|
|
1244
1309
|
scriptSupportsBackfill,
|
|
1245
1310
|
resolveSetupAllTools,
|
|
1246
1311
|
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) => {
|