From 6426d25e00dab84d10c528dcf355c5c047906e3c Mon Sep 17 00:00:00 2001 From: Vignesh Subbiah Date: Wed, 2 Sep 2026 18:31:54 -0700 Subject: [PATCH 1/3] fix(mdm): make the discovery key optional in onboard.ps1/onboard.py (fall back to the admin key) The dashboard's generated MDM onboard command stopped emitting -DiscoveryKey / --discovery-key (unbound-fe #1999, WEB-5597) because the backend now accepts the admin key for discovery uploads. onboard.ps1 and onboard.py still hard-required it and exited 1 with "-DiscoveryKey is required", which failed Xome's whole Intune rollout. - onboard.py: --discovery-key is optional; when absent the discovery step runs with the --api-key value. Explicit --discovery-key still wins. --api-key with no/empty value now fails the wrapper's own check instead of being passed through to every per-tool script. - onboard.ps1: -DiscoveryKey optional, defaults to -ApiKey (passed explicitly so the fallback holds regardless of onboard.py revision). Usage/examples now point at https://getunbound.ai/setup/mdm/windows/onboard (the old /setup/mdm/onboard.ps1 URL returns 404). - mdm/README.md: drop "separate key required" wording and examples. - claude-code/hooks/mdm/setup.py: raw docstring for the one containing `\AppData\Roaming`, which raised SyntaxWarning: invalid escape sequence '\A' on Python 3.12+. - tests/test_mdm_onboard.py: argv-level contract for onboard.main(). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NYCyXQWih1Ujk9ScSpjvrp --- claude-code/hooks/mdm/setup.py | 2 +- mdm/README.md | 18 ++++--- mdm/onboard.ps1 | 28 ++++++----- mdm/onboard.py | 36 +++++++++----- tests/test_mdm_onboard.py | 88 ++++++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 tests/test_mdm_onboard.py diff --git a/claude-code/hooks/mdm/setup.py b/claude-code/hooks/mdm/setup.py index c6dd80af..6d364fbb 100644 --- a/claude-code/hooks/mdm/setup.py +++ b/claude-code/hooks/mdm/setup.py @@ -1718,7 +1718,7 @@ def _is_reparse_point(path: Path) -> bool: def _claude_desktop_support_dirs(home: Path) -> List[Path]: - """Claude Desktop app support dir(s) for a home. Team/SSO desktop sessions + r"""Claude Desktop app support dir(s) for a home. Team/SSO desktop sessions cache the active account's oauthAccount under local-agent-mode-sessions/ here. Taken from unbound.py, keyed off `home` instead of Path.home()/APPDATA: MDM diff --git a/mdm/README.md b/mdm/README.md index 9cd24dc1..ecdffb24 100644 --- a/mdm/README.md +++ b/mdm/README.md @@ -6,9 +6,9 @@ Runs all five MDM setup steps for an admin device enrollment in one shot: 2. **Cursor** MDM setup 3. **Codex** MDM setup 4. **GitHub Copilot** MDM setup -5. **Coding-discovery** scan (separate repo, separate API key) +5. **Coding-discovery** scan (separate repo) -Steps 1–4 use `--api-key` (the admin MDM key). Step 5 uses `--discovery-key` (a separate discovery-specific key — the two are different credentials and the backend distinguishes them). +All steps use `--api-key` (the admin MDM key). The backend accepts the admin key for discovery uploads, so a separate discovery key is no longer needed. `--discovery-key` / `-DiscoveryKey` is still accepted for back-compat (deprecated) and, when given, is used for the discovery scan in place of the admin key. Each step runs in its own subprocess; a failure in one does not abort the others. A summary at the end lists which steps succeeded and which failed. @@ -17,7 +17,7 @@ Each step runs in its own subprocess; a failure in one does not abort the others MDM setup requires Administrator privileges. Download and execute the PowerShell wrapper: ```powershell -Invoke-WebRequest -Uri 'https://getunbound.ai/setup/mdm/windows/onboard' -OutFile onboard.ps1; .\onboard.ps1 -ApiKey YOUR_ADMIN_API_KEY -DiscoveryKey YOUR_DISCOVERY_KEY +Invoke-WebRequest -Uri 'https://getunbound.ai/setup/mdm/windows/onboard' -OutFile onboard.ps1; .\onboard.ps1 -ApiKey YOUR_ADMIN_API_KEY ``` The wrapper automatically: @@ -29,13 +29,13 @@ The wrapper automatically: Optional parameters: ```powershell # Tenant deployment URLs -.\onboard.ps1 -ApiKey YOUR_KEY -DiscoveryKey YOUR_KEY -BackendUrl https://backend.example.com -GatewayUrl https://api.example.com +.\onboard.ps1 -ApiKey YOUR_KEY -BackendUrl https://backend.example.com -GatewayUrl https://api.example.com # Enable backfill of historical transcripts (opt-in) -.\onboard.ps1 -ApiKey YOUR_KEY -DiscoveryKey YOUR_KEY -Backfill +.\onboard.ps1 -ApiKey YOUR_KEY -Backfill # Claude Code only: install the hook script, leave managed-settings.json alone -.\onboard.ps1 -ApiKey YOUR_KEY -DiscoveryKey YOUR_KEY -SkipManagedSettings +.\onboard.ps1 -ApiKey YOUR_KEY -SkipManagedSettings ``` ### Clearing Setup (Windows) @@ -50,14 +50,12 @@ MDM setup requires root privileges. Pass the script to `python3 -c` via command ```bash sudo python3 -c "$(curl -fsSL https://getunbound.ai/setup/mdm/onboard)" \ - --api-key YOUR_ADMIN_API_KEY \ - --discovery-key YOUR_DISCOVERY_KEY + --api-key YOUR_ADMIN_API_KEY ``` ```bash sudo python3 -c "$(curl -fsSL https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main/mdm/onboard.py)" \ - --api-key YOUR_ADMIN_API_KEY \ - --discovery-key YOUR_DISCOVERY_KEY + --api-key YOUR_ADMIN_API_KEY ``` Optional overrides for tenant deployments: `--backend-url `, `--gateway-url ` (defaults: `https://backend.getunbound.ai`, `https://api.getunbound.ai`). The `--backend-url` value also becomes the discovery scan's `--domain`. diff --git a/mdm/onboard.ps1 b/mdm/onboard.ps1 index 6400c264..a9cde1a0 100644 --- a/mdm/onboard.ps1 +++ b/mdm/onboard.ps1 @@ -26,7 +26,8 @@ The MDM admin API key (required unless -Clear is specified) .PARAMETER DiscoveryKey - The discovery-specific API key, separate from ApiKey (required unless -Clear is specified) + Deprecated, optional: a separate key for the coding-discovery scan. Defaults to + ApiKey — the backend accepts the admin key for discovery uploads. .PARAMETER BackendUrl Backend URL override for tenant deployments (default: https://backend.getunbound.ai) @@ -49,20 +50,20 @@ Remove MDM configuration for all four tools (no discovery scan, no backfill) .EXAMPLE - # Standard onboarding with both keys - Invoke-WebRequest -Uri "https://getunbound.ai/setup/mdm/onboard.ps1" -OutFile onboard.ps1; .\onboard.ps1 -ApiKey YOUR_ADMIN_KEY -DiscoveryKey YOUR_DISCOVERY_KEY + # Standard onboarding + Invoke-WebRequest -Uri "https://getunbound.ai/setup/mdm/windows/onboard" -OutFile onboard.ps1; .\onboard.ps1 -ApiKey YOUR_ADMIN_KEY .EXAMPLE # With backfill of historical transcripts (opt-in) - Invoke-WebRequest -Uri "https://getunbound.ai/setup/mdm/onboard.ps1" -OutFile onboard.ps1; .\onboard.ps1 -ApiKey YOUR_ADMIN_KEY -DiscoveryKey YOUR_DISCOVERY_KEY -Backfill + Invoke-WebRequest -Uri "https://getunbound.ai/setup/mdm/windows/onboard" -OutFile onboard.ps1; .\onboard.ps1 -ApiKey YOUR_ADMIN_KEY -Backfill .EXAMPLE # Tenant deployment with custom URLs - Invoke-WebRequest -Uri "https://getunbound.ai/setup/mdm/onboard.ps1" -OutFile onboard.ps1; .\onboard.ps1 -ApiKey YOUR_ADMIN_KEY -DiscoveryKey YOUR_DISCOVERY_KEY -BackendUrl "https://backend.example.com" -GatewayUrl "https://api.example.com" + Invoke-WebRequest -Uri "https://getunbound.ai/setup/mdm/windows/onboard" -OutFile onboard.ps1; .\onboard.ps1 -ApiKey YOUR_ADMIN_KEY -BackendUrl "https://backend.example.com" -GatewayUrl "https://api.example.com" .EXAMPLE # Clear MDM setup - Invoke-WebRequest -Uri "https://getunbound.ai/setup/mdm/onboard.ps1" -OutFile onboard.ps1; .\onboard.ps1 -Clear + Invoke-WebRequest -Uri "https://getunbound.ai/setup/mdm/windows/onboard" -OutFile onboard.ps1; .\onboard.ps1 -Clear .NOTES Requires: Python 3, Administrator privileges @@ -141,11 +142,7 @@ function Main { # Validate parameters (unless -Clear is specified) if (-not $Clear) { if ([string]::IsNullOrWhiteSpace($ApiKey)) { - Exit-WithError "-ApiKey is required. Usage: & ([scriptblock]::Create((iwr 'https://getunbound.ai/setup/mdm/onboard.ps1' -UseBasicParsing).Content)) -ApiKey YOUR_KEY -DiscoveryKey YOUR_KEY" - } - - if ([string]::IsNullOrWhiteSpace($DiscoveryKey)) { - Exit-WithError "-DiscoveryKey is required. Usage: & ([scriptblock]::Create((iwr 'https://getunbound.ai/setup/mdm/onboard.ps1' -UseBasicParsing).Content)) -ApiKey YOUR_KEY -DiscoveryKey YOUR_KEY" + Exit-WithError "-ApiKey is required. Usage: Invoke-WebRequest -Uri 'https://getunbound.ai/setup/mdm/windows/onboard' -OutFile onboard.ps1; .\onboard.ps1 -ApiKey YOUR_ADMIN_API_KEY" } } @@ -174,8 +171,15 @@ function Main { } else { $pythonArgs += "--api-key" $pythonArgs += $ApiKey + # -DiscoveryKey is deprecated: the backend accepts the admin key for + # discovery uploads, so it defaults to ApiKey. Passed explicitly so + # the fallback holds whichever onboard.py revision is fetched. + $discoveryKeyArg = $DiscoveryKey + if ([string]::IsNullOrWhiteSpace($discoveryKeyArg)) { + $discoveryKeyArg = $ApiKey + } $pythonArgs += "--discovery-key" - $pythonArgs += $DiscoveryKey + $pythonArgs += $discoveryKeyArg } # URL overrides apply to both normal and clear modes diff --git a/mdm/onboard.py b/mdm/onboard.py index e5ee76fb..107cc411 100644 --- a/mdm/onboard.py +++ b/mdm/onboard.py @@ -9,9 +9,10 @@ 5. Augment MDM setup 6. Coding-discovery scan -Steps 1-5 use --api-key (admin MDM key). Step 6 uses --discovery-key (a -separate discovery-specific key). The two are different credentials and the -backend distinguishes them; passing one in place of the other will be rejected. +All six steps use --api-key (the admin MDM key). The backend accepts the +admin key for discovery uploads, so a separate discovery key is no longer +needed. --discovery-key is still accepted for back-compat (deprecated) and, +when given, is used for step 6 in place of the admin key. Backfill must be explicitly enabled via --backfill flag (typically passed from PowerShell's -Backfill parameter). When enabled, it seeds Claude Code and Codex @@ -23,8 +24,10 @@ Usage: sudo python3 -c "$(curl -fsSL https://getunbound.ai/setup/mdm/onboard)" \ - --api-key YOUR_ADMIN_API_KEY \ - --discovery-key YOUR_DISCOVERY_KEY + --api-key YOUR_ADMIN_API_KEY + +Optional (deprecated): --discovery-key runs the discovery scan with a +separate key instead of the admin key. Optional overrides for tenant deployments (passed to MDM tools and reused as the discovery --domain): @@ -104,8 +107,8 @@ "Usage:\n" " sudo python3 -c \"$(curl -fsSL https://getunbound.ai/setup/mdm/onboard)\" \\\n" " --api-key YOUR_ADMIN_API_KEY \\\n" - " --discovery-key YOUR_DISCOVERY_KEY \\\n" - " [--backend-url ] [--gateway-url ] [--skip-managed-settings]\n" + " [--discovery-key ] [--backend-url ] [--gateway-url ]\n" + " [--skip-managed-settings]\n" "\n" " sudo python3 -c \"$(curl -fsSL https://getunbound.ai/setup/mdm/onboard)\" --clear\n" ) @@ -341,6 +344,15 @@ def parse_args(argv: list) -> tuple: return discovery_key, mdm_args, backend_url, is_clear, skip_managed_settings +def _flag_value(args: list, flag: str): + """Value following `flag` in args, or None if absent or given no value.""" + try: + i = args.index(flag) + except ValueError: + return None + return args[i + 1] if i + 1 < len(args) else None + + def main() -> int: args = sys.argv[1:] @@ -353,14 +365,16 @@ def main() -> int: # Validate flags. --clear short-circuits the key checks: nothing to # authenticate, just remove the configuration. if not is_clear: - if "--api-key" not in mdm_args: + api_key = _flag_value(mdm_args, "--api-key") + if not api_key: print("Error: --api-key is required (the MDM admin key).\n", file=sys.stderr) print(USAGE, file=sys.stderr) return 1 + # The backend accepts the admin key for discovery uploads, and the + # dashboard-generated onboard command no longer includes a discovery + # key. An explicit --discovery-key still wins (back-compat). if not discovery_key: - print("Error: --discovery-key is required (separate from --api-key).\n", file=sys.stderr) - print(USAGE, file=sys.stderr) - return 1 + discovery_key = api_key if not check_admin_privileges(): if platform.system().lower() == "windows": diff --git a/tests/test_mdm_onboard.py b/tests/test_mdm_onboard.py new file mode 100644 index 00000000..5a9d566c --- /dev/null +++ b/tests/test_mdm_onboard.py @@ -0,0 +1,88 @@ +"""Argument contract for the one-shot MDM onboard wrapper (mdm/onboard.py). + +The dashboard's generated onboard command stopped including a discovery key +(unbound-fe #1999 / WEB-5597) because the backend now accepts the admin key for +discovery uploads. The wrapper must therefore treat --discovery-key as an +optional override and fall back to --api-key for the discovery step. +""" + +import pytest + +from tests.conftest import load_module + + +@pytest.fixture +def onboard(monkeypatch): + """onboard.py with every side effect stubbed: no admin check, no downloads, + no subprocesses. Records what each step would have been invoked with.""" + mod = load_module("mdm/onboard.py") + calls = {"tools": [], "discovery": []} + monkeypatch.setattr(mod, "check_admin_privileges", lambda: True) + monkeypatch.setattr(mod, "run_tool", lambda name, url, args: calls["tools"].append((name, list(args))) or True) + monkeypatch.setattr(mod, "run_discovery", lambda key, backend: calls["discovery"].append((key, backend)) or True) + return mod, calls + + +def _run(monkeypatch, mod, argv): + monkeypatch.setattr(mod.sys, "argv", ["onboard.py"] + argv) + return mod.main() + + +def test_discovery_falls_back_to_api_key_when_no_discovery_key(onboard, monkeypatch): + mod, calls = onboard + + rc = _run(monkeypatch, mod, ["--api-key", "ADMIN"]) + + assert rc == 0 + assert calls["discovery"] == [("ADMIN", mod.DEFAULT_BACKEND_URL)] + assert len(calls["tools"]) == len(mod.TOOLS) + for _name, args in calls["tools"]: + assert args == ["--api-key", "ADMIN"] + + +def test_explicit_discovery_key_still_honoured(onboard, monkeypatch): + mod, calls = onboard + + rc = _run(monkeypatch, mod, ["--api-key", "ADMIN", "--discovery-key", "DISC"]) + + assert rc == 0 + assert calls["discovery"] == [("DISC", mod.DEFAULT_BACKEND_URL)] + # The discovery key is never forwarded to the per-tool MDM scripts. + for _name, args in calls["tools"]: + assert "--discovery-key" not in args + assert "DISC" not in args + + +def test_backend_url_reaches_discovery_with_fallback_key(onboard, monkeypatch): + mod, calls = onboard + + rc = _run(monkeypatch, mod, ["--api-key", "ADMIN", "--backend-url", "https://backend.example"]) + + assert rc == 0 + assert calls["discovery"] == [("ADMIN", "https://backend.example")] + + +@pytest.mark.parametrize("argv", [ + ["--discovery-key", "DISC"], # discovery key alone is not enough + ["--api-key"], # flag with no value + ["--api-key", ""], # flag with an empty value +]) +def test_missing_api_key_still_errors(onboard, monkeypatch, capsys, argv): + mod, calls = onboard + + rc = _run(monkeypatch, mod, argv) + + assert rc == 1 + assert "--api-key is required" in capsys.readouterr().err + assert calls["tools"] == [] + assert calls["discovery"] == [] + + +def test_clear_needs_no_keys_and_skips_discovery(onboard, monkeypatch): + mod, calls = onboard + + rc = _run(monkeypatch, mod, ["--clear"]) + + assert rc == 0 + assert calls["discovery"] == [] + assert [args for _name, args in calls["tools"]] == [["--clear"]] * len(mod.TOOLS) From 7551a377840ea887757cd134fea0bd900bf499e0 Mon Sep 17 00:00:00 2001 From: Vignesh Subbiah Date: Wed, 2 Sep 2026 18:38:33 -0700 Subject: [PATCH 2/3] fix(mdm): stop onboard.ps1 capturing Python stdout as the exit code `$exitCode = Main` captured Main's whole success stream, i.e. every line the Python driver and the per-tool setup.py scripts wrote to stdout, and `exit` on the resulting Object[] returned 0. Measured on a Windows VM: a Main whose python printed one line and exited 3 gave captured-type=Object[] count=2 value=[py-stdout-line 3] and a cmd-level exit code of 0. So customers saw only stderr in their logs, and Intune remediation saw success even when onboarding failed. Main is now invoked bare so native stdout flows to the host; the Python exit code is stashed in $script:pythonExitCode (defaulted to 1 before Main so an early stop can't leak a 0) and used by the final `exit`. Exit-WithError paths and the self-destruct block are unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NYCyXQWih1Ujk9ScSpjvrp --- mdm/onboard.ps1 | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/mdm/onboard.ps1 b/mdm/onboard.ps1 index a9cde1a0..7edda377 100644 --- a/mdm/onboard.ps1 +++ b/mdm/onboard.ps1 @@ -208,9 +208,12 @@ function Main { $pythonArgs += "--skip-managed-settings" } - # Execute the Python script and capture exit code + # Execute the Python script. Its stdout flows straight to the host + # because Main is invoked bare at the entry point; the exit code is + # stashed script-scoped instead of returned. (`$x = Main` would capture + # the Python output into $x and `exit` on that array reports 0.) & $pythonCmd @pythonArgs - $exitCode = $LASTEXITCODE + $script:pythonExitCode = $LASTEXITCODE } finally { # Clean up temporary files @@ -221,13 +224,12 @@ function Main { Remove-Item $tempPyFile -ErrorAction SilentlyContinue } } - - # Return the exit code - return $exitCode } -# Entry point - capture exit code from Main -$exitCode = Main +# Entry point. Defaults to failure so anything that stops Main before Python +# runs can never report success to the caller (e.g. Intune remediation). +$script:pythonExitCode = 1 +Main # Self-destruct: Remove this script file after execution completes # This allows users to run without manual cleanup: Invoke-WebRequest ... -OutFile onboard.ps1; .\onboard.ps1 -ApiKey ... @@ -236,4 +238,4 @@ if ($MyInvocation.MyCommand.Path) { } # Exit with the Python script's exit code -exit $exitCode +exit $script:pythonExitCode From e8f643300403e34d492faca1c66a8267b4585a82 Mon Sep 17 00:00:00 2001 From: Vignesh Subbiah Date: Wed, 2 Sep 2026 19:12:44 -0700 Subject: [PATCH 3/3] fix(mdm): resolve the discovery key from the admin key + serial, never the admin key itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scanning with the admin key authenticates but mis-attributes: the backend (ai-gateway-data webapp/tasks/ai_tools_report_tasks.py:185-200) attributes an application-key-authenticated report to that key's OWNER and skips the MDM serial lookup, so a fleet would show every device under the admin. unbound-cli #82 solved this by exchanging admin key + hardware serial for the device owner's key; onboard.py now does the same. - onboard.py: get_device_serial() (ioreg / dmidecode + /sys dmi / Win32_BIOS→MachineGuid→hostname, copied from claude-code setup.py), fetch_owner_key() via urllib against /api/v1/automations/mdm/get_application_api_key/?serial_number=&app_type=default with Bearer , 20s timeout, one retry. Precedence: explicit --discovery-key > owner key > Discovery step FAILED with the cause named. Never falls back to the admin key. Steps 1-5 run regardless. Prints "[Discovery] scanning with the device owner's key (serial X)". - onboard.ps1: only forward --discovery-key when -DiscoveryKey was given; onboard.py owns the resolution. Exit-code and URL fixes kept. - README + docstring/USAGE updated. - tests: exchange happy path (URL, header, timeout, info line, no keys printed), explicit key skips exchange, URLError/HTTP 404/no api_key/bad JSON → Discovery failed + exit 1 with tools still run, missing serial same, plus the existing api-key/--clear cases. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NYCyXQWih1Ujk9ScSpjvrp --- mdm/README.md | 2 +- mdm/onboard.ps1 | 17 ++-- mdm/onboard.py | 173 +++++++++++++++++++++++++++++++++++--- tests/test_mdm_onboard.py | 114 +++++++++++++++++++++---- 4 files changed, 264 insertions(+), 42 deletions(-) diff --git a/mdm/README.md b/mdm/README.md index ecdffb24..11e3656b 100644 --- a/mdm/README.md +++ b/mdm/README.md @@ -8,7 +8,7 @@ Runs all five MDM setup steps for an admin device enrollment in one shot: 4. **GitHub Copilot** MDM setup 5. **Coding-discovery** scan (separate repo) -All steps use `--api-key` (the admin MDM key). The backend accepts the admin key for discovery uploads, so a separate discovery key is no longer needed. `--discovery-key` / `-DiscoveryKey` is still accepted for back-compat (deprecated) and, when given, is used for the discovery scan in place of the admin key. +Steps 1–4 use `--api-key` (the admin MDM key). The discovery scan runs with the **device owner's** key, which onboard.py resolves by exchanging the admin key + hardware serial via `/api/v1/automations/mdm/get_application_api_key/` (the same exchange the per-tool MDM scripts do), so the scan is attributed to the owner rather than the admin. If that exchange fails the Discovery step is reported failed; it never falls back to the admin key. `--discovery-key` / `-DiscoveryKey` is still accepted (deprecated) and, when given, skips the exchange and scans with that key. Each step runs in its own subprocess; a failure in one does not abort the others. A summary at the end lists which steps succeeded and which failed. diff --git a/mdm/onboard.ps1 b/mdm/onboard.ps1 index 7edda377..d069103c 100644 --- a/mdm/onboard.ps1 +++ b/mdm/onboard.ps1 @@ -26,8 +26,8 @@ The MDM admin API key (required unless -Clear is specified) .PARAMETER DiscoveryKey - Deprecated, optional: a separate key for the coding-discovery scan. Defaults to - ApiKey — the backend accepts the admin key for discovery uploads. + Deprecated, optional: scan with this key instead of the device owner's key that + onboard.py resolves from ApiKey + the hardware serial. .PARAMETER BackendUrl Backend URL override for tenant deployments (default: https://backend.getunbound.ai) @@ -171,15 +171,12 @@ function Main { } else { $pythonArgs += "--api-key" $pythonArgs += $ApiKey - # -DiscoveryKey is deprecated: the backend accepts the admin key for - # discovery uploads, so it defaults to ApiKey. Passed explicitly so - # the fallback holds whichever onboard.py revision is fetched. - $discoveryKeyArg = $DiscoveryKey - if ([string]::IsNullOrWhiteSpace($discoveryKeyArg)) { - $discoveryKeyArg = $ApiKey + # -DiscoveryKey is deprecated and only forwarded when given. Otherwise + # onboard.py resolves the device owner's key from ApiKey + the serial. + if (-not [string]::IsNullOrWhiteSpace($DiscoveryKey)) { + $pythonArgs += "--discovery-key" + $pythonArgs += $DiscoveryKey } - $pythonArgs += "--discovery-key" - $pythonArgs += $discoveryKeyArg } # URL overrides apply to both normal and clear modes diff --git a/mdm/onboard.py b/mdm/onboard.py index 107cc411..6e6d55ca 100644 --- a/mdm/onboard.py +++ b/mdm/onboard.py @@ -9,10 +9,16 @@ 5. Augment MDM setup 6. Coding-discovery scan -All six steps use --api-key (the admin MDM key). The backend accepts the -admin key for discovery uploads, so a separate discovery key is no longer -needed. --discovery-key is still accepted for back-compat (deprecated) and, -when given, is used for step 6 in place of the admin key. +Steps 1-5 use --api-key (the admin MDM key). Step 6 must run with the DEVICE +OWNER's key: the backend attributes a discovery report authenticated with an +application key to that key's owner and skips the MDM serial lookup, so a scan +run with the admin key would file every device under the admin. onboard.py +therefore exchanges the admin key + hardware serial for the owner's key via +/api/v1/automations/mdm/get_application_api_key/ (the same exchange the +per-tool MDM scripts perform) and scans with that. If the exchange fails (no +serial, HTTP error, no api_key in the response) the Discovery step is reported +as failed; it never falls back to the admin key. --discovery-key +(deprecated) skips the exchange and scans with the given key. Backfill must be explicitly enabled via --backfill flag (typically passed from PowerShell's -Backfill parameter). When enabled, it seeds Claude Code and Codex @@ -26,8 +32,8 @@ sudo python3 -c "$(curl -fsSL https://getunbound.ai/setup/mdm/onboard)" \ --api-key YOUR_ADMIN_API_KEY -Optional (deprecated): --discovery-key runs the discovery scan with a -separate key instead of the admin key. +Optional (deprecated): --discovery-key scans with the given key instead +of the device owner's key resolved from the admin key + hardware serial. Optional overrides for tenant deployments (passed to MDM tools and reused as the discovery --domain): @@ -48,12 +54,17 @@ others. A summary at the end lists which steps succeeded and which failed. """ +import json import os import platform import signal +import socket import subprocess import sys import tempfile +import urllib.error +import urllib.parse +import urllib.request # On Windows, when this script runs as a child of the MDM onboard wrapper its # stdout is a non-console pipe defaulting to the legacy code page (cp1252), @@ -102,6 +113,8 @@ DISCOVERY_INSTALL_SH = f"{_RAW_DISCOVERY}/install.sh" DISCOVERY_INSTALL_PS1 = f"{_RAW_DISCOVERY}/install.ps1" DEFAULT_BACKEND_URL = "https://backend.getunbound.ai" +KEY_EXCHANGE_TIMEOUT_SECONDS = 20 +KEY_EXCHANGE_ATTEMPTS = 2 USAGE = ( "Usage:\n" @@ -305,6 +318,142 @@ def run_discovery(discovery_key: str, backend_url: str) -> bool: pass +def _run_stdout(cmd: list) -> str: + """Stripped stdout of `cmd`, or "" on any failure (missing binary, + non-zero exit, timeout). Serial probing is best-effort per source.""" + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) + except Exception: + return "" + return result.stdout.strip() if result.returncode == 0 else "" + + +def _serial_darwin(): + # IOPlatformSerialNumber is locale-stable; system_profiler's label is not. + for line in _run_stdout(["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"]).split("\n"): + if "IOPlatformSerialNumber" in line: + parts = line.split("=") + if len(parts) >= 2: + serial = parts[1].strip().strip('"').strip() + if serial: + return serial + return None + + +def _serial_linux(): + serial = _run_stdout(["dmidecode", "-s", "system-serial-number"]) + if serial: + return serial + try: + with open("/sys/class/dmi/id/product_serial", encoding="utf-8") as f: + serial = f.read().strip() + except OSError: + return None + return serial or None + + +def _serial_windows(): + # Same chain as get_device_identifier() in claude-code/hooks/mdm/setup.py, + # copied rather than imported (this script is standalone): BIOS serial, + # then the MachineGuid, then the hostname. + serial = _run_stdout([ + "powershell", "-NoProfile", "-Command", + "(Get-CimInstance -ClassName Win32_BIOS).SerialNumber", + ]) + if serial: + return serial + try: + import winreg + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography") as key: + value, _ = winreg.QueryValueEx(key, "MachineGuid") + if value: + return str(value).strip() + except Exception: + pass + return socket.gethostname() or None + + +def get_device_serial(): + """Hardware serial as the per-tool MDM scripts report it, so the discovery + scan is attributed to the same device owner. None if it can't be read.""" + system = platform.system().lower() + try: + if system == "darwin": + return _serial_darwin() + if system == "linux": + return _serial_linux() + if system == "windows": + return _serial_windows() + except Exception: + return None + return None + + +def fetch_owner_key(api_key: str, backend_url: str, serial: str) -> str: + """Exchanges the admin key + device serial for the device owner's + application key. Raises RuntimeError naming the cause on any failure.""" + params = urllib.parse.urlencode({"serial_number": serial, "app_type": "default"}) + url = f"{backend_url.rstrip('/')}/api/v1/automations/mdm/get_application_api_key/?{params}" + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {api_key}", + "User-Agent": "unbound-mdm-onboard/1.1", + }) + last_error = None + for _attempt in range(KEY_EXCHANGE_ATTEMPTS): + try: + with urllib.request.urlopen(req, timeout=KEY_EXCHANGE_TIMEOUT_SECONDS) as resp: + body = resp.read() + break + except urllib.error.HTTPError as e: + last_error = f"HTTP {e.code}" + except Exception as e: + last_error = str(e) or type(e).__name__ + else: + raise RuntimeError( + f"key exchange failed after {KEY_EXCHANGE_ATTEMPTS} attempts: {last_error}" + ) + try: + data = json.loads(body) + except ValueError: + raise RuntimeError("key exchange returned invalid JSON") + owner_key = data.get("api_key") if isinstance(data, dict) else None + if not owner_key: + raise RuntimeError( + "key exchange response has no api_key (is this device's serial enrolled in MDM?)" + ) + return owner_key + + +def resolve_discovery_key(api_key: str, backend_url: str) -> str: + """The device owner's key for the discovery scan. Raises RuntimeError if + the serial can't be read or the exchange fails. Deliberately never falls + back to the admin key: the backend would attribute the device to the admin.""" + serial = get_device_serial() + if not serial: + raise RuntimeError("could not read this device's hardware serial number") + owner_key = fetch_owner_key(api_key, backend_url, serial) + print(f"[Discovery] scanning with the device owner's key (serial {serial})") + return owner_key + + +def run_discovery_step(explicit_key, api_key: str, backend_url: str) -> bool: + """Explicit --discovery-key wins; otherwise resolve the owner's key from + the admin key + serial. On resolution failure the step is reported failed + and the scan is skipped.""" + key = explicit_key + if not key: + try: + key = resolve_discovery_key(api_key, backend_url) + except RuntimeError as e: + print( + f"❌ [Discovery] cannot resolve the device owner's key: {e}. " + "Skipping the scan rather than attributing this device to the admin key.", + file=sys.stderr, + ) + return False + return run_discovery(key, backend_url) + + def parse_args(argv: list) -> tuple: """Splits argv into (discovery_key, mdm_args, backend_url, is_clear, skip_managed_settings). @@ -363,18 +512,16 @@ def main() -> int: discovery_key, mdm_args, backend_url, is_clear, skip_managed_settings = parse_args(args) # Validate flags. --clear short-circuits the key checks: nothing to - # authenticate, just remove the configuration. + # authenticate, just remove the configuration. --discovery-key is optional: + # the dashboard-generated command no longer includes it, and the scan's key + # is resolved from the admin key + serial in run_discovery_step. + api_key = None if not is_clear: api_key = _flag_value(mdm_args, "--api-key") if not api_key: print("Error: --api-key is required (the MDM admin key).\n", file=sys.stderr) print(USAGE, file=sys.stderr) return 1 - # The backend accepts the admin key for discovery uploads, and the - # dashboard-generated onboard command no longer includes a discovery - # key. An explicit --discovery-key still wins (back-compat). - if not discovery_key: - discovery_key = api_key if not check_admin_privileges(): if platform.system().lower() == "windows": @@ -402,7 +549,7 @@ def main() -> int: # Discovery is a one-shot scan — skip it on --clear (nothing to remove). if not is_clear: print(f"\n{'=' * 60}\n[Discovery] coding-tool scan\n{'=' * 60}\n") - if not run_discovery(discovery_key, backend_url or DEFAULT_BACKEND_URL): + if not run_discovery_step(discovery_key, api_key, backend_url or DEFAULT_BACKEND_URL): failures.append("Discovery") print(f"\n{'=' * 60}") diff --git a/tests/test_mdm_onboard.py b/tests/test_mdm_onboard.py index 5a9d566c..7c3f4490 100644 --- a/tests/test_mdm_onboard.py +++ b/tests/test_mdm_onboard.py @@ -1,26 +1,55 @@ -"""Argument contract for the one-shot MDM onboard wrapper (mdm/onboard.py). +"""Argument and key-resolution contract for the one-shot MDM onboard wrapper +(mdm/onboard.py). The dashboard's generated onboard command stopped including a discovery key -(unbound-fe #1999 / WEB-5597) because the backend now accepts the admin key for -discovery uploads. The wrapper must therefore treat --discovery-key as an -optional override and fall back to --api-key for the discovery step. +(unbound-fe #1999 / WEB-5597). The backend attributes an application-key +authenticated discovery report to that key's OWNER (ai-gateway-data +webapp/tasks/ai_tools_report_tasks.py), so scanning with the admin key would +file every device under the admin. The wrapper must instead exchange the admin +key + hardware serial for the device owner's key, and must never fall back to +the admin key when that exchange fails. """ +import io +import json +import urllib.error + import pytest from tests.conftest import load_module +class _Response(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + return False + + @pytest.fixture def onboard(monkeypatch): """onboard.py with every side effect stubbed: no admin check, no downloads, - no subprocesses. Records what each step would have been invoked with.""" + no subprocesses, a fixed serial, and a scripted key-exchange endpoint. + Records what each step and the exchange would have been invoked with.""" mod = load_module("mdm/onboard.py") - calls = {"tools": [], "discovery": []} + calls = {"tools": [], "discovery": [], "exchange": []} + exchange = {"responses": [json.dumps({"api_key": "OWNER"}).encode()]} + + def fake_urlopen(req, timeout=None): + calls["exchange"].append((req.full_url, dict(req.header_items()), timeout)) + outcome = exchange["responses"][min(len(calls["exchange"]) - 1, len(exchange["responses"]) - 1)] + if isinstance(outcome, Exception): + raise outcome + return _Response(outcome) + monkeypatch.setattr(mod, "check_admin_privileges", lambda: True) + monkeypatch.setattr(mod, "get_device_serial", lambda: "SER123") + monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen) monkeypatch.setattr(mod, "run_tool", lambda name, url, args: calls["tools"].append((name, list(args))) or True) monkeypatch.setattr(mod, "run_discovery", lambda key, backend: calls["discovery"].append((key, backend)) or True) - return mod, calls + return mod, calls, exchange def _run(monkeypatch, mod, argv): @@ -28,38 +57,85 @@ def _run(monkeypatch, mod, argv): return mod.main() -def test_discovery_falls_back_to_api_key_when_no_discovery_key(onboard, monkeypatch): - mod, calls = onboard +def test_discovery_uses_owner_key_from_serial_exchange(onboard, monkeypatch, capsys): + mod, calls, _ = onboard rc = _run(monkeypatch, mod, ["--api-key", "ADMIN"]) assert rc == 0 - assert calls["discovery"] == [("ADMIN", mod.DEFAULT_BACKEND_URL)] + assert calls["discovery"] == [("OWNER", mod.DEFAULT_BACKEND_URL)] assert len(calls["tools"]) == len(mod.TOOLS) for _name, args in calls["tools"]: assert args == ["--api-key", "ADMIN"] + [(url, headers, timeout)] = calls["exchange"] + assert url == (mod.DEFAULT_BACKEND_URL + + "/api/v1/automations/mdm/get_application_api_key/?serial_number=SER123&app_type=default") + assert headers["Authorization"] == "Bearer ADMIN" + assert timeout == mod.KEY_EXCHANGE_TIMEOUT_SECONDS -def test_explicit_discovery_key_still_honoured(onboard, monkeypatch): - mod, calls = onboard + out = capsys.readouterr().out + assert "[Discovery] scanning with the device owner's key (serial SER123)" in out + assert "ADMIN" not in out and "OWNER" not in out # keys are never printed + + +def test_explicit_discovery_key_wins_and_skips_exchange(onboard, monkeypatch): + mod, calls, _ = onboard rc = _run(monkeypatch, mod, ["--api-key", "ADMIN", "--discovery-key", "DISC"]) assert rc == 0 assert calls["discovery"] == [("DISC", mod.DEFAULT_BACKEND_URL)] + assert calls["exchange"] == [] # The discovery key is never forwarded to the per-tool MDM scripts. for _name, args in calls["tools"]: assert "--discovery-key" not in args assert "DISC" not in args -def test_backend_url_reaches_discovery_with_fallback_key(onboard, monkeypatch): - mod, calls = onboard +def test_backend_url_used_for_exchange_and_discovery(onboard, monkeypatch): + mod, calls, _ = onboard - rc = _run(monkeypatch, mod, ["--api-key", "ADMIN", "--backend-url", "https://backend.example"]) + rc = _run(monkeypatch, mod, ["--api-key", "ADMIN", "--backend-url", "https://backend.example/"]) assert rc == 0 - assert calls["discovery"] == [("ADMIN", "https://backend.example")] + assert calls["exchange"][0][0].startswith("https://backend.example/api/v1/automations/mdm/get_application_api_key/?") + assert calls["discovery"] == [("OWNER", "https://backend.example/")] + + +@pytest.mark.parametrize("responses, cause", [ + ([urllib.error.URLError("connection refused")] * 2, "connection refused"), + ([urllib.error.HTTPError("u", 404, "Not Found", {}, None)] * 2, "HTTP 404"), + ([json.dumps({"email": "x@y"}).encode()], "no api_key"), + ([b""], "invalid JSON"), +]) +def test_exchange_failure_fails_discovery_only(onboard, monkeypatch, capsys, responses, cause): + mod, calls, exchange = onboard + exchange["responses"] = responses + + rc = _run(monkeypatch, mod, ["--api-key", "ADMIN"]) + + assert rc == 1 + assert calls["discovery"] == [] # never scanned with the admin key + assert len(calls["tools"]) == len(mod.TOOLS) # steps 1-5 still ran + assert len(calls["exchange"]) == (mod.KEY_EXCHANGE_ATTEMPTS if isinstance(responses[0], Exception) else 1) + captured = capsys.readouterr() + assert "cannot resolve the device owner's key" in captured.err + assert cause in captured.err + assert "Discovery" in captured.out.split("failure(s):")[-1] + + +def test_missing_serial_fails_discovery_only(onboard, monkeypatch, capsys): + mod, calls, _ = onboard + monkeypatch.setattr(mod, "get_device_serial", lambda: None) + + rc = _run(monkeypatch, mod, ["--api-key", "ADMIN"]) + + assert rc == 1 + assert calls["discovery"] == [] + assert calls["exchange"] == [] + assert len(calls["tools"]) == len(mod.TOOLS) + assert "hardware serial" in capsys.readouterr().err @pytest.mark.parametrize("argv", [ @@ -68,7 +144,7 @@ def test_backend_url_reaches_discovery_with_fallback_key(onboard, monkeypatch): ["--api-key", ""], # flag with an empty value ]) def test_missing_api_key_still_errors(onboard, monkeypatch, capsys, argv): - mod, calls = onboard + mod, calls, _ = onboard rc = _run(monkeypatch, mod, argv) @@ -76,13 +152,15 @@ def test_missing_api_key_still_errors(onboard, monkeypatch, capsys, argv): assert "--api-key is required" in capsys.readouterr().err assert calls["tools"] == [] assert calls["discovery"] == [] + assert calls["exchange"] == [] def test_clear_needs_no_keys_and_skips_discovery(onboard, monkeypatch): - mod, calls = onboard + mod, calls, _ = onboard rc = _run(monkeypatch, mod, ["--clear"]) assert rc == 0 assert calls["discovery"] == [] + assert calls["exchange"] == [] assert [args for _name, args in calls["tools"]] == [["--clear"]] * len(mod.TOOLS)