- 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>
- Install-AutopilotMonitor.ps1 v2.6: Get-AgentPayloadRejections (Test-FileCatalog
-Detailed; signature Valid, signer -like the pin, files exactly as catalogued) and
Install-VerifiedAgentPayload (extract into Agent-Install, verify there, then move into
the agent folder; a rejection deletes the staging folder, so the next run starts clean).
Replaces the name-filtered Get-UnverifiedAgentBinaries; the dead "already installed"
branch behind guard 5 is gone.
- OOBE go-script: embeds the installer's marked block verbatim (EmbeddedResource) and
verifies before --install; pin and catalog name come from the Shared constants; the
golden is updated, new tests pin the embedding and the order check-before-install.
- build-agent.yml: the release verify step dot-sources the installer and runs its own
function on the final ZIP.
- Pester: every decision branch, a real unsigned catalog, the staging flow (64/64).
- Privacy page: the catalog claim; LAST_UPDATED.
Merge only after a catalog ZIP is live on stable (plan step V0): pushing this to main
publishes the installer to every loader on its next run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The option is a regular tenant-admin toggle in its own "Without pre-registration" block, with the
add-on permission state read from the existing graph-permissions status endpoint. The dashboard
banner and the admin Ready badge key on "any validation method enabled" (feature flag
deviceValidationEnabled, manifest-driven validate* fields) instead of Autopilot alone, so a
device-preparation tenant that validates by device association is no longer told that agent
ingestion is blocked. Stale "shadow mode" wording for Device Association is gone, the
Not-registered report names every method, sessions show "Intune Enrollment", and the privacy
page and FAQ list the new method. Grant script help describes the IntuneDeviceBinding feature
as Intune Enrollment Validation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The SHA-256 check proves the ZIP is the one version.json names, but that
manifest lives in the same container as the ZIP: it cannot outlive a
compromise of the download host. The expected publisher travels inside this
script, which the loader verified before running it, so the chain from the
file an administrator assigns to the process that starts is publisher-anchored
end to end.
Install-AutopilotMonitor.ps1 v2.4 -> v2.5:
- Get-UnverifiedAgentBinaries checks every AutopilotMonitor.*.exe/.dll in the
extracted payload for status Valid and the same subject pin the loader
carries; the gate runs immediately before --install and throws on any
rejection, exactly like the hash check.
- Third-party assemblies keep their own publisher's signature and are not
checked: System.Management.Automation.dll ships unsigned, so a gate over all
files would refuse the live payload.
- An extracted folder with no binary of ours is a rejection, not a pass.
- The SHA-256 manifest check is unchanged and still runs first: it proves the
bytes, the signature proves the author.
Verified against the live payload 2.0.1454 (all five of our PEs Valid and
glueckkanja AG, gate passes); appending one byte to AutopilotMonitor.Shared.dll
makes it reject. Pester 55/55.
The portal privacy and about pages carried the pre-signing wording and are
corrected in the same change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The full subject of an EV certificate is a line of street address and registration
numbers and pushed the useful part of the log line off the screen. The CN answers the
question the log has to answer. The full subject is still logged in the one case where it
is the finding itself: a valid signature from a publisher that is not us.
Loader 1.1 -> 1.2; a Pester case pins the short form. Suite on PS 5.1: 45/45.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Invoke-BootstrapDownload.ps1 and Start-AutopilotMonitor.ps1 had become the same script:
download the current installer, run it, log, always exit 0. The MSI copy additionally
downloaded and executed WITHOUT verifying what it got, which is exactly the gap the loader
was written to close. The MSI is now packaging only -- it installs and runs the same file
that is assigned directly in Intune, and both delivery channels get publisher verification
from one implementation.
Dropped from the MSI path: its own Deployed-marker check. That guard lives in stage 2 and
runs there anyway, so the only cost is one download on a device that is already deployed.
Kept: the log file name. bootstrap-msi.log is what the diagnostics tooling keys on to tell
the MDM-LOB channel apart from the Intune script channel, so the loader takes -LogFileName
(default bootstrap-loader.log) and the custom action passes bootstrap-msi.log. A Pester
test now pins that contract across the two files -- renaming the parameter on either side
would leave the MSI channel logging under the wrong name.
Package version 1.0.2 -> 1.1.0 so Intune LOB update detection sees the new payload, and a
new component GUID because the key path file changed.
Loader 1.0 -> 1.1. Bootstrap suite on Windows PowerShell 5.1: 44/44.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/ at the repo root is the customer surface: everything under
Bootstrap/ and CustomerSetup/ is published to the download container by
Publish-BootstrapScripts.ps1, Authenticode-signed and version-gated. The
error-code generator is a maintainer tool no workflow runs, so it diluted
that invariant. .github/scripts/ was not the alternative -- it holds only
helpers a workflow step invokes, and unlike guardrails-in-sync and
shared-manifests-in-sync the catalog has no CI drift gate (the generator
needs IME_ENUMS_JSON from operator tooling and live MS Learn fetches).
REPO_ROOT stays ../.. at the new depth, so only the documented path moved:
the generator header and README, the C# and TypeScript comments, the test
summary, and the description header of error-codes.json together with the
generator literal that emits it (the two must stay identical or the next
regeneration diffs). The web sync re-ran clean at 607 entries with no
change to utils/error-codes.json, which drops the header.
Start-AutopilotMonitor.ps1 is the new stage 1 -- the file an administrator assigns in
Intune. It carries no enrollment logic: it downloads the published installer, verifies its
Authenticode signature, and runs it in the same process, passing through every remaining
argument. Assigning the installer directly stays supported and unchanged.
The point of the split is that the customer-held artifact stops changing. Today every
bootstrap change forces customers to replace their Intune copy, so the fleet runs a
distribution of script versions we do not control. It is also what makes script signing
worth doing: signing a file that gets swapped by hand each release is a one-off gain,
signing a loader that never changes is permanent.
The loader is deliberately minimal, because it is the one piece that cannot be fixed
remotely -- every guard placed in it would be frozen for years. All guards stay in stage 2.
Verification pins the certificate SUBJECT, never a thumbprint: certificates get renewed and
a deployed loader cannot be, so a thumbprint pin would strand the fleet at the next
renewal. A hash oracle was rejected for a different reason -- it would have to be fetched
from the same host as the script, so it could not survive a compromise of that host, while
the expected publisher travels inside the loader.
Fail-closed on verification, fail-soft on exit: nothing runs without a valid signature from
us (running unverified code as SYSTEM is the risk being removed here), but the loader still
exits 0, because a device without monitoring is acceptable and a failed enrollment step is
not. The accepted cost is that a device which can never fetch or verify stays silent.
Publisher changes:
- every published script is signed (loader, installer, both -Dev renders,
Test-ShouldBootstrapAgent, Grant-AutopilotMonitorAddOn), and signing is the LAST
transformation: it covers exactly the CRLF/UTF-8 bytes Get-PublishBytes produced, so
nothing may normalise or re-encode them afterwards.
- signing is mandatory for a real publish. Only -DryRun may run unsigned, because the PR
gate executes the publisher without any Azure login.
- comparisons against a published copy strip the signature block first. A timestamped
signature differs on every run, so without stripping the version-bump guard and the
drift check would report a change for every unchanged script.
- the bump guard became Assert-VersionBump and now covers the loader as well.
- version.json gains loaderVersion. Additive, no consumer yet: stage 2 is current by
construction, so only stage 1's version says anything about a customer's Intune copy.
- -SignedOutputDir writes the exact uploaded bytes for the CI artifact, in a dry run too --
that is how a signed script gets inspected before it reaches anyone.
publish-scripts.yml logs in with the signing identity first and only to fetch the Key Vault
token (azure/login sets the default az account and the storage login has to be the one that
stays), then publishes and offers the signed scripts as an artifact. build-agent.yml passes
the same parameters on a stable cutover.
Tests: 14 new Pester cases, including that a validly signed script from a FOREIGN publisher
is refused -- that is what an attacker with any code-signing certificate can produce. Full
bootstrap suite on Windows PowerShell 5.1: 43/43.
Not covered: the MSI runner still downloads stage 2 without verifying it, and the customer
docs still describe the direct assignment only.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tenant binding (stage 1) proves the client certificate was issued to this
tenant. It does not prove the certificate belongs to a device the tenant
still has: a cert lifted from a decommissioned machine keeps working.
IntuneDeviceBindingValidator resolves the Intune managedDevice id from the
certificate's Subject CN against the tenant's own managedDevices inventory.
Point-GET on managedDevices/{id} rather than an OData filter, so there is no
filter literal to inject into and a 404 is unambiguous. Cache/retry/transient
contract mirrors CloudPcDeviceValidator.
Shadow only, and visible only to Global Admins (same gate as the DevPrep
association preview) because the open question is a race, not an attack: a
device object can appear after the agent's first call. Every result carries
enrolledDateTime and the log line records enrolledAgeSeconds, so a device
created seconds before the request is distinguishable from a foreign cert.
Grep marker: CERT-DEVICE-BINDING-SHADOW.
The lookup is fire-and-forget like the DevPrep shadow so a cold Graph
round-trip never sits in the agent request path. That rules out stage 1's
request-row dimension (the row is written before the task finishes), hence
the Warning log; widening beyond preview should move to inline + dimension.
Needs DeviceManagementManagedDevices.Read.All, granted per tenant via the
customer script as feature IntuneDeviceBinding. The script's catalog,
ValidateSet and help block stay pinned to GraphFeatureCatalog by
GraphFeatureCatalogSyncTests (extended with the help-block case).
Build 0 warnings, backend 4629/4629, web 874/874, tsc clean, eslint 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generated PowerShell command now uses the readable -Features form
(first catalog feature as the example) instead of raw permission strings;
a note below lists all available values incl. the All meta-feature and
how to fall back to -Permissions via Copy permissions. The section links
to the Optional Graph Permissions customer docs (via DOCS_URL registry),
and the "What this script does" bullet reflects the Cloud Shell
ambient-identity sign-in. Script help no longer claims the admin UI
generates the -Permissions form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All is a script-side convenience that expands to every GraphFeatureCatalog
feature (works with -Revoke too). The catalog sync test now pins All in the
ValidateSet while keeping it out of the catalog comparison, and the admin UI
mentions the shorthand next to the generated grant command.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Try Connect-MgGraph -Identity first (Azure Cloud Shell issues a delegated
token for the signed-in admin, avoiding the device-code flow that
Conditional Access commonly blocks), probe Graph readability, and fall
back to interactive sign-in with explicit scopes otherwise. The
AppRoleAssignment.ReadWrite.All scope guard now applies only to the
interactive path, since ambient-identity tokens carry a fixed scope set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The agent log directory mixed three conventions (agent_20260817.log,
bootstrap_agent.log, msi-bootstrap.log) while everything else in the
diagnostics ZIP is already kebab-case. All log files now follow
<component>-<content>.log:
agent.log (was agent_YYYYMMDD.log; segments agent-NNN.log)
bootstrap-script.log (was bootstrap_agent.log; script v2.4)
bootstrap-msi.log (was msi-bootstrap.log)
crash-<utc>.log (was crash_<utc>.log)
gather-rules-debug.log (was gather_rules_debug.log)
ime-pattern-matches.log (was ime_pattern_matches.log)
The date is dropped from the main log: the agent only lives for one
enrollment, and date rotation split the chronology at local midnight -
which can fall mid-enrollment while the clock is still on the OOBE
default timezone. The existing 50MB size rotation stays as the growth
bound. Legacy dated files are ignored by the rotation probe; crash
detection still picks up legacy crash_*.log once so an in-place
self-update cannot lose a pending crash classification.
Diag collectors glob *.log and need no change. MCP zip-map and docs
note the legacy names for archives from pre-rename agents. Settings UI
hint paths updated. MSI rebuild + bootstrap script publish (v2.4) +
MCP redeploy required to ship.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Install-AutopilotMonitor.ps1 takes over the field-tested v2.3-dev.3 logic
unchanged (code-only diff vs dev: ScriptVersion + log file name); comments
condensed to customer-facing descriptions, dev changelog collapsed into one
v2.3 entry. Pester suite moved to Install-AutopilotMonitor.Tests.ps1 (neutral
test user names), dev script and dev suite removed. Test-ShouldBootstrapAgent
dry-run tester mirrors the Cloud PC trigger and the guard-4 uptime exemption.
Pester 29/29 green under PS 5.1 and pwsh 7; parse + pure-ASCII checks pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs
Windows 365 Cloud PCs are never Autopilot-registered, so the serial lookup
(and the Corporate Identifier fallback) structurally reject them and Cloud PC
first-connect enrollment could not be monitored. New stage-3 fallback in the
device-validation chain: extract the Intune device id from the chain-validated
MDM client certificate's Subject CN (field-verified: CN == managedDevice id ==
cloudPC.managedDeviceId) and require a matching cloudPC object in the claimed
tenant (Graph v1.0 virtualEndpoint/cloudPCs, $filter=managedDeviceId eq).
Only service-provisioned Cloud PCs have such an object — a regular enrolled
non-Autopilot device still ends in the 403, and local W365 markers are never
consulted for auth.
- Shared: ValidatorType.CloudPc = 5, TenantConfiguration.ValidateCloudPcDevice
(default false, tenant-admin writable), GraphAppPermissions.CloudPCReadAll,
GraphFeatureCatalog feature W365CloudPcValidation -> CloudPC.Read.All
- Backend: CloudPcDeviceValidator (AutopilotDeviceValidator sibling contract:
30/5 min cache, 2 attempts, transient->503; GUID gate before the OData
filter; exact-match guard; Graph 403 = definitive "grant missing", not
transient), SecurityValidator CN extraction + fallback stage + hard gate,
wiring through the 5 agent endpoints, TableConfigRepository persistence
- Consent script: W365CloudPcValidation in catalog/ValidateSet/help; NEW
GraphFeatureCatalogSyncTests pins C# catalog <-> grant script (previously
comment-only lock-step)
- Bootstrap dev script v2.3-dev.3: guard 4 (uptime window) exempted while the
Cloud-PC relax is active — Cloud PCs run headless for days before first
connect; the fresh-profile window is the time anchor there (+ Pester tests)
- Web: validation toggle + context plumbing, ValidatedBy "CloudPc" label,
privacy-page validation claim generalized (Last updated bumped)
- Docs: OKF concept doc backend/cloudpc-device-validation.md + index + log
Verified: web tsc clean, ESLint 0 errors (99 pre-existing warnings = ratchet
cap, none added). .NET build/tests and Pester run in CI — SDK download and
pwsh are unavailable in this sandbox's network policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv
* w365: agent-side IsCloudPc flag + engine expectation "no Device-ESP phase"
Companion to the CloudPc validator: the agent now classifies Windows 365
Cloud PCs locally with the bootstrap's field-verified marker AND
(HKLM\SOFTWARE\Microsoft\Windows365 key + installed
CloudManagedDesktopExtension service, probed via its Services registry key
so net48 needs no System.ServiceProcess). SKIP-safe: any error resolves
false. Never an auth input - backend admission stays exclusively the
cert-CN-bound Graph lookup (ValidatedBy=CloudPc); the two signals are
independent and may disagree.
Two transport rails:
- Session metadata: SessionRegistration.IsCloudPc -> Sessions table
(sticky-true OR across re-registrations, IsSelfDeployingProfile pattern)
-> SessionsIndex full mirror -> SessionSummary -> new isCloudPc search
filter end to end (SearchSessions + QueryRawSessions + pagination
fingerprint + all three in-memory predicates; MCP search_sessions /
query_raw_sessions params + summary projection; web type, Cloud PC pill,
SessionInfoCard enrollment label, CSV export appended column).
- Decision engine: isCloudPc rides the EnrollmentFactsObserved payload,
recorded set-once for BOTH values in EnrollmentScenarioObservations.CloudPc
and annotates the scenario profile reason
cloud_pc_first_connect:no_device_esp_expected - the engine-recorded
expectation that Device-ESP already ran headless at provisioning and the
session starts at Account Setup. Deliberately reason+observation only:
no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-
classified (Classic), EspConfig keeps configured-FirstSync semantics.
Positive marker surfaces as cloud_pc_marker in the audit-trail census.
Updater idempotency: the cloudPcAnnotates gate keeps the generic v1
reason branch from re-claiming the slot on repeat posts (would bump
EvidenceOrdinal every post). Additive-nullable observation - no state
schema bump (v4 convention), serializer roundtrip pinned.
Tests: CloudPcDetector resolver (marker AND, throwing probe), registration
body, facts-signal payload shape, updater annotation/idempotency/
self-deploying interplay, reducer observation both values + census fold,
serializer roundtrip, index projection superset, sticky-true both
directions. Suites: DecisionCore 500, Functions 3941, Agent V2 2154,
vitest 632 - all green; tsc web+MCP clean; ESLint 0 errors (cap 99).
Docs: cloudpc-device-validation.md follow-up section replaced with the
shipped mechanism + log entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
First W365 field test (2026-08-06) proved the assumed WMI signature wrong:
a real Cloud PC reports the generic Manufacturer='Microsoft Corporation' +
Model='Virtual Machine'; the 'Cloud PC Enterprise ...' string exists only in
Intune/Graph inventory. Probe on the live Cloud PC captured two local
markers instead: HKLM\SOFTWARE\Microsoft\Windows365 and the
CloudManagedDesktopExtension service (W365 management agent).
Get-CloudPcModel is replaced by Test-IsCloudPc requiring BOTH markers (each
alone has look-alikes: W365-Boot physical clients / legacy managed-desktop
agents). Raw identity and both marker states are always logged. Tests
updated to the marker model (26 tests, green under PS 5.1 and pwsh 7).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First W365 field test failed: the log said only "CloudPC: False" with no way
to tell whether Win32_ComputerSystem reported unexpected values or the query
failed. Get-CloudPcModel now logs the raw identity on every run and the
error message on failure. Detection pattern itself unchanged until the
probe from the real Cloud PC delivers evidence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Refactor Install-AutopilotMonitor-dev.ps1 (v2.3-dev) into functions
(Get-CloudPcModel, Get-RealUserProfilePaths, Get-RelaxDecision,
Get-BootstrapDecision with reason codes) with an Invoke-Bootstrap main and
a dot-source entry guard, so the Pester suite can load the logic without
executing the bootstrap. Behaviour when run directly is unchanged.
Fixes a refactoring leak found by smoke-run: Write-Log used Write-Output,
which turned function return values into arrays and swallowed console
output; now Write-Host, guarded by a dedicated logging-purity test.
Add Install-AutopilotMonitor-dev.Tests.ps1 (25 tests: Cloud PC detection,
relax scenarios incl. the W365 field case, guards 1-5, profile collection
with WMI failure path) and .github/workflows/pester.yml running the suite
under Windows PowerShell 5.1 (IME runtime) plus an ASCII guard over all
bootstrap scripts. Tests pass 25/25 under PS 5.1 and pwsh 7.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stable Install-AutopilotMonitor.ps1 is now the previously field-tested
-dev variant (v2.2): agent download via the Front Door alias
https://download.autopilotmonitor.com/agent/ instead of the legacy blob
host, mandatory SHA-256 manifest verification (Content-MD5 fallback
removed), and the OOBE-relax exemption for the Windows Backup for
Organizations restore case. Test-ShouldBootstrapAgent.ps1 mirrors the
same guard logic; the separate -dev.ps1 files are gone.
CI dev-render anchor literals in build-agent.yml updated to the alias
URLs (stable + dev channel) so the literal-substitution check keeps
passing; local build.ps1 anchors updated too (gitignored).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The $profilePaths assignment wrapped only the inner subexpression in @(),
so the trailing Where/Select-Object -Unique pipeline unwrapped a single
result back to a scalar string. With exactly one user profile present,
$profilePaths[0] then indexed into the string and returned its first
char ('C'), making Get-Item fail with "Cannot find path 'C'". The OOBE
age check crashed and the Windows Backup for Organizations restore case
never relaxed guards 2+3.
Force an outer @() so $profilePaths is always an array. Also surface the
profile age and the exact failing condition in the SKIP/NOT-applied logs
for diagnosing future OOBE attempts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The script applies a lifecycle policy + container creation against a live
storage account using operator-specific parameters (StorageAccountName,
ResourceGroupName). It is not portable across environments and each
operator keeps their own copy, so it does not belong in the repo.
The file is preserved on disk (git rm --cached, not -f) so the local
operator setup keeps working — only the Git tracking is dropped, plus
the matching .gitignore entry so a future `git add scripts/Backup` is
silently ignored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the full backup pipeline that snapshots 15 critical tables (Admin/Tenant
config, rules, identities, audit, preview) into a logically-isolated blob
container, daily at 04:00 UTC and on operator demand. Restore endpoints follow
in PR2/PR3.
Architecture:
* Daily TimerTrigger + manual POST /api/global/backups/trigger (GA-only,
fail-hard producer). HTTP returns 202 + jobId on enqueue, 500 if the queue
write fails (and the BackupJobs row is rolled to Failed).
* BackgroundService queue worker (not QueueTrigger — matches the Analyze/
IndexReconcile pattern that avoids the Functions-host connection-binding
app-setting). Worker holds the maintenance lease for the whole run; service
is intentionally lease-unaware so it stays unit-testable.
* BlobBackupStore wraps the new 'critical-table-backups' container with a
shared maintenance-lease sentinel at '_lock/maintenance.lock' that
serialises every backup or restore op across worker/timer/single-row paths.
* CriticalTableBackupService streams each table through CryptoStream(SHA256)
into per-table NDJSON blobs, then writes manifest.json LAST as the
durability anchor. Per-table failures degrade Outcome to Partial without
aborting; per-run budget (50 min) → Skipped status.
* BackupJobsRepository persists the six-state machine
(Queued/Running/Completed/Failed/Skipped/BlockedTerminal) with ETag-CAS
updates. Enum→string mapping rejects unknown values on read so refactors
surface loudly instead of silently shifting state.
* BackupJobWatchdog (30 min timer) heals stuck rows via a probe lease on the
shared sentinel: Queued > 60 min → Failed, Running with stale heartbeat
AND free lease → Failed (lease-busy means a live worker — leave alone).
* OpsEvents: distinct CriticalTableBackupPartial (Warning) vs Failed (Error)
so operators can wire softer Telegram rules for partial captures.
* MaintenanceService.MonitoredPoisonQueues + OPS_EVENT_TYPES catalog
registered (dual-register memory).
Test coverage (15 new tests, 1952/1952 backend xUnit green):
* BackupManifestJsonTests: enum-as-string (allowIntegerValues=false rejects
numeric values), DictionaryKeyPolicy unset (Azure column names preserved
case-faithfully), manifest field round-trip.
* BackupJobsRepositoryMappingTests: full entity↔domain round-trip, unknown
Kind/State/BackupOutcome rejected, legacy BackupOutcome="Failed" rejected.
* CriticalTableBackupServiceTests: happy/partial/empty/budget-exceeded paths
exercise the per-table loop with in-memory table + blob fakes.
Setup: scripts/Backup/setup-backup-container.ps1 — idempotent az-CLI bootstrap
for container + lifecycle rule (90 d delete on prefix). Soft-Delete and
Versioning are NOT applied automatically (storage-account-wide blast radius
— operator must validate per separate doc note).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds opt-in resolution of Intune Platform Script + Remediation display names in
session timelines via a tenant-side appRoleAssignment grant. No app manifest
change, no impact on customers who don't enable it. Architecture is reusable for
future optional Graph permissions: catalog -> detector -> resolver -> endpoint.
Backend
- GraphFeatureDetector parses the `roles` claim from the SP access token and
caches the (token, roles) bundle per tenant. GetSnapshotAsync surfaces a
transient flag so admin-UI callers can render "unknown" instead of a
misleading "0 of 0 granted" when token acquire times out.
- GraphTokenService now accepts a CancellationToken so optional-feature callers
can apply tight budgets instead of inheriting the 5+15+30 s retry chain.
- ScriptDisplayNameResolver: cache-first, list-pull-on-cold-tenant, per-ID
fallback. Throttle-aware abort: skip per-ID for kinds whose full-pull hit a
transient failure; break the per-ID loop on the first HttpRequestException.
Total budget 5 s, with a tight 1.5 s sub-budget on the token acquire.
- Azure Table `ScriptNameCache` + direct-TableClient fail-loud repository
(mirrors TableOffboardingAuditRepository pattern). Daily
ScriptNameCacheCleanupFunction: 7 d positive / 1 h NotFound TTL.
- POST /api/tenants/{id}/scripts/display-names (body-carried refs to dodge URL
length limits; backend cap 200 refs/request).
- GET /api/tenants/{id}/graph-permissions/status (4 s budget; includes app
ClientId so the UI can render an exact grant command).
- POST /api/tenants/{id}/graph-permissions/refresh (invalidates token cache).
- All three routes registered in EndpointAccessPolicyCatalog (MemberRead for the
resolve, TenantAdminOrGA for the admin-UI surfaces).
Web
- `useScriptDisplayNames` hook with stable refset fingerprint (no spurious
refetches on parent re-renders); clears stale map on tenant/refset change so
a session switch never leaks names across tenants.
- Chunked POST loop: 200-ref batches with partial-success semantics. Preserves
earlier chunks when later ones fail (non-2xx OR transport throw); only
TokenExpiredError propagates so auth flow can react.
- ScriptExecutions renders " . {DisplayName}" alongside existing label/short-id,
keyed by Kind + policyId so Platform/Remediation under the same id never
collide.
- New Settings section "Optional Graph capabilities": status table with granted
/ not granted / unknown, copy-paste PS one-liner with ClientId baked in,
refresh button gated on response.ok.
Customer-facing
- scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1: idempotent
grant / verify / revoke script. -Features ScriptDisplayNames (high level) or
-Permissions ... (admin-UI generated). Pure ASCII for PS 5.1 compat.
- docs/customer-graph-add-on-permissions.md: end-to-end customer guide
covering prerequisites, the appRoleAssignment pattern, and revocation.
Tests
- 1718 backend xUnit / 364 web vitest. Highlights: JWT roles parsing edge
cases; full-pull-429 suppresses per-ID; per-ID-429 breaks loop; transient
detection via GetSnapshotAsync; chunked partial success (HTTP failure + thrown
transport); TokenExpiredError propagation; EndpointPolicyCatalogCompleteness
covers the three new routes by reflection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces V1/V2-hardcoded build + bootstrap with a single parametric
pipeline that scales to V3+ without rewrites. Full architecture in
.claude/plans/v2-cutover.md.
Stable namespace (Bootstrap-side, never renamed): generic Bootstrap
script, agent owns its --install defaults so it works for any line.
Versioned namespace (per-line, SelfUpdater-side): isolated *-v{N}.*
blobs; SelfUpdater never crosses lines or touches stable.
Backend GetAgentConfig dispatches per X-Agent-Version-Major to
AdminConfiguration.GetAgentLine(int) -> LatestAgentV{N}* fields.
Future V3 = add field set + switch arm. TableConfigRepository.Map
falls back to legacy unsuffixed columns for transparent migration.
V1 SelfUpdater now reads version-v1.json (would have been clobbered
by the V2 cutover otherwise). V2 agent owns its own 600s TenantId-wait
default so the Bootstrap calls --install plain.
Tests: 3289 across 6 suites, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundled change covering three orthogonal pieces of work that all landed in
the same working tree before the next PR-style commit could be split out.
## WG Part-2 architectural cleanup (PR-A through PR-E)
Plan: ~/.claude/plans/wg-part2-architectural-cleanup.md.
The V2 agent carried a full separate "Part-2 apparatus" (4 special signal
kinds, 2 special stages, a special outcome, a special classifier, a 24h
safety deadline, a dedicated reducer file, adapter `part2Mode` switches,
a SessionRecovered bridge). This did not match OS reality: post-reseal
Part 2 is just a normal user-driven OOBE running through the Classic
flow. The apparatus also carried a real risk (24h false-positive
enrollment_failed mid-success) and was not even active in practice
(observed in session bdb3cf9d-541b-4c44-aebe-5b9e72743b0a, 2026-05-04).
V2 is now V1-symmetric:
PR-A — Orchestrator Resume Mechanic. EnrollmentOrchestrator detects a
persisted Stage=WhiteGloveSealed snapshot at Start(), archives the
reducer-state segments to state/.part1-<utc-ts>/, runs as a fresh
first-boot through the normal recovery branches, and posts a single
whiteglove_resumed InformationalEvent after the onIngressReady hook.
New helper class StateArchiver.cs + StateArchiverTests +
EnrollmentOrchestratorPart2EmissionTests.
PR-B — DecisionCore Cleanup. Removed:
- DecisionSignalKind.UserAadSignInComplete / HelloResolvedPart2 /
DesktopArrivedPart2 / AccountSetupCompletedPart2 / SessionRecovered
- SessionStage.WhiteGloveAwaitingUserSignIn / WhiteGloveCompletedPart2
- SessionOutcome.WhiteGlovePart2Complete
- DeadlineNames.WhiteGlovePart2Safety
- DecisionState's 4 Part-2 SignalFacts (+ builder + snapshot slots)
- WhiteGlovePart2CompletionClassifier + WhiteGlovePart2CompletionSnapshot
- DecisionEngine.WhiteGlovePart2.cs (entire reducer file)
- HandleSessionRecoveredV1 dispatcher + handler
PR-C — Adapter Cleanup. Dropped the inert `part2Mode` ctor parameter
from AadJoinWatcherAdapter, DesktopArrivalDetectorAdapter,
HelloTrackerAdapter, EspAndHelloTrackerAdapter (and their tests).
AadJoinHost is the only production caller using a named-arg form,
unaffected.
PR-D — Backend + Web. Removed `WhiteGloveCompletedPart2` cases from
TelemetryPayloadParser.IsTerminalStage and DecisionGraphBuilder
(IsTerminalStage + DeriveTerminalOutcome) plus their tests. Dropped
the dead `UserAadSignInComplete` constant from Shared.Constants.
Extracted pure helper computeWhiteGloveSplitSequence(events) in
app/sessions/[sessionId]/utils/eventHelpers.ts; useSessionDerivedData +
utils/sessionExportUtils now share it. New 6-case vitest covering
splitter behaviour (no marker, primary trigger via whiteglove_resumed,
agent_started fallback, Pending-only variants, primary-vs-fallback
race). PhaseTimeline + eventTypes were already on the new model.
PR-E — Tests + Fixtures Final Cleanup. Deleted the two
whiteglove-part2-*.jsonl fixtures + EdgeScenarioTests references;
removed dead `whiteglove_part2` case + test from SummaryDialog
OutcomeMapper; trimmed PR-B history breadcrumbs that still embedded
removed symbol names so the final grep is clean. Updated MEMORY
entries (project_v2_completion_location, project_wg_part2_simple_model,
project_wg_signal_validation_pending, project_bdb3cf9d_wg_followups,
MEMORY.md index).
What stayed: SessionStage.WhiteGloveSealed (Part-1 marker), the
`_isWhiteGlovePart2` Hint-Bool (V1-symmetric, plan §11) wired only into
EnrollmentTerminationHandler / SoftwareInventoryAnalyzer for shutdown-
analyzer phase=2 tagging, the `whiteglove_part1_complete` event-type +
`whiteglove_part1` outcome-string, `PausedForPart2` terminal-outcome
label for the Inspector, the `whiteglove_resumed` event as UI-splitter
trigger.
Final acceptance: grep for the removed symbol set in src/ excluding V1
(Agent.Core/) is empty. V2.Core.Tests 991 / DecisionCore.Tests 282 /
Functions.Tests 862 / SummaryDialog.Tests 44 / Web vitest 227 — all
green. Solution build clean.
## Resume-path correctness fixes (Codex review of the cleanup)
Three findings on the new resume mechanic:
P1a — AgentRuntimeHost was still skipping LifecycleEmitters
PostEnrollmentFactsObserved + PostSessionStarted on isWhiteGloveResume.
After PR-A's archive-and-reset the decision state is empty, so the
Classic flow needs both anchors (otherwise ScenarioProfile stays at
default and the Hello+Desktop AND-gate misclassifies). Fixed: post
unconditionally for facts; SessionStarted is now only gated by an
AdminAction, not by the resume flag. The orchestrator's
whiteglove_resumed lands first (smaller sequence) so the Web splitter
(`splitSequence = resumed.sequence - 1`) still puts SessionStarted
into the Part-2 timeline block.
P1b — StateArchiver was archiving event-sequence.json along with the
reducer state. Part 2 reuses the same SessionId and the backend orders
events session-wide by (SessionId, Sequence); the Web split also uses
`splitSequence = resumed.sequence - 1`. Resetting the counter at the
resume boundary would collide Part-1 and Part-2 sequences and break
the splitter. event-sequence.json is now intentionally preserved; the
three reducer-state segments still archive. StateArchiverTests +
RecoveryPathTests asserting the new contract.
P2 — V1 reset the local session-age clock on Part-2 resume
(SessionPersistence.ResetSessionCreatedAt, MonitoringService.cs:530).
V2 was clearing only the WhiteGlove marker, so a crash/reboot during
Part-2 AccountSetup would let the next start run the emergency-break
watchdog against the original Part-1 timestamp and trip too early.
AgentRuntimeHost now also calls SessionIdPersistence.SaveSessionCreatedAt
(DateTime.UtcNow) right after ClearWhiteGloveComplete. New
ProgramGuardsTests test asserting the V1-symmetric resume contract.
## Install-mode handoff hardening
Plan reference (memory): project_v2_install_runtime_handoff.md.
Bootstrap script + Program.InstallMode + InstallModeTaskXmlTests.
PR1 — Replace `schtasks /Run` with WMI Win32_Process.Create for the
install-time runtime handoff. schtasks /Run reports SUCCESS as soon
as the run is queued; the same battery / AV / OOBE-defer paths that
motivate PR2 also queue a manual /Run (observed WinPE 2026-05-04,
task stuck in Queued state). WMI also gives the runtime process the
same isolation Task Scheduler uses, without the Job-Object inheritance
risk of a direct Process.Start.
PR2 — Register the BootTrigger Scheduled Task from a hardened XML
definition rather than `schtasks /Create`. The CLI form inherits XML
schema defaults DisallowStartIfOnBatteries=true and
StopIfGoingOnBatteries=true, which queue the BootTrigger run
indefinitely on a laptop that boots on battery in OOBE / WinPE. The
XML disables both, sets StartWhenAvailable, and pins
ExecutionTimeLimit=PT0S so the long-running agent is never killed at
the schtasks 72h CLI default.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bridges the OOBE / hybrid-AAD-join race window where the agent fires
before AADTenantID / CloudDomainJoin\TenantInfo are written to the
registry. Field evidence (GardnerMedia, 2026-04-30): the agent fired
~5 minutes before the AAD device cert landed; an event-driven wait of
10 minutes recovers the run instead of bailing and waiting for next
reboot.
Mechanism (V2 only — V1 keeps the fast-fail path):
* New TenantIdAwaiter.WaitForTenantId(timeoutSec, logger, ct) sets up
a CompositeRegistryChangeSignal over HKLM\SOFTWARE\Microsoft\Enrollments
and HKLM\SYSTEM\...\CloudDomainJoin (Registry64 view, recursive,
Name+LastSet+ThreadAgnostic filter), re-probes silently on every
change, and returns the resolved TenantId or null on timeout.
* 30 s belt-and-suspenders periodic re-probe protects against a missed
RegNotifyChangeKeyValue notification.
* 100 ms debounce after each signal so multi-value enrollment
transactions land before the re-probe.
Configuration:
* Opt-in via --tenant-id-wait <sec>. Default 0 = no wait (legacy
fast-fail). The PS1 bootstrap script owns the production default;
the agent itself never assumes one.
* CLI value persisted to bootstrap-config.json by --install so the
Scheduled Task picks it up on each restart.
* Install-AutopilotMonitor-v2.ps1 sets $tenantIdWaitSec = 600. Admins
override per tenant by editing this single line and re-deploying.
Testability:
* IRegistryChangeSignal abstraction lets unit tests drive the loop
with a fake signal and scripted probe — 8 deterministic cases cover
timeout, signal hit, multi-signal, cancellation, periodic re-probe,
whitespace/empty/null handling, zero-timeout short-circuit, and
argument validation.
* RegistryWatcherIntegrationTests exercise the real Win32 plumbing
(RegNotifyChangeKeyValue → Changed event) against HKCU so no
elevation is required and the test runs in standard CI.
* Both classes opt out of xUnit parallelisation — the timing-sensitive
Thread.Sleep handshakes are unreliable on a busy parallel pool.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
schtasks reports SUCCESS as soon as the run is queued, not once the
process is actually alive. A silent block (AV/EDR, AppLocker/WDAC) or
Task Scheduler defer during OOBE would otherwise stay invisible until
the next reboot triggers the task via /SC ONSTART.
After --install, poll Get-Process for AutopilotMonitor.Agent.V2 every
500ms up to 10s. Log pid + StartTime on success, or a clear WARNING
that points operators at TaskScheduler/Operational + AV/EDR logs on
failure. Same change applied to the production and dev variants.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new projects as a clean-slate V2 fork of the Legacy Agent:
AutopilotMonitor.Agent.V2 — net48 exe, minimal Program.cs,
buildcounter 2.0.N, version-v2.json
AutopilotMonitor.Agent.V2.Core — net48 library, 94 files copied from
Agent.Core via bulk sed namespace
rewrite; same folder layout
AutopilotMonitor.DecisionCore.Tests — xUnit net8.0 with ReplayHarness
+ 13 smoke/harness tests
V2 isolation is build-time-enforced via V2Isolation.targets imported from
both V2 csproj files — any Legacy ProjectReference fails the build with a
clear error. Grep-verified at commit time: zero `using
AutopilotMonitor.Agent.Core` statements and zero Legacy ProjectReferences
in the V2 tree.
Per plan §2.11, kernel items (CompletionStateMachine, CompletionGuards,
EnrollmentTracker*, Flows/*, WhiteGloveClassifier, WhiteGloveSignals,
EnrollmentStatePersistence) are transitionally retained so the V2 tree
compiles; M4 removes them when SignalAdapters + V2Orchestrator take over.
Per user direction (feedback_v2_kernel_only_refactor): the V2 refactor
replaces only the decision kernel. All peripheral capabilities — gather
rules, analyzers, diagnostics upload, timezone, server-driven actions,
summary dialog launch, env-var resolution, version checks, registry
watchers, network/DO/performance telemetry, self-update, bootstrap→cert
handover, self-destruct — are preserved verbatim via the bulk copy.
Supporting M3 bring-up platform:
- ReplayHarness (empty-stream deterministic, SHA256 hash over terminal
state fields) in DecisionCore.Tests/Harness/
- Fixture scaffold under tests/fixtures/: committed anonymized directory
with commit-policy README, plus gitignored enrollment-sessions-local/
for raw exports
- tools/export-session-fixture.mjs with --help / --dry-run / --validate
modes; real MCP wiring marked TODO(v2-m3)
V2 bootstrap scripts scripts/Bootstrap/Install-AutopilotMonitor-v2(.|-dev).ps1
(committed, pure ASCII verified per feedback_ps1_ascii_only). V2 deployment
scripts under scripts/Deployment/V2/ are gitignored (analog to Legacy).
Test totals at M2 gate: 13 DecisionCore + 507 Agent.Core (unchanged) +
557 Functions (unchanged) = 1077 green.
Plan: plans/REFACTOR_AGENT_V2.md §4 M2 + §2.16 V2-Agent-Split.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>