From 7040832565131585d04f666d94121ec6dbad4e13 Mon Sep 17 00:00:00 2001 From: "benjamin.baker" Date: Sun, 30 Aug 2026 08:31:31 -0400 Subject: [PATCH] Add agentless adoption tool for endpoints Standalone one-shot enrollment tool that creates a ShopDB PC + Endpoint entry using only the endpoints collector key, reusing the existing collector adopt path. Collects identity, WMI hardware, and installed-software SBOM; requires admin. Server-side packaging route attaches to the existing endpoints blueprint; no core contract change. --- agent/ShopDbAdoptionTool/Program.cs | 358 ++++++++++++++++++ .../ShopDbAdoptionTool.csproj | 30 ++ agent/ShopDbAdoptionTool/app.manifest | 20 + agent/ShopDbEndpointAgent.slnx | 1 + plugins/endpoints/ADOPTION-TOOL-SPIKE.md | 77 ++++ plugins/endpoints/api/__init__.py | 2 + plugins/endpoints/api/adoption.py | 107 ++++++ .../frontend/views/AgentKeySettings.vue | 90 +++++ 8 files changed, 685 insertions(+) create mode 100644 agent/ShopDbAdoptionTool/Program.cs create mode 100644 agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj create mode 100644 agent/ShopDbAdoptionTool/app.manifest create mode 100644 plugins/endpoints/ADOPTION-TOOL-SPIKE.md create mode 100644 plugins/endpoints/api/adoption.py diff --git a/agent/ShopDbAdoptionTool/Program.cs b/agent/ShopDbAdoptionTool/Program.cs new file mode 100644 index 00000000..c6126710 --- /dev/null +++ b/agent/ShopDbAdoptionTool/Program.cs @@ -0,0 +1,358 @@ +using System.Management; +using System.Net.Http.Json; +using System.Net.NetworkInformation; +using System.Runtime.Versioning; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ShopDbAdoptionTool; + +// ShopDB adoption tool (SPIKE). A one-shot, agentless enrollment client: it uses +// only the endpoints collector key to create a ShopDB PC (Computer) + Endpoint +// entry via the existing collector adopt path, then exits. It is NOT the endpoint +// agent - it installs nothing, runs no service, and reports no ongoing telemetry. +// For sites that want an inventory entry without running the full agent. +// +// Configuration comes from adopt.config.json beside the exe, or from +// --baseurl / --collectorkey / --hostname arguments (arguments win). The +// collector key is never written to disk by this tool; it is read from the +// config the operator generated and placed next to the exe. +[SupportedOSPlatform("windows")] +internal static class Program +{ + private const string ReportPath = "/api/collector/endpoints"; + + private static async Task Main(string[] args) + { + try + { + var config = LoadConfig(args); + if (string.IsNullOrWhiteSpace(config.BaseUrl) || + string.IsNullOrWhiteSpace(config.CollectorKey)) + { + Console.Error.WriteLine( + "Missing baseurl or collectorkey. Provide adopt.config.json next to the " + + "exe or pass --baseurl and --collectorkey."); + return 2; + } + + // The app manifest forces UAC elevation, so this is a belt-and-braces + // guard for edge cases (for example a stripped manifest). Full WMI and + // the machine Uninstall hives need admin, or collection is partial. + if (!IsElevated()) + { + Console.Error.WriteLine( + "This tool must run as administrator so hardware and installed-" + + "software collection succeeds. Right-click and Run as administrator."); + return 3; + } + + var payload = BuildPayload(config); + Console.WriteLine($"Adopting '{payload.Hostname}' into {config.BaseUrl} ..."); + Console.WriteLine( + $"Collected {payload.Applications?.Length ?? 0} installed applications."); + + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) }; + client.DefaultRequestHeaders.Add("X-API-Key", config.CollectorKey); + + var url = config.BaseUrl!.TrimEnd('/') + ReportPath; + using var response = await client.PostAsJsonAsync(url, payload); + var body = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + Console.Error.WriteLine( + $"Adoption failed: HTTP {(int)response.StatusCode}. {body}"); + return 1; + } + + Console.WriteLine("Adoption succeeded."); + Console.WriteLine(body); + return 0; + } + catch (Exception error) + { + Console.Error.WriteLine($"Adoption error: {error.Message}"); + return 1; + } + } + + private static AdoptConfig LoadConfig(string[] args) + { + var config = new AdoptConfig(); + + var configPath = Path.Combine(AppContext.BaseDirectory, "adopt.config.json"); + if (File.Exists(configPath)) + { + var fromFile = JsonSerializer.Deserialize( + File.ReadAllText(configPath), + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (fromFile is not null) + config = fromFile; + } + + for (var i = 0; i < args.Length - 1; i++) + { + var value = args[i + 1]; + switch (args[i].ToLowerInvariant()) + { + case "--baseurl": + config.BaseUrl = value; + break; + case "--collectorkey": + config.CollectorKey = value; + break; + case "--hostname": + config.Hostname = value; + break; + } + } + + return config; + } + + private static AdoptPayload BuildPayload(AdoptConfig config) + { + var hostname = !string.IsNullOrWhiteSpace(config.Hostname) + ? config.Hostname! + : Environment.MachineName; + + return new AdoptPayload + { + Hostname = hostname, + Adopt = true, + LoggedInUser = GetLoggedInUser(), + SerialNumber = WmiFirst("SELECT SerialNumber FROM Win32_BIOS", "SerialNumber"), + PcType = WmiFirst("SELECT Model FROM Win32_ComputerSystem", "Model"), + VendorName = WmiFirst("SELECT Manufacturer FROM Win32_ComputerSystem", "Manufacturer"), + ModelNumber = GetModelNumber(), + OsName = WmiFirst("SELECT Caption FROM Win32_OperatingSystem", "Caption"), + IpAddress = ResolveIpAddress(), + LastBootTime = GetLastBootTime(), + Applications = GetApplications(), + AgentVersion = "adopt-0.1.0", + }; + } + + // Mirrors the endpoint agent: prefer the WMI logged-on user, fall back to + // the process identity so the field is never blank. + private static string GetLoggedInUser() + { + var user = WmiFirst("SELECT UserName FROM Win32_ComputerSystem", "UserName"); + return string.IsNullOrWhiteSpace(user) + ? $"{Environment.UserDomainName}\\{Environment.UserName}" + : user; + } + + // Human-readable model first (what pctype classifies on); SystemSKUNumber is + // an OEM SKU code, used only when Model is blank. Matches EndpointCollector. + private static string? GetModelNumber() + { + var model = WmiFirst("SELECT Model FROM Win32_ComputerSystem", "Model"); + if (!string.IsNullOrWhiteSpace(model)) + return model; + return WmiFirst("SELECT SystemSKUNumber FROM Win32_ComputerSystem", + "SystemSKUNumber"); + } + + private static DateTimeOffset? GetLastBootTime() + { + var raw = WmiFirst("SELECT LastBootUpTime FROM Win32_OperatingSystem", + "LastBootUpTime"); + if (string.IsNullOrWhiteSpace(raw)) + return null; + try + { + return ManagementDateTimeConverter.ToDateTime(raw).ToUniversalTime(); + } + catch + { + return null; + } + } + + // Run one WMI query and return the first row's column as a trimmed string, + // or null. Best-effort: WMI failures never abort adoption. + private static string? WmiFirst(string query, string column) + { + try + { + using var searcher = new ManagementObjectSearcher(query); + foreach (var item in searcher.Get()) + { + var value = item[column]?.ToString()?.Trim(); + if (!string.IsNullOrWhiteSpace(value)) + return value; + } + } + catch + { + // Best-effort: the server treats every hardware field as optional. + } + return null; + } + + // Installed-software SBOM. Mirrors the endpoint agent exactly: enumerate both + // Uninstall registry hives (64-bit and WOW6432Node), keep entries with a + // DisplayName, de-dupe on name+version, and emit {displayname, displayversion, + // publisher}. Requires admin to read the machine hives reliably (see the + // app manifest). Never throws out to the caller. + private static object[] GetApplications() + { + const string uninstall64 = + @"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; + const string uninstall32 = + @"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"; + + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + CollectUninstallHive(uninstall64, result, seen); + CollectUninstallHive(uninstall32, result, seen); + return result.ToArray(); + } + + private static void CollectUninstallHive(string subKey, List result, + HashSet seen) + { + try + { + using var machine = Microsoft.Win32.RegistryKey.OpenBaseKey( + Microsoft.Win32.RegistryHive.LocalMachine, + Microsoft.Win32.RegistryView.Default); + using var uninstall = machine.OpenSubKey(subKey); + if (uninstall is null) + return; + + foreach (var name in uninstall.GetSubKeyNames()) + { + try + { + using var entry = uninstall.OpenSubKey(name); + var displayName = entry?.GetValue("DisplayName")?.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(displayName)) + continue; + + var displayVersion = + entry?.GetValue("DisplayVersion")?.ToString()?.Trim() ?? ""; + var publisher = + entry?.GetValue("Publisher")?.ToString()?.Trim() ?? ""; + + if (!seen.Add(displayName + "\u0000" + displayVersion)) + continue; + + result.Add(new + { + displayname = displayName, + displayversion = displayVersion, + publisher, + }); + } + catch + { + // Skip unreadable entries; keep collecting the rest. + } + } + } + catch + { + // Skip an unreadable hive; the other hive may still return data. + } + } + + private static string? ResolveIpAddress() + { + try + { + foreach (var network in NetworkInterface.GetAllNetworkInterfaces()) + { + if (network.OperationalStatus != OperationalStatus.Up) + continue; + if (network.NetworkInterfaceType == NetworkInterfaceType.Loopback) + continue; + foreach (var address in network.GetIPProperties().UnicastAddresses) + { + if (address.Address.AddressFamily == + System.Net.Sockets.AddressFamily.InterNetwork) + return address.Address.ToString(); + } + } + } + catch + { + // Best-effort: the server treats ipaddress as optional. + } + return null; + } + + // True when the process runs with the Administrators group in its token. + private static bool IsElevated() + { + try + { + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + var principal = new System.Security.Principal.WindowsPrincipal(identity); + return principal.IsInRole( + System.Security.Principal.WindowsBuiltInRole.Administrator); + } + catch + { + return false; + } + } +} + +internal sealed class AdoptConfig +{ + [JsonPropertyName("baseurl")] + public string? BaseUrl { get; set; } + + [JsonPropertyName("collectorkey")] + public string? CollectorKey { get; set; } + + [JsonPropertyName("hostname")] + public string? Hostname { get; set; } +} + +// Payload keys mirror the endpoints collector adopt schema exactly (lowercase, +// concatenated) so the server stores them without translation. This tool sends +// the adopt identity plus the hardware and SBOM subset the endpoint agent +// collects; ongoing health telemetry (cpu, services, events) is intentionally +// absent because this is agentless enrollment, not the agent. +internal sealed class AdoptPayload +{ + [JsonPropertyName("hostname")] + public string Hostname { get; set; } = ""; + + [JsonPropertyName("adopt")] + public bool Adopt { get; set; } + + [JsonPropertyName("loggedinuser")] + public string? LoggedInUser { get; set; } + + [JsonPropertyName("serialnumber")] + public string? SerialNumber { get; set; } + + [JsonPropertyName("pctype")] + public string? PcType { get; set; } + + [JsonPropertyName("vendorname")] + public string? VendorName { get; set; } + + [JsonPropertyName("modelnumber")] + public string? ModelNumber { get; set; } + + [JsonPropertyName("osname")] + public string? OsName { get; set; } + + [JsonPropertyName("ipaddress")] + public string? IpAddress { get; set; } + + [JsonPropertyName("lastboottime")] + public DateTimeOffset? LastBootTime { get; set; } + + [JsonPropertyName("applications")] + public object[]? Applications { get; set; } + + [JsonPropertyName("agentversion")] + public string? AgentVersion { get; set; } +} diff --git a/agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj b/agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj new file mode 100644 index 00000000..ad644831 --- /dev/null +++ b/agent/ShopDbAdoptionTool/ShopDbAdoptionTool.csproj @@ -0,0 +1,30 @@ + + + + Exe + net8.0-windows + enable + enable + ShopDbAdoptionTool + shopdb-adopt + + 0.1.0 + true + win-x64 + true + true + true + true + none + app.manifest + + + + + + + diff --git a/agent/ShopDbAdoptionTool/app.manifest b/agent/ShopDbAdoptionTool/app.manifest new file mode 100644 index 00000000..3691a666 --- /dev/null +++ b/agent/ShopDbAdoptionTool/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/agent/ShopDbEndpointAgent.slnx b/agent/ShopDbEndpointAgent.slnx index 1383261a..e58da209 100644 --- a/agent/ShopDbEndpointAgent.slnx +++ b/agent/ShopDbEndpointAgent.slnx @@ -2,4 +2,5 @@ + diff --git a/plugins/endpoints/ADOPTION-TOOL-SPIKE.md b/plugins/endpoints/ADOPTION-TOOL-SPIKE.md new file mode 100644 index 00000000..448c9f38 --- /dev/null +++ b/plugins/endpoints/ADOPTION-TOOL-SPIKE.md @@ -0,0 +1,77 @@ +# Adoption tool spike + +Status: spike. Built on the `origin/main` baseline so it can be cherry-picked as +a single unit once endpoint agent development is complete. + +## What it is + +A one-shot, agentless enrollment tool. An admin generates a small download from +the Endpoints key settings page; running the tool once on a PC creates a ShopDB +PC (Computer) and Endpoint entry using only the endpoints collector key. It is +NOT the endpoint agent: it installs nothing, runs no service, and sends no +ongoing telemetry. It targets sites that want an inventory entry without running +the full agent. + +## How it works + +1. Admin generates or reuses an endpoint agent key (existing `/agentkey` flow). +2. Admin opens the "Adoption tool" section, confirms the base URL (prefilled + from the browser origin) and pastes the collector key (prefilled when a key + was just generated, since the secret is only shown once). +3. `POST /api/endpoints/adoptiontool/package` (admin-gated) returns a ZIP with + `adopt.config.json`, `README.txt`, and `shopdb-adopt.exe` when a prebuilt + binary is staged on the server. +4. On the target PC, `shopdb-adopt.exe` reads the config beside it (or CLI args), + then POSTs to `/api/collector/endpoints` with `X-API-Key` and `adopt=true`. + The existing `_adopt_computer` path creates the Computer + EndpointState. + +## Collection + +The tool gathers the same identity, hardware, and SBOM subset the endpoint +agent collects, using identical WMI queries and wire keys so the server stores +it without translation: + +- Identity: `hostname`, `loggedinuser`, `ipaddress`. +- Hardware: `serialnumber` (Win32_BIOS), `vendorname` / `pctype` / `modelnumber` + (Win32_ComputerSystem), `osname` and `lastboottime` (Win32_OperatingSystem). +- SBOM: `applications` = both Uninstall registry hives (64-bit + WOW6432Node), + de-duped `{displayname, displayversion, publisher}`. The server logs this as + an unpersisted key today (same as the agent) until the SBOM store lands. + +The tool requires administrator rights (enforced by its app manifest +`requireAdministrator`, with a runtime elevation guard) because the machine +Uninstall hives and full WMI need elevation, or collection would be silently +partial. + +## Cherry-pick boundary + +The spike is deliberately isolated. Cherry-pick exactly: + +- `agent/ShopDbAdoptionTool/` (new standalone tool project, incl. `app.manifest`) +- `agent/ShopDbEndpointAgent.slnx` (one line adding the project) +- `plugins/endpoints/api/adoption.py` (new module; attaches to `endpoints_bp`) +- `plugins/endpoints/api/__init__.py` (one-line import to register the route) +- `plugins/endpoints/frontend/views/AgentKeySettings.vue` (new UI section only) +- `plugins/endpoints/ADOPTION-TOOL-SPIKE.md` (this file) + +`plugins/endpoints/api/routes.py` is intentionally untouched so the delivery +spike changes there do not tangle with this feature. + +## Prebuilt exe distribution + +The api container has no .NET SDK, so the package embeds the exe only when a +prebuilt copy is staged at `instance/adoptiontool/shopdb-adopt.exe`. Otherwise +the ZIP ships the config plus README (build the exe out of band, same model as +the endpoint agent installer bundle). Follow-up: a CI job that publishes +`shopdb-adopt.exe` and stages it into the image or instance path. + +## Known follow-ups + +- Agentless health signal: an adopted PC never reports again, so + `EndpointState.lastreportedat` reflects only the adoption moment. The fleet + health/dashboard logic should distinguish "agentless, adopted" from + "agent expected but silent" so these PCs do not read as permanently failed. + Left out of this spike to keep the server create-path unchanged for a clean + cherry-pick; the tool does not send an `agentless` marker yet because the + server does not persist one (`KNOWN_PAYLOAD_KEYS`). +- CI packaging of the prebuilt exe (see above). diff --git a/plugins/endpoints/api/__init__.py b/plugins/endpoints/api/__init__.py index 6ad50266..e4f289bc 100644 --- a/plugins/endpoints/api/__init__.py +++ b/plugins/endpoints/api/__init__.py @@ -1,5 +1,7 @@ """Endpoints plugin API.""" from .routes import endpoints_bp +# Attaches the adoption-tool packaging route to endpoints_bp (spike). +from . import adoption # noqa: F401 __all__ = ['endpoints_bp'] diff --git a/plugins/endpoints/api/adoption.py b/plugins/endpoints/api/adoption.py new file mode 100644 index 00000000..8e6e5967 --- /dev/null +++ b/plugins/endpoints/api/adoption.py @@ -0,0 +1,107 @@ +"""Endpoints adoption-tool packaging (SPIKE). + +This module is deliberately self-contained so it can be cherry-picked onto +main as a single unit. It attaches ONE admin-gated route to the existing +endpoints blueprint and never edits routes.py. + +The adoption tool is NOT the endpoint agent. It is a one-shot, agentless +enrollment client: given only the endpoints collector key and this server's +base URL, it creates a ShopDB PC (Computer) + Endpoint entry via the existing +POST /api/collector/endpoints adopt path, then exits. This route packages that +tool for download: a ZIP carrying the operator-supplied config, a README, and +(when a prebuilt binary is staged on the server) the shopdb-adopt.exe. + +The collector key is supplied by the admin and is NOT stored or logged here. +The minted secret is unretrievable after generation, so the operator pastes it +into the generator; it only lives inside the downloaded config file. +""" + +import io +import json +import os +import zipfile + +from flask import request, current_app, send_file + +from shopdb.api import error_response, ErrorCodes, require_role + +from .routes import endpoints_bp + +# A staged, prebuilt shopdb-adopt.exe is embedded when present. The container +# has no .NET SDK, so this mirrors the endpoint agent installer model: build the +# exe out of band, drop it under instance/adoptiontool/, and it ships in the ZIP. +STAGED_EXE_RELPATH = os.path.join('adoptiontool', 'shopdb-adopt.exe') + +_README = """ShopDB adoption tool (spike) + +WHAT THIS IS + A one-shot, agentless enrollment tool. Run it once on a PC and it creates a + ShopDB PC and Endpoint entry using only the collector key in the config file + next to this exe. It collects identity, hardware, and installed-software (SBOM) + details, then exits. It installs nothing, runs no service, and sends no + ongoing telemetry. It is NOT the endpoint agent. + +FILES + shopdb-adopt.exe The tool (present only if the server had a prebuilt copy). + adopt.config.json The base URL and collector key this tool will use. + +USAGE + 1. Keep shopdb-adopt.exe and adopt.config.json in the same folder. + 2. Right-click shopdb-adopt.exe and choose Run as administrator. Elevation is + required so hardware and installed-software collection succeeds. + 3. It reports the machine to ShopDB and exits 0 on success. + + You can override config values with arguments: + shopdb-adopt.exe --baseurl https://shopdb.example --collectorkey KEY + +SECURITY + adopt.config.json contains a live collector key. Treat it as a secret. The + key is not recoverable from the ShopDB server after it was generated, so keep + this download safe or regenerate the key to revoke it. +""" + + +def _build_readme(): + return _README + + +@endpoints_bp.route('/adoptiontool/package', methods=['POST']) +@require_role('admin') +def package_adoption_tool(): + """Bundle the adoption tool as a downloadable ZIP. + + Body: {"baseurl": "...", "collectorkey": "..."}. Returns an in-memory ZIP + with adopt.config.json, README.txt, and the staged exe when available. The + collector key is embedded in the config only; it is never stored or logged. + """ + body = request.get_json(silent=True) or {} + baseurl = (body.get('baseurl') or '').strip() + collectorkey = (body.get('collectorkey') or '').strip() + + if not baseurl or not collectorkey: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'baseurl and collectorkey are required', + http_code=400) + + config = { + 'baseurl': baseurl.rstrip('/'), + 'collectorkey': collectorkey, + } + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as archive: + archive.writestr('adopt.config.json', + json.dumps(config, indent=2) + '\n') + archive.writestr('README.txt', _build_readme()) + + staged = os.path.join(current_app.instance_path, STAGED_EXE_RELPATH) + if os.path.isfile(staged): + archive.write(staged, 'shopdb-adopt.exe') + + buffer.seek(0) + return send_file( + buffer, + mimetype='application/zip', + as_attachment=True, + download_name='shopdb-adoption-tool.zip') diff --git a/plugins/endpoints/frontend/views/AgentKeySettings.vue b/plugins/endpoints/frontend/views/AgentKeySettings.vue index 8e67d7e5..f2aadb1c 100644 --- a/plugins/endpoints/frontend/views/AgentKeySettings.vue +++ b/plugins/endpoints/frontend/views/AgentKeySettings.vue @@ -138,6 +138,57 @@ + +
+

Adoption tool

+
+

+ Generate a one-shot, agentless enrollment tool. Run it once on a PC and + it creates a ShopDB PC and Endpoint entry using only the collector key - + no service, no polling, no ongoing telemetry. Use this for machines you + want inventoried without running the full endpoint agent. +

+

+ Paste a collector key below (generate one above first; the secret is + only shown once). The key is embedded in the downloaded config and is + not stored on the server. +

+ + + + + + + +
+ +
+

{{ adoptmessage }}

+

{{ adopterror }}

+
+
+ @@ -197,6 +248,13 @@ const copied = ref(false) const message = ref('') const error = ref('') +// Adoption tool (spike): build a one-shot, agentless enrollment download. +const adoptbaseurl = ref('') +const adoptcollectorkey = ref('') +const adoptbusy = ref(false) +const adoptmessage = ref('') +const adopterror = ref('') + function formatDate(value) { if (!value) return '' const parsed = new Date(value) @@ -209,6 +267,7 @@ function applyStatus(data) { } onMounted(async () => { + adoptbaseurl.value = window.location.origin try { const response = await api.get('/endpoints/agentkey') applyStatus(response.data.data || {}) @@ -238,6 +297,9 @@ async function regenerate() { const data = response.data.data || {} applyStatus(data) secret.value = data.secret || '' + if (data.secret) { + adoptcollectorkey.value = data.secret + } message.value = 'Endpoint agent key generated.' } catch (regenError) { error.value = regenError.response?.data?.data?.error?.message @@ -276,6 +338,34 @@ async function copySecret() { console.error(copyError) } } + +async function downloadAdoptionTool() { + adoptbusy.value = true + adoptmessage.value = '' + adopterror.value = '' + try { + const response = await api.post( + '/endpoints/adoptiontool/package', + { baseurl: adoptbaseurl.value, collectorkey: adoptcollectorkey.value }, + { responseType: 'blob' }) + const url = window.URL.createObjectURL(response.data) + const link = document.createElement('a') + link.href = url + link.download = 'shopdb-adoption-tool.zip' + document.body.appendChild(link) + link.click() + link.remove() + window.URL.revokeObjectURL(url) + adoptmessage.value = 'Adoption tool downloaded.' + } catch (downloadError) { + adopterror.value = downloadError.response?.status === 403 + ? 'Only an administrator can build the adoption tool' + : 'Could not build the adoption tool' + console.error(downloadError) + } finally { + adoptbusy.value = false + } +}