- Loader 1.3 downloads into a protected run folder of its own
(%ProgramData%\AutopilotMonitor-Loader-<8 hex>), runs the installer only when it
is signed by the pinned publisher and declares the installer role and version
2.7 or later, removes the folder after a successful run and never touches the
agent folder. Unbound arguments now reach the installer by name; the array
splat bound them by position.
- Installer 2.7 refuses without elevation and decides before it writes: guards
read HKLM through the 64-bit view (a 32-bit host never saw the deployment
marker), guard 5 is covered by the agent check. An agent's folder stays as it
is, a skip without agent removes the folder, an install starts from an emptied,
protected folder and stages the ZIP in a run folder. It moves the MSI's working
directory out, writes stderr only on failure and removes the copy older loaders
left at the fixed path.
- Help texts of both scripts state what customers need; .CHANGELOG, which made
Get-Help ignore the help, gives way to the Platform Changelog.
- OOBE go-script: the installer's shared helpers, its own elevation check
(#Requires is ignored under irm | iex), a run folder.
- Publish: version read line-anchored, the loader's own check on the exact
publish bytes, installers before loaders, -Channel dev (dispatch input).
- Bootstrap MSI 1.2.0; Test-ShouldBootstrapAgent reads the 64-bit view;
Uninstall removes loader run folders and keeps the Deployed marker.
- Tests: Pester unit suites, an integration harness running the real scripts
(loader 1.2 and the MSI 1.0 runner frozen in TestData), BootstrapContractTests;
the gate runs on windows-2022 and windows-2025, once more as SYSTEM, and against
a real standard user.
[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The signup list under the chart now links every domain to
/admin/tenants/management?tenantId=…, the deep link the ops events
"View tenant" button already uses, so the list opens narrowed to that tenant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The backend repeats AdminAction on every telemetry response while the session
row carries it. The agent handled each echo anew: a new admin_marked_session
with an immediate upload, whose response carried the next echo. With a running
agent on a Mark-Succeeded session that looped at the device rate limit until
the agent ended; Mark-Failed ended the agent and stayed bounded.
- ServerControlPlane: AdminActionLatch admits the first AdminAction of a
process; every later one only logs at Debug.
- AdminAction=Succeeded runs the agent's own termination pipeline with outcome
Succeeded, like Failed: what the portal's confirmation dialog and the docs
promise, and what the restart path already did.
- HandleAdminActionTests: Succeeded terminates; repeated echoes, a later
different outcome and concurrent callers are handled once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the public host a telemetry initializer adds the referrer's host and
the utm_source, utm_medium and utm_campaign tags of the entry URL to every
page view and custom event. Both come from the document itself
(document.referrer and the navigation entry), so nothing is stored and no
identifier is added; metrics, dependencies and the portal stay as they are.
The hero and final get-started buttons and the three Pro plan links now
navigate client-side, so landing, plans, get-started and the sign-in click
stay one page load. A guard fails on a plain <a> to /get-started or /plans.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tls= was empty on every report: .NET Framework's SecureChannelFailure
carries no inner Win32 code. After a terminal SecureChannelFailure the
agent now opens one direct SslStream connection to the backend host with
the client certificate (system-default protocols, normal server
validation, never a proxy) and sends one GET of the agent config with the
agent's headers - the platform asks for the certificate only once it has
seen the path. The message carries tlsp=<protocol>,<m|->,<answer>:
b<status> only when the probe's own correlation id comes back (shared
ResponseOriginClassifier), cc<status> for the platform's "Client
Certificate Required" page, st/x/io otherwise; or unreachable,
hs(<SSPI code>), hs(srvcert), timeout, error. A server certificate not
issued by Microsoft appends ,i=<issuer CN> (TLS inspection).
The probe runs beside the TPM and Schannel reads, all three within 10 s
(4 s per network step), on the failure path only. The Schannel window now
ends when the failure is detected (ReadBetween), so the probe's own
handshake never shows up in sch=. A connect that fails after its timeout
is observed (an unobserved task exception would write crash artefacts);
an answer that began is kept when the server does not close.
Live, .NET Framework 4.8 against production without a certificate:
tls12,-,cc403.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first reports with context all carried OobeState=completed: Windows'
OOBE flag flips at the desktop, was read at send time after minutes of
retries, and marks the later successful starts too, so it separated
nothing.
Agent: EspPhaseSnapshot reads the ESP registry once with the tracker's
own parser (device_preparation / device_setup / account_setup while
running, <category>_failed, *_done, esp_done, none, unavailable).
DistressDeviceContext (OOBE flag, ESP phase, boot from the uptime
counter) is captured before a registration's first attempt and travels
with the TPM-PSS report; other reports sample it at send time. The
reporter takes a DistressDetail (TPM version, context). sch= is always
set: the events, none, unreadable or timeout. The failed-run record keeps
the ESP phase and OOBE flag, and beside it - never on the wire - the boot
time and the client-certificate thumbprint; the next successful start's
agent_started reports priorRegistrationLastEspPhase, LastOobeState,
RebootedSince (boots more than 2 min apart) and CertChanged, and every
start reports espPhaseAtAgentStart.
Shared/backend: DistressReport.EspPhase (kept as [a-z_] up to 32) and
DeviceBootUtc (kept within 30 days before and 5 minutes after the report),
stored, logged and on the custom event; the TPM panel item carries the ESP
phase. The GA distress detail names the OOBE flag as such and shows ESP
phase and device boot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes Dependabot alerts #123 and #124 (GHSA-698v-4r3x-3vcv, launch-editor
escapeCmdArgs() omits % escaping on Windows). Lockfile-only: vite is a
transitive dev dependency of vitest, whose range already allows 8.3.x.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The code-signing identity trusts only environment:release now, so build-agent
release, build-msi sign-loader and release, and publish-scripts publish run in it.
Every job that federates to Azure carries an environment; the ref is no longer a
trusted subject. Help texts of the storage and endpoint helpers follow: the local
build publishes nothing since D-354.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A TpmPssUnsupported report classifies the registration start that failed,
not the device: devices with older TPM firmware fail during setup and
connect after it, on 24H2, 25H2 and 26H2 alike.
Agent: every distress report carries the OS build and the Windows setup
state at send time; the TPM-PSS report adds the TPM manufacturer and
firmware and a language-neutral message with the NCrypt codes of the PSS
signs, the native code of the failed handshake and up to two Schannel
System-log events since the registration started (numeric properties
only). The TPM and System-log reads run in parallel for at most 5 s. The
send is awaited, since the run exits right after it. An attempt that got
an answer resets the last exception, so the probe never classifies an
earlier attempt. The run's last error carries the native code. Win32_Tpm
is read by one shared TpmInfoReader (tpm_status unchanged).
Shared/backend: one body cap for both sides (DistressReportMaxBytes 2048);
the agent shortens its message to stay within it. The endpoint keeps OS
build and setup state only in their format, stores the three fields and
adds them to the log and the custom event. GET audit/tpm-pss-unsupported
returns TPM version, OS build, setup state and connectedAt: the last
upload of a certificate-authenticated session of the serial after the
device's last report (bootstrap sessions excluded, six serials per
SessionsIndex query). The bell says the device could not connect and may
connect after setup and a restart.
Web: the Incompatible TPM panel shows TPM and Status columns, uses the
generated types and no longer says "never" or names a Windows version; the
GA distress detail shows the new fields, Cloud PC and the certificate.
Tests: probe message, Schannel tokens, native codes, payload cap, awaited
send, context bound, last-exception reset, connectedAt rule, filter
comparison limit, repository round trip, panel render. A device serial
from production data in a test fixture is replaced.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Tenant Admins add the Push channel; Telegram and e-mail stay Global-Admin-only
- One Push channel per scope on the tenant and ops paths; the dispatcher fans Push out once per send
- The channel also gates session-watch completions and the device test; paired devices stay listed while it is off
- Members whose role comes from an Entra app role pair and receive like table members; a paused device resumes at the owner's next sign-in
- SLA and failure-streak alerts name the tenant; the id moves to a Tenant ID fact
- The legacy single webhook accepts URL webhook providers only
- Portal: Conditional Access hint in the channel editor, neutral role texts
- Landing: push section with the Notify me when done animation and the hero announcement; SignalR is "live updates"
- Privacy, about and FAQ describe push notifications
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Measured in production with 300 ms latency, router.prefetch loaded only the route tree while auth/me was in flight, and the replace still fetched the route payload after auth/me: no gain on the critical path, one to three extra requests per sign-in return.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- MSAL's in-flight redirect state stays in its tab. storeAuthStateInCookie mirrored it into origin-wide cookies that MSAL reads first, so two tabs signing in at once overwrote each other and the first one failed with state_mismatch, stuck on the landing. A guard test pins the cache config.
- AuthGate keeps the auth-pending overlay for as long as the browser is signed in, so it lifts only once the target route has committed; it used to lift when loading ended and exposed the landing for the whole client navigation. The target's route payload is prefetched while auth/me is in flight.
- A sign-in return that carried a code but ends without an account starts one fresh sign-in from "/", keeping the saved deep link; any further failure in the tab hands over to ProtectedRoute's sign-in card on the deep link, which waits for a click. The auth response is read at module load (the router writes its hydration-time URL back), the tab's temporary MSAL keys are purged before a retry and before the card's sign-in, and loading holds while a redirect leaves the page. New events: auth_redirect_recovered, auth_redirect_recovery_exhausted, auth_redirect_failed (error codes only).
- host.json adds Access-Control-Max-Age: 7200 through the http customHeaders. The Functions host answers every preflight itself and its CORS policy carries no max-age; its custom-headers middleware runs outside the CORS middleware, so the header lands on the preflight too. A guard test pins the entry to exactly that header. The CORS startup log goes: it read settings this host does not use, at a level that never reached the telemetry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The section reads the tenant configurations from config/all and derives everything in the browser: no endpoint, no counter, no backfill.
- Customers: new tenants per UTC day (per Monday-anchored week beyond 90 days), the window's best day, weekly average, the last 7 days, the total and the cumulative gain; 7/30/90/365 days in ?days=; clicking a column lists that bucket's tenants with their edition. A signup is a configuration row with a domain. The section is the first Metrics entry and the target of /admin/metrics.
- Charts: AppBarChart takes a category formatter, a custom tooltip, a column click and whole-number ticks; AppLineChart can hide its legend and use whole-number ticks. Existing callers get the same props as before.
- The Monday-of-week helper moves from the Fleet Health timeline into lib/timeWindow; the Stat tile duplicated in two metrics sections becomes one component, and the duplicate baseline loses that entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
None of them had a production caller. DelegatedAdminService.GetAssignmentsForUpnAsync and GetAssigneesForTenantAsync, DelegatedAdminService.SetStatusAsync with IAdminRepository.SetDelegatedAdminStatusAsync (only a test called it; the operator revoke is RemoveAsync), and IAdminRepository.GetDelegatedAdminAsync and IsMcpUserAsync, which only had their own implementation.
Revoke_InvalidatesCache now pins the revoke the operator route runs (RemoveAsync); Disable_InvalidatesCache covers SetEnabledAsync.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removing or disabling a member under Access Management left their assignment to the tenant's self-service group in place, and the delegated scope never re-checked the membership, so the person kept reading every managed customer.
- Scope: an assignment to msp-{owner} confers the group's tenants only while the assignee holds an enabled TenantAdmins row in the owner (inside the cached row resolution, fail-closed, Warning when ignored). Operator groups and direct grants are unchanged.
- Access Management: remove and disable end the self-service assignment (DelegatedGroupAccess DELETE under every managed tenant, reason member-removed / member-disabled) before the existing stream cut. Re-enabling restores nothing.
- Assigning requires an enabled member and re-reads the membership after the write; the Global Admin group route follows the same rule for a self-service group (enabled member of the owner, bound to it, read-only).
- The SignalR group join resolves the delegated scope uncached, so the re-join right after a revoke cannot be admitted by another instance's warm cache.
- Web: Delegated Access offers only enabled members and says that removing or disabling a member ends their access; the Global Admin page hides the role select for self-service groups.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ErrorCodeCatalog.TryLookupDetailed normalised any 0x-prefixed value without
checking its characters and then ran int.Parse on the low word, so a value
such as 0x8007zzzz threw FormatException. The backend enricher runs that
lookup over device-authored event data on every GET /api/sessions/{id}/events,
so one such event answered HTTP 500 for every reader of the session timeline.
- The catalog parses the hex body once with uint.TryParse (hex digits only)
and derives the HRESULT_FROM_WIN32 low word from the number; it now returns
null for any input instead of throwing.
- ErrorCodeEnricher.EnrichEvents isolates each event: a failed lookup leaves
that event unenriched, logs a warning and the page still returns 200.
- Web errorCodeMap.ts gets the same hex-only check: parseInt read
0x8007643z as 1603 and labelled the garbage ERROR_INSTALL_FAILURE.
MCP already validated; a test pins it.
Tests: the reported shapes return null, an exhaustive sweep of short tails
after every prefix never throws, and the enricher keeps the rest of the page
enriched when one event fails. Against the previous catalog 10 of the new
tests fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five platform reads take ?tenantId= only as a filter, but their QueryParam
scoping also admitted a delegated (MSP) reader of the named tenant:
global/session-reports, global/rule-submissions, global/metrics/mcp-usage,
global/metrics/mcp-usage/daily and global/metrics/verdict-calibration. They
carry operator data (admin notes, contact data and reviews, an organization's
accounts, classifier diagnostics), so they are catalogued None now; the
handlers still read ?tenantId= themselves, and Global Admins and Global
Readers keep the filter.
- A catalog ratchet in PolicyEnforcementMiddlewareTests lists every platform
route a delegated caller reaches; a new one fails the build until it is
added on purpose. The TenantScoping.QueryParam doc states the rule.
- The middleware took the cross-tenant decision on the first ?tenantId=
value while the handlers read all values comma-joined. It now decides on
the joined string, with ASP.NET Core's parser (the handlers parse Url.Query,
where System.Uri has already escaped a stray '%').
- The rule pages show a delegated reader the admin-only hint instead of the
submission list, as they already did for an Operator or Viewer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>