14 Commits

Author SHA1 Message Date
Oliver Kieselbach 3a898b5071 Bootstrap leaves nothing behind: loader 1.3 and installer 2.7 (D-375)
- 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>
2026-10-22 13:34:54 +02:00
Oliver Kieselbach 60a41e9778 CI: signing jobs run in the release environment
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>
2026-10-20 18:30:02 +02:00
Oliver Kieselbach 8d24c5680b CI: one endpoint registry for every host the workflows use
.github/scripts/Get-Endpoints.ps1 defines the storage account (blob and table
derived from its name), the containers, the AdminConfiguration row, the legacy
mirror, the download alias, API, MCP, portal, www, docs and the timestamp
server. Workflows load it once per job; the script publisher and the local
release build call it; the dev-render anchors are built from the alias entry.

deploy-web takes NEXT_PUBLIC_API_BASE_URL and NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL
from the registry instead of repository variables and refuses an empty value.
A new Workflow gates job fails on any host spelled out in a workflow or CI
helper; the enforced list is derived from the registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:32:36 +02:00
Oliver Kieselbach 9f46adcdfa ci: the storage auth helper masks its token in the run log, like the Key Vault token
GitHub masks only secrets.* by itself. The bearer token Get-StorageAuthHeaders.ps1
mints at run time was never registered as a mask, unlike the Key Vault signing token,
which build-agent, build-msi and publish-scripts mask before exporting it. No caller
prints it today -- every call site captures the hashtable and passes it only as
-Headers -- but this repository's run logs are public, and a single debugging line
would have published it: an uncaptured call of the helper, or Get-Error /
Format-List on a failed request, whose TargetObject is the HttpRequestMessage
including the Authorization header (reproduced locally with a dummy token).

The helper now emits ::add-mask:: for every token it hands out, which covers all six
workflows in one place. Write-Host, never Write-Output: the output stream is the
script's return value. Verified with a mocked az that the result stays one hashtable
and that a nested caller in the Request-BuildNumber.ps1 pattern still returns only
its build number. Only under GITHUB_ACTIONS -- locally the line would print the token
instead of hiding it.

Prompted by an external AI review of the public scripts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:52:01 +02:00
Oliver Kieselbach c24c7bf7ef fix(publish): serve the customer scripts as a download, not as a web page
The blobs carry Content-Type: text/plain and no Content-Disposition, so every browser
renders them inline -- the documented "download the bootstrapper script" step turns into
"select this text and save it yourself", with whatever the editor then does to the line
endings and the signature block.

x-ms-blob-content-disposition makes the link a download in every browser, which is a
server-side fact rather than a per-browser hint in the docs. Command-line clients (the
loader, the MSI runner, curl) ignore the header, so nothing on the device path changes.

Metadata only: the script bytes are untouched, so no version bump and the bump guard
still sees the published copies as current.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 21:07:35 +02:00
Oliver Kieselbach 647581b4ff refactor(msi): the MSI carries the shared stage-1 loader instead of its own downloader
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>
2026-09-08 20:41:52 +02:00
Oliver Kieselbach cc6be10532 fix(publish): read the signed bytes back as byte[], not as a wrapped Object[]
The ',' operator elsewhere in this file exists because PowerShell unrolls an array
RETURNED from a function; a property assignment does not unroll, so it wrapped the
byte[] in an Object[] of length 1 and the upload failed converting it back. Signing
itself was fine -- run 34253331703 signed all six scripts and uploaded none, so nothing
reached the download alias.

Added the invariant that would have caught it: signed bytes must be longer than the
unsigned form, since a signature only ever adds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:54:36 +02:00
Oliver Kieselbach 0ac548bfce feat(bootstrap): two-stage loader and Authenticode-signed customer scripts (D-224)
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>
2026-09-08 18:48:21 +02:00
Oliver Kieselbach e4a5a6654d ci: workflows authorize Azure Storage with the federated identity, not SAS secrets (D-222)
Every storage write against autopilotmonitoreu -- build-counter CAS, agent ZIP and MSI,
version manifests, customer bootstrap scripts, the AdminConfiguration hash oracle -- now
rides on the existing federated credential (subject repo:.../ref:refs/heads/main) instead
of a long-lived SAS in repo secrets.

The REST calls themselves are unchanged: '?<sas>' became an Authorization header, so the
ETag-CAS of the counters, Content-MD5 and the no-cache contract survive verbatim. That is
also why this is not a rewrite onto 'az storage blob upload' -- it cannot express If-Match,
and two concurrent builds could then mint the same build number.

.github/scripts/Get-StorageAuthHeaders.ps1 is the single place that knows the resource id,
the az invocation and the service version. x-ms-version is mandatory for Entra-authorized
requests (>= 2017-11-09) and for any Table request without a SAS; 2021-08-06 was verified
live against both the blob and the table endpoint of this account.

azure/login added to build-agent, build-msi, publish-scripts (plus id-token: write) and
deploy-web; deploy-backend and deploy-mcp already had it. Publish-BootstrapScripts.ps1
-DryRun acquires NO token: the PR gate runs it without any Azure login, and that must
stay true.

The AdminConfiguration merge lost its "secret not configured" skip branch -- a failure
there now surfaces instead of silently leaving the hash oracle pointing at an older build.

AZURE_BLOB_AGENT_SAS_TOKEN, AZURE_BLOB_VERSIONS_SAS_TOKEN and AZURE_TABLE_ADMIN_SAS_TOKEN
are retired once the first runs are green. AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN stays: that
account lives in the pre-migration tenant, where this principal has no identity -- it dies
with the mirror, not before.

Requires on the SP: Storage Blob Data Contributor on the containers 'agent' and 'versions',
Storage Table Data Contributor on the table 'AdminConfiguration'. Granted 2026-09-08.
2026-09-08 17:38:14 +02:00
Oliver Kieselbach db020789e1 chore: private submodule docs/ becomes internal/ (knowledge + decisions + work files + private instructions)
- .gitmodules: docs -> internal, repo renamed to Autopilot-Monitor-internal; the OKF bundle now lives at internal/docs/
- .claude/CLAUDE.md rewritten: where knowledge lives, working-file rules, hard rules; imports the private half from internal/CLAUDE.md (silently absent without submodule access)
- .claude/architecture-principles.md moved into the internal bundle (reconciled against the code)
- tasks/ (gitignored) retired; working files live in internal/work/
- doc path references docs/<bundle> -> internal/docs/<bundle> in code comments, skills gitlink, codeql paths, build script
- rule descriptions no longer cite an internal doc path (customer-visible text); rules/dist regenerated
- public tree no longer names private operator tooling
- .claude/commands gitlink: skill docs point at the new paths
2026-09-04 02:19:00 +02:00
Oliver Kieselbach 25bac34acb fix(ci): upload raw bytes, not a stringified array
The first CI run overwrote all four customer-facing scripts on the download
host and the legacy mirror with their own byte values as decimal text
(Install-AutopilotMonitor.ps1 went from 27331 to 95679 bytes).

PowerShell unrolls an array returned from a function into the output stream, so
the caller receives Object[] rather than byte[]. Invoke-RestMethod serialises an
Object[] body as space-separated decimals. Every hash check stayed green because
[byte[]] parameter coercion repairs the type everywhere except on the wire.
build-agent.yml's Send-Blob was never affected: it read the bytes inside the
function. Proven with a local HttpListener: the old shape puts 50 bytes starting
54,48,32,51,53 ("60 35 13 1...") on the wire where 17 bytes starting 60,35,13,10
("<#\r\n.SYN") are expected.

Fixes:
- ',' on both byte-array returns, plus an explicit [byte[]] at the upload site.
- Read each blob back from storage right after its PUT and abort before the
  mirror and the remaining files. Verifying only at the end left every blob
  corrupted.
- The version-bump guard tolerates an unparseable published copy with a warning
  instead of throwing: that is the state a republish exists to repair, and a
  guard that blocks the repair is worse than no guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:31:06 +02:00
Oliver Kieselbach c0be389a95 ci(scripts): version-bump guard compares behaviour, not bytes
Byte comparison made the guard fire on comment fixes, typos in comments and
reflow -- changes no consumer of the version can act on, so forcing a bump for
them is friction without a payoff.

The guard now compares a code fingerprint: the PowerShell token stream with
comments, newlines and line continuations removed. A behaviour change under an
unchanged $ScriptVersion still fails; comment and formatting changes publish
with a warning. That is also the rule the source file's own comment on
$ScriptVersion already states ("bump on meaningful changes").

Tokenising rather than regex-stripping keeps a '#' inside a string literal from
being mistaken for a comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:15:48 +02:00
Oliver Kieselbach 91f9d656ae ci(scripts): publish customer scripts on push, decoupled from the agent release
The scripts behind download.autopilotmonitor.com/agent/ rotated only on a
publish_as_stable run of build-agent.yml, so a bootstrap fix sat unpublished
until the next agent cutover -- while the WDP bootstrap MSI downloads the
published copy on every enrolling device.

Publish-BootstrapScripts.ps1 becomes the single owner of the chain: dev render
with anchor check, $ScriptVersion bump guard, uploads (eu + fail-soft legacy
mirror, no-cache), the two version oracles, and verification through the alias.
publish-scripts.yml calls it on every push to main touching the sources; the
publish_as_stable branch of build-agent.yml calls the same code, so a release
still lands a consistent set.

Blobs are published in a deterministic CRLF/UTF-8-without-BOM form. Git
normalises line endings on checkout, so raw working-copy bytes would differ per
environment and make every byte comparison -- guard and verify alike -- depend
on where the workflow ran. The form reproduces the currently published bytes
exactly.

The Pester (PS 5.1) and ASCII gates move into the reusable
bootstrap-script-gates.yml and now block the upload; pester.yml keeps the
pull-request view and drops its push trigger. AdminConfiguration
.LatestBootstrapV2ScriptVersion gets exactly one writer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 15:09:33 +02:00
Oliver Kieselbach bbb55034e3 feat(versioning): one version contract for agent, backend and MCP
All three components now report <major>.<minor>.<build> plus a 7-char commit
and a build timestamp. The backend had been pinned at a hand-written 1.0.0
since inception and starts at 1.5.1000; the MCP server's package.json version
was bumped by hand and starts at 1.5.300, and it now reports its own commit on
/health instead of only the documentation bundle's.

The build number keeps coming from a counter blob reserved before the build
with ETag-CAS, but there is now one implementation instead of two copies:
.github/scripts/Request-BuildNumber.ps1, called by all three workflows and by
the local build.ps1. It lives under .github/ because scripts/Deployment/ is
gitignored (those scripts carry SAS tokens) and would therefore be absent from
a CI checkout; scripts/Build/ and scripts/Release/ are shadowed by the
[Bb]uild/ and [Rr]elease/ artifact ignores.

The counter cannot be replaced by the published manifest, which is the obvious
simplification to reach for: version.json is written only on stable publish, so
two consecutive dev builds would derive the same number and the second would
overwrite the first's artifact. git rev-list --count fails the same way, two
builds of one commit collide.

Both deploy workflows now poll their health endpoint until it reports exactly
the version and commit just deployed, and publish versions/<component>.json
only then. A green deploy step alone means the package was accepted, not that
it is being served -- so a manifest that disagrees with the live endpoint now
means the running instance drifted (rollback, stale revision) rather than a
pipeline that stopped halfway. The agent manifest gains the same commit and
buildUtc fields additively; the bootstrap reads it by field name, so older
installers ignore them, and sha256/bootstrapVersion stay untouched.

Requires the versions container plus AZURE_BLOB_VERSIONS_SAS_TOKEN and the two
seeded counters (backend 999, mcp 299) before the first deploy run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:41:03 +02:00