Commit Graph

11 Commits

Author SHA1 Message Date
Oliver Kieselbach 6a9203905d feat(agent): implement MaxAuthFailures watchdog + post-config binary integrity check
Closes three DTO-only fields on AgentConfigResponse that have been dead since
the config schema was defined — neither V1 nor V2 ever consumed them. V2 now
honours them end-to-end.

1. MaxAuthFailures + AuthFailureTimeoutMinutes — AuthFailureTracker

New Security/AuthFailureTracker: thread-safe counter + time-window observer
that fires a single-shot ThresholdExceeded event when either ceiling is
crossed. Wired into:
- RemoteConfigService.FetchConfigAsync: RecordSuccess on 2xx, RecordFailure
  on BackendAuthException (401/403 from /api/agent/config).
- BackendTelemetryUploader.MapResponseAsync: RecordSuccess on 2xx,
  RecordFailure on 401/403 (moved from static to instance method).
- Program.cs: tracker constructed before FetchConfig with CLI defaults;
  UpdateLimits called after RemoteConfigMerger.Merge so tenant policy wins;
  ThresholdExceeded handler sets the main shutdown signal so the agent exits
  cleanly instead of retrying a permanently-revoked certificate forever.

Defaults: MaxAuthFailures=5, AuthFailureTimeoutMinutes=0 (time-window
disabled). Both honour 0 = "feature off", and UpdateLimits can tighten or
relax the ceilings mid-run when the tenant admin changes policy.

2. LatestAgentExeSha256 — BinaryIntegrityVerifier

New Security/BinaryIntegrityVerifier: pure-static helper that computes
SHA-256 over the running agent EXE and compares against the backend-
advertised hash. Case-insensitive hex compare. Three outcomes:
- Skipped: no hash provided / local path not resolvable → no-op.
- Match: silent success path (debug-log only).
- Mismatch: emits AgentErrorType.IntegrityCheckFailed via EmergencyReporter
  (the enum slot was already reserved — just never used). Does NOT terminate
  or auto-update; SelfUpdater's own hash-based trigger handles the "wrong
  EXE" case at next startup, and auto-terminating the agent on every
  mismatch would risk boot loops during rollouts.

Wired in Program.cs immediately after RemoteConfigMerger.Merge.

Context

These are three of the four "DTO-only" fields I flagged in PR #45's body.
User feedback: do not leave dead config schema entries. V1 never implemented
any of these either (V1 has a ZIP-hash check in SelfUpdater via
LatestAgentSha256, but no EXE-hash check and no auth-failure shutdown
threshold) — so this is net-new functionality that makes V2 strictly
better than V1 in these three dimensions, not a regression closure.

Test coverage

- AuthFailureTrackerTests (12 tests): count-based threshold, time-window
  threshold, RecordSuccess resets both, single-shot idempotence,
  handler-exception isolation, disabled-limits never fire, UpdateLimits
  retroactively tightens or relaxes, thread-safety under 8 concurrent
  callers, ctor null-guards.
- BinaryIntegrityVerifierTests (6 tests): skipped on null/empty/missing
  file, match path, case-insensitive compare, mismatch path, and a
  known-vector test against SHA-256("abc") to pin the hex formatter.

464 / 464 V2.Core tests green (vs. 446 before).

Not mapped / remaining dead-config

EnableImeMatchLog stays DTO-only — it duplicates the CLI flag
--ime-match-log with no semantic overlap to a backend switch, and mapping
it would just overwrite a dev-only debug toggle. Left as documented
non-feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:19:39 +02:00
Oliver Kieselbach a46ecb7ebd fix(agent): wire remote config onto AgentConfiguration + reorder Transport subscription
Two V2 agent bugs surfaced in the VM smoke-test after the first-run ghost-detect
hotfix (PR #44) let the agent reach the backend fetch phase:

1. INIT-ORDER CRASH — Program.WireTelemetryServerResponse touched
   orchestrator.Transport before orchestrator.Start() was called, and the
   Transport getter throws InvalidOperationException pre-Start because
   _transport is only constructed inside Start() at EnrollmentOrchestrator.cs:311.
   Every VM crashed at this exact line. Fix: move the wire call to immediately
   after orchestrator.Start() inside the same try block. No behavioural loss —
   the first ServerResponseReceived cannot fire until a telemetry upload
   completes, which happens after Start().

2. REMOTE-CONFIG WIRING GAP — RemoteConfigService fetched AgentConfigResponse
   from the backend but only Collectors, ImeLogPatterns, GatherRules,
   WhiteGloveSealingPatternIds and LatestAgentSha256 were consumed downstream.
   ~20 tenant-controlled fields were bound to AgentConfiguration defaults from
   CLI / bootstrap-config and never got projected from the remote DTO —
   SelfDestructOnComplete, KeepLogFile, RebootOnComplete, NtpServer,
   EnableTimezoneAutoSet, EnableGeoLocation, DiagnosticsUploadMode,
   DiagnosticsLogPaths, DiagnosticsUploadEnabled, ShowEnrollmentSummary +
   EnrollmentSummary* (4 fields), LogLevel, AgentMaxLifetimeMinutes,
   HelloWaitTimeoutSeconds, UploadIntervalSeconds, MaxBatchSize,
   SendTraceEvents, UnrestrictedMode, RebootDelaySeconds.

   Every consumer site (CleanupService, SummaryDialogLauncher,
   StartupEnvironmentProbes, DiagnosticsPackageService,
   EnrollmentTerminationHandler, AgentLogger, EnrollmentOrchestrator,
   GatherRuleGuards) reads agentConfig.XXX — so tenant admin settings were
   effectively ignored. A tenant with SelfDestructOnComplete=false still saw
   its agent wipe ProgramData on enrollment complete, because the CLI-default
   !noCleanup=true always won.

   Fix: new RemoteConfigMerger.Merge(agentConfig, remoteConfig, args) pure
   static helper, called immediately after FetchConfigAsync. Projects all
   ~20 tenant-controlled fields onto AgentConfiguration. CLI flags remain
   authoritative: flags explicitly passed (--no-cleanup, --keep-logfile,
   --reboot-on-complete, --disable-geolocation, --log-level) skip the remote
   overwrite so local dev runs keep their ad-hoc overrides.

   Partial-remote-config tolerance: empty strings, null lists and non-positive
   ints keep the AgentConfiguration default. Non-V1-parity fields
   (MaxAuthFailures, AuthFailureTimeoutMinutes, LatestAgentExeSha256) are
   deliberately skipped — they are DTO-only in V1 too and have no consumer in
   either version, no regression.

Test coverage added:
- ProgramGuardsTests: Transport pre-Start now explicitly asserted to throw
  (regression guard); Transport_is_available_after_start confirms the post-
  Start happy path.
- RemoteConfigMergerTests (20 tests): happy path (all fields), 5 CLI override
  tests (no-cleanup, keep-logfile, reboot, disable-geolocation, log-level),
  log-level case-insensitivity, log-level parse failure preserves default,
  null remote-config + null args + null nested Collectors tolerated, empty-
  string NtpServer + null DiagnosticsUploadMode keep defaults, null
  DiagnosticsLogPaths becomes empty list (not null), non-positive int clamping
  (UploadIntervalSeconds, MaxBatchSize, HelloWaitTimeoutSeconds), zero
  AgentMaxLifetimeMinutes allowed (explicit "disabled"), null-agent-config
  argument guard, and a regression test for the exact VM scenario
  (tenant SelfDestructOnComplete=false + agent with CLI-default true).

446 / 446 V2.Core tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:02:21 +02:00
Oliver Kieselbach bf090c436e fix(agent): create SessionId before startup guards to prevent first-run ghost-detect
The V2 agent was deterministically self-destructing on every first run after --install on a fresh VM. CheckEnrollmentCompleteMarker reads HKLM\SOFTWARE\AutopilotMonitor\Deployed (written by --install) and treats "marker present + session.id absent" as a ghost restart, triggering ExecuteSelfDestruct and wiping %ProgramData%\AutopilotMonitor before FetchConfigAsync can run. Remote config's SelfDestructOnComplete=false was never honored because the config fetch never happened.

Legacy V1 agent creates the session in LoadConfiguration (Program.Configuration.cs:131) before the guard runs (Program.cs:169). V2 reversed this ordering with a comment claiming it prevents "dead session resurrection" — but the guards key on file-system state (SessionPersistence.SessionExists()), not the runtime-loaded session object, so the reordering was functionally unnecessary and introduced this regression.

Fix: move SessionIdPersistence.GetOrCreate + --new-session handling back before the guards, restoring Legacy boot order. Ghost-restart detection still triggers in its intended case (session.id file deleted by incomplete self-destruct, but Scheduled Task + registry marker survive).

Test coverage: adds 3 missing tests for CheckEnrollmentCompleteMarker in ProgramGuardsTests.cs (first-run with session, marker without selfdestruct, marker with selfdestruct retry). The M4.6.alpha guard suite had no coverage for this path, which is how the regression shipped.

425 V2.Core tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 17:10:58 +02:00
Oliver Kieselbach 51e9966e0a fix(agent): forward AdminAction outcome to termination handler (Codex Finding 2)
Portal Mark-Succeeded traffic was silently landing as Failed locally. The
M4.6.ε ServerAction synthesis in Program.cs already copied the adminOutcome
value into the terminate_session ServerAction's Params bag, but the
terminate_session callback that fed the TerminationHandler hard-coded
EnrollmentTerminationOutcome.Failed regardless of what adminOutcome said.

Downstream consequences of the hard-coded Failed:
- FinalStatusBuilder.MapOutcome rendered "Failed" in the SummaryDialog
  even when the operator had explicitly marked the session Succeeded.
- EnrollmentTerminationHandler's DiagnosticsUploadMode.OnFailure gate
  fired a diagnostics package for every admin-override, regardless of
  actual outcome.

Fix:

- New Program.ServerActionSynthesis.cs partial (public static class
  Program, matches existing declaration) carrying a pure static helper
  MapAdminOutcome(IReadOnlyDictionary<string,string>? params) →
  EnrollmentTerminationOutcome. Case-insensitive "Succeeded" → Succeeded;
  missing/null/empty/any-other-value → Failed (default-failure-safe for
  kill-signal-driven synthesis that never sets adminOutcome).
- The terminate_session callback in Program.cs now calls the helper and
  threads the mapped outcome into EnrollmentTerminatedEventArgs. Log
  message extended to include the mapped outcome so ops can correlate
  the server-side override with the local termination result.

Reason stays DecisionTerminalStage. A distinct AdminAction reason would
need a DecisionCore enum extension — out of scope for this fix. What
Codex flagged concretely (Mark-Succeeded → visible as Failed) is now
addressed.

Tests (+10 in V2.Core.Tests): AdminActionOutcomeMappingTests
- "Succeeded" → Succeeded
- Case-insensitive variants (succeeded / SUCCEEDED / mixed case)
- "Failed" → Failed
- Unknown values (TimedOut / weird-future / empty) → Failed
- Missing adminOutcome param (kill-signal case) → Failed
- Null params dict → Failed

Accesses the helper through InternalsVisibleTo("AutopilotMonitor.Agent.V2.Core.Tests")
that was already set on the V2 agent assembly.

Suites: V2.Core 416 green (prev 406 + 10 new). Full solution build green.

This closes the last of the three Codex findings from the PR-43 review.
Remaining review comment on cross-layer test coverage is already
satisfied by the TelemetryWireRoundTripTests added in commit b6ea13f1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:36:10 +02:00
Oliver Kieselbach 9615b4c902 feat(agent-v2): M4.6.ε backend kill-switch + device-block + admin-action plumbing
Closes the last peripheral-parity gap flagged by the user: Legacy's admin
kill-switch / device-block / admin-mark-failed signals flow through the
batch-upload response and drive the same termination / pause paths. Without
this the V2 agent had no way to be remotely stopped or quarantined — it
could only be self-destructed by its own decision engine.

UploadResult extended
- New optional fields on success: DeviceBlocked, UnblockAt, DeviceKillSignal,
  AdminAction, Actions. Factory OkWithSignals(...) for success-with-control.
- Legacy-parity: same semantic as IngestEventsResponse's quarantine / kill /
  admin surface, just riding on the new /api/telemetry/batch response body.

BackendTelemetryUploader.TryReadControlSignalsAsync
- Parses the 2xx response body as JSON (defensive — empty body or malformed
  JSON degrades cleanly to plain Ok). Accepts both camelCase and PascalCase
  field names so an M5 backend can choose either without breaking V2
  agents in the wild.
- Recognises deviceBlocked (bool) + unblockAt (ISO-8601 UTC) + deviceKillSignal
  (bool) + adminAction (string) + actions (ServerAction[]).

TelemetryUploadOrchestrator
- New public event ServerResponseReceived fires on successful uploads that
  carry any control signal. Handlers run after the cursor has moved, so a
  handler exception does not abort or re-run the batch.
- New public IsPaused property — DeviceBlocked sets an internal
  _pausedUntilUtc; subsequent DrainAllAsync calls short-circuit with a
  "device blocked" LastErrorReason until UnblockAt elapses (or indefinitely
  when UnblockAt is null). UnblockAt=null maps to DateTime.MaxValue so the
  comparison stays deterministic without a null branch per tick.

EnrollmentOrchestrator.Transport property
- Exposes the live TelemetryUploadOrchestrator so Program.cs can subscribe
  to ServerResponseReceived without leaking transport internals through
  public orchestrator API.

Program.cs WireTelemetryServerResponse
- Routes ServerResponseReceived through the existing ServerActionDispatcher:
  • DeviceKillSignal → synthesises terminate_session with forceSelfDestruct=
    true + gracePeriodSeconds=0 (kill overrides local SelfDestructOnComplete
    config — Legacy parity, the operator's decision wins)
  • AdminAction → synthesises soft terminate_session respecting local config
  • Actions[] → dispatched directly
  • DeviceBlocked → logged (the transport already paused itself)
- onTerminateRequested extended: reads params["forceSelfDestruct"] and
  flips agentConfig.SelfDestructOnComplete at runtime when set, so the
  cleanup service actually runs.

Tests (+15 in V2.Core.Tests → 384)
- BackendTelemetryUploaderControlSignalsTests: empty body, malformed JSON,
  DeviceBlocked + UnblockAt, PascalCase accepted, DeviceKillSignal,
  AdminAction, Actions array round-trip, non-2xx does not parse signals,
  body with only unknown fields.
- TelemetryUploadOrchestratorServerResponseTests: plain Ok is silent,
  kill-signal raises event once, admin-action forwards field, actions
  forward list, DeviceBlocked pauses → unblock lifts pause → next drain
  uploads the queued item, handler exceptions do not break cursor.

All 4 suites green: 88 DecisionCore + 384 V2.Core + 507 Agent.Core + 557 Functions
= 1536 tests green.

With this commit M4.6 closes all known Legacy-vs-V2 peripheral-parity gaps:
agent_kill (DeviceKillSignal → synthesized terminate_session), agent_blocked
(DeviceBlocked → paused drain), portal Mark-Failed/Mark-Succeeded (AdminAction
→ synthesized terminate_session), plus the generic server-queued Actions[].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:56:44 +02:00
Oliver Kieselbach 50c3bf0516 feat(agent-v2): M4.6.δ diagnostic modes + analyzer manager + gather-rules runtime
Closes the final M4.6 batch of peripheral-parity gaps. After this commit the
V2 agent has full feature parity with the Legacy agent around every
capability NOT inside the DecisionEngine itself.

Diagnostic CLI modes (Program.*.cs partials)
- --run-gather-rules: ported 1:1 from Legacy. Resolves TenantId via V2
  TenantIdResolver, creates a fresh ephemeral SessionId, registers it,
  fetches remote config, runs GatherRuleExecutor against all Trigger=startup
  rules (120s cap), uploads the collected events in-memory via
  BackendApiClient.IngestEventsAsync. Never touches the live agent's spool.
- --run-ime-matching <path> [--patterns <file>]: ported 1:1 from Legacy.
  Single-file or directory scan through the same IME log-file patterns the
  live tracker watches, loads patterns from GitHub (fallback: cached
  remote-config.json), compiles regexes with {GUID} substitution + 1s
  timeout, scans + writes ime_pattern_matching.log with per-pattern counts.
- Both modes fork early in Main before orchestrator startup — no side
  effects on the Scheduled Task, no process conflict with the live agent.

AgentAnalyzerManager (V2.Core/Runtime/)
- Ported from Legacy AnalyzerManager. Reads AnalyzerConfiguration from
  AgentConfigResponse.Analyzers directly (no RemoteConfigService
  indirection — the cache is already resolved at call site).
- Initialize() registers LocalAdminAnalyzer / SoftwareInventoryAnalyzer /
  IntegrityBypassAnalyzer gated by Enable* flags; idempotent across
  repeated calls.
- RunStartup() fires on a background task, swallows per-analyzer
  exceptions. Called from Program.cs right after orchestrator.Start so
  analyzers emit through the live TelemetryEventEmitter.
- RunShutdown(int? whiteGlovePart) runs synchronously before the
  diagnostics upload, routing SoftwareInventoryAnalyzer to its overload
  that distinguishes WhiteGlove part 1 baseline from part 2 delta.

EnrollmentTerminationHandler integration
- New optional `analyzerManager` ctor param. RunShutdownAnalyzers runs
  FIRST in the termination sequence (before FinalStatus + diagnostics
  upload) so the analyzers' delta events make it into the final
  diagnostics ZIP. Passes whiteGlovePart=1 on WhiteGloveSealed terminal.

GatherRuleExecutorHost (DefaultComponentFactory nested)
- Runtime gather-rules execution via a new ICollectorHost that wraps
  GatherRuleExecutor. Only registered when remoteConfig.GatherRules has
  entries. Picks up ImeLogPathOverride for file-collector paths.
- Legacy parity: gather rules run as part of the active enrollment session
  alongside the collector hosts, not only via the standalone --run-
  gather-rules diagnostic mode.

Program.cs mode fork (Main)
- --install / --run-gather-rules / --run-ime-matching handled in order
  after --help / --version, before multi-instance guard and self-update
  (those only apply to the normal monitoring mode).

Tests (+8 in V2.Core.Tests → 369)
- AgentAnalyzerManagerTests: toggle-matrix, null-config default behaviour,
  idempotent Initialize, argument-null guards, fake-analyzer injection for
  RunShutdown success/exception/empty-list paths. The three real analyzers
  already have their own parity-tested implementations in V2.Core shared
  with Legacy.

All 4 suites green: 88 DecisionCore + 369 V2.Core + 507 Agent.Core + 557 Functions
= 1521 tests green.

M4.6 is now complete. The V2 agent matches Legacy feature parity for:
install mode, self-update, multi-instance guard, clean-exit marker,
previous-exit classification, enrollment-complete ghost-restart detection,
session-age emergency break, AgentMaxLifetime watchdog, BootstrapConfigCleanup,
terminal-stage detection with typed Terminated event, FinalStatus.json +
SummaryDialog launch via UserSessionProcessLauncher, CleanupService self-
destruct (with WhiteGlove-part-1 pause), DiagnosticsPackageService upload,
ServerActionDispatcher (terminate_session / request_diagnostics / rotate_
config callbacks ready for M5 response-path plumbing), Geo/NTP/Timezone
probes, Emergency + Distress reporters, DeliveryOptimizationCollector,
3 analyzers, --run-gather-rules + --run-ime-matching diagnostic modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:10:56 +02:00
Oliver Kieselbach 7e199019ce feat(agent-v2): M4.6.γ diagnostic probes, reporters, DO collector wired
Closes the third batch of peripheral-parity gaps — Legacy's fire-and-forget
startup probes, the two cert-less + authenticated error-reporting channels,
and Delivery-Optimization telemetry all run in V2.

StartupEnvironmentProbes (V2.Core/Runtime/)
- New helper that replicates the Legacy MonitoringService startup diagnostic
  trio: device_location / timezone_auto_set / ntp_time_check.
- Sequential RunAsync(config, logger, emitter): geo lookup → (optional)
  tzutil auto-set → NTP offset check. Each step is best-effort and feeds
  through the orchestrator's TelemetryEventEmitter.
- Geo probe respects EnableGeoLocation; timezone probe respects
  EnableTimezoneAutoSet AND requires an IANA tz from geo. NTP probe always
  runs (diagnostic-only; Warning on offset >60s or failure).
- Program.cs wires this via Task.Run right after orchestrator.Start so a
  slow network never delays the critical path.

EmergencyReporter + DistressReporter (live in Program.cs)
- Previously instantiated nowhere — now built once at startup with the
  shared hardware info. RemoteConfigService ctor takes both so config-fetch
  failures flow to the right channel (auth → distress, network → emergency).
- Initial AuthCertificateMissing distress fires when UseClientCertAuth=true
  but BackendApiClient.ClientCertificate is null — surfaces pre-MDM-
  enrollment dead-ends via the cert-less channel (Legacy parity).
- BackendTelemetryUploader uses the same hardware info (dedup'd the second
  HardwareInfo.GetHardwareInfo call that was duplicated since M4.5.b).

DeliveryOptimizationHost (DefaultComponentFactory nested)
- New ICollectorHost wrapping DeliveryOptimizationCollector with:
  • getPackageStates → ImeLogHost.PackageStates (existing accessor)
  • onDoTelemetryReceived → ImeLogHost.Tracker.OnDoTelemetryReceived pass-
    through (new internal accessor; keeps encapsulation)
  • OnAppStateChanged chained into ImeLogTracker so WakeUp() fires when
    any app enters Downloading or Installing — before any fire we preserve
    the existing handler (the adapter's signal-ingest path must keep
    running unchanged).
- Gated by CollectorConfiguration.EnableDeliveryOptimizationCollector.

Tests (+8 in V2.Core.Tests → 353 → 361)
- StartupEnvironmentProbesTests: event-shape for all three probes + geo
  success/failure/null attempt, timezone success/failure severity, NTP
  small/large offset + failure-with-detail.

All 4 suites green: 88 DecisionCore + 361 V2.Core + 507 Agent.Core + 557 Functions
= 1513 tests green.

Note: the "RegistryWatcher standalone admin-action-detector" from the
original M4.6 gap list was a Subagent fabrication — verified no such usage
exists in either Legacy or V2 (the class is only consumed transitively
inside AadJoinWatcher + ProvisioningStatusTracker, both already wired via
DefaultComponentFactory since M4.5.b). Removed from the M4.6.γ scope.

Remaining: M4.6.δ (--run-gather-rules + --run-ime-matching modes,
AnalyzerManager + 3 Analyzers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:50:15 +02:00
Oliver Kieselbach 17063be600 feat(agent-v2): M4.6.β terminal dispatch — FinalStatus, SummaryDialog, cleanup, server actions
Closes the second batch of peripheral-parity gaps so the V2 agent actually
terminates like Legacy does: write FinalStatus.json, launch the WPF summary
dialog in the user session, upload diagnostics (respecting mode/outcome),
write the enrollment-complete marker, run self-destruct, and signal shutdown.
All of this is driven off a single typed kernel event so nothing leaks back
into the DecisionEngine.

Terminal-stage detection (kernel hook)
- DecisionCore/State/SessionStageExtensions.IsTerminal() + IsPauseBeforePart2()
  — Completed, Failed, WhiteGloveSealed, WhiteGloveCompletedPart2 are terminal;
  WhiteGloveSealed is the "pause before Part 2" special case (session resumes
  on reboot, cleanup MUST be suppressed).
- DecisionStepProcessor ctor gains Action<DecisionState>? onTerminalStageReached;
  fires at most once per processor lifetime, swallows handler exceptions,
  suppressed when initial state was already terminal (crash-recovery race).
- EnrollmentOrchestrator.OnDecisionTerminalStage translates the hook into the
  public Terminated event with Reason=DecisionTerminalStage + Outcome mapped
  from stage (Completed/Part2/Sealed→Succeeded, Failed→Failed, else TimedOut).
  MaxLifetime watchdog is cancelled on the real terminal so the two paths can't
  race each other.

Peripheral termination (V2.Core/Termination/*)
- FinalStatus DTO — wire-format mirror of SummaryDialog's Models/FinalStatus
  (same JSON property names). Lives in V2.Core because the dialog is a
  separate net48 WPF exe we can't ProjectReference.
- FinalStatusBuilder — pure function: DecisionState + AppPackageStateList +
  TerminatedArgs + agent start time → FinalStatus. Maps outcome string
  ("succeeded" | "failed" | "timed_out" | "whiteglove_part1" | "whiteglove_part2"),
  derives signalsSeen from DecisionState facts, counts apps by AppTargeted
  phase (Device/User), tallies device vs user errors.
- SummaryDialogLauncher — writes final-status.json, copies dialog exe + deps
  + status file to a per-launch temp dir (%TEMP%\AutopilotMonitor-Summary\<guid>),
  composes the Legacy-compatible CLI (--status-file, --timeout, --cleanup,
  optional --branding-url), invokes UserSessionProcessLauncher.LaunchInUserSession
  with the configured retry window. Fire-and-forget, swallows all errors.
- EnrollmentTerminationHandler — the Terminated-event handler that sequences:
  build FinalStatus → write JSON + launch dialog → upload diagnostics
  (respects DiagnosticsUploadMode: Off/Always/OnFailure) → write
  enrollment-complete.marker (suppressed on WhiteGloveSealed) → run
  CleanupService.ExecuteSelfDestruct (suppressed on WhiteGloveSealed OR when
  SelfDestructOnComplete=false) → signal shutdown MRE. Idempotent; each step
  is best-effort and logs rather than throws — nothing is allowed to prevent
  agent shutdown.

DefaultComponentFactory
- Stores the ImeLogHost it creates and exposes its PackageStates to the
  termination pipeline (factory.ImePackageStates) so FinalStatusBuilder has
  the live package list without breaking host encapsulation.

Program.cs wiring
- Instantiates DiagnosticsPackageService, EnrollmentTerminationHandler, and
  ServerActionDispatcher with all callbacks live:
  • rotate_config → RemoteConfigService.FetchConfigAsync()
  • request_diagnostics → DiagnosticsPackageService.CreateAndUploadAsync()
  • terminate_session → EnrollmentTerminationHandler.Handle() synthesising a
    DecisionTerminalStage/Failed termination
- Terminated event routed to the handler; the handler signals the shutdown
  MRE so the main thread cleanly calls orchestrator.Stop().
- ServerActionDispatcher is alive but has no ingest-response consumer yet —
  V2 batch endpoint is M5 scope, we plumb the API contract here so M5 can
  drop in the response parser.

Tests (+47 total)
- DecisionCore.Tests: +15 SessionStageExtensionsTests (IsTerminal + IsPauseBeforePart2)
- V2.Core.Tests: +32 across terminal-stage hook, FinalStatusBuilder, handler
  • DecisionStepProcessorTerminalStageTests: fires once per stage, skips
    non-terminal, respects initial-terminal-state (recovery), swallows
    handler exceptions, works with null callback
  • FinalStatusBuilderTests: outcome mapping per stage, uptime, HelloOutcome
    fact path, signalsSeen composition, empty package list, device/user
    error tallying, JSON property-name contract
  • EnrollmentTerminationHandlerTests: writes FinalStatus.json + marker,
    runs/skips cleanup per config + WhiteGlove rule, idempotent shutdown
    signal, diagnostics mode matrix (Off/Always/OnFailure × Succeeded/Failed),
    suffix ("success" | "failure") per outcome

All 4 suites green: 88 DecisionCore + 353 V2.Core + 507 Agent.Core + 557 Functions
= 1505 tests green.

Remaining M4.6 scope: γ (Geo/NTP/Timezone/Emergency/Distress services wiring,
RegistryWatcher admin-action detector, DeliveryOptimizationCollector in
DefaultComponentFactory) + δ (--run-gather-rules + --run-ime-matching modes,
AnalyzerManager + 3 analyzers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:37:36 +02:00
Oliver Kieselbach 02bbb5490c feat(agent-v2): M4.6.α peripheral parity — lifecycle, install, self-update, watchdog
Closes the first batch of peripheral-parity gaps between Legacy and V2 so the
V2 agent is actually deployable/runnable in production. M4 kernel is unchanged
— only wiring and startup helpers.

Deployment + install
- Program.InstallMode.cs: --install mode ported 1:1 (payload copy, schtasks
  Create/Run, bootstrap-config.json + await-enrollment.json persistence,
  HKLM\SOFTWARE\AutopilotMonitor\Deployed marker)
- Configuration/BootstrapConfigFile.cs + AwaitEnrollmentConfigFile.cs
- Scheduled Task target is AutopilotMonitor.Agent.V2.exe (same task name
  as Legacy — V2 replaces Legacy on the device)

Startup guards (Program.Guards.cs)
- Multi-instance guard (IsAnotherAgentInstanceRunning)
- clean-exit.marker writer registered in AppDomain.ProcessExit
- DetectPreviousExit: reads marker + crash_*.log + session files + event log
  → classifies {first_run, clean, exception_crash, hard_kill, reboot_kill}
- CheckEnrollmentCompleteMarker: ghost-restart detection via HKLM Deployed
  key + enrollment-complete.marker → cleanup-retry + exit
- CheckSessionAgeEmergencyBreak: AbsoluteMaxSessionHours watchdog across
  restarts (respects whiteglove.complete pause)
- WriteCrashLog: last-resort crash_YYYYMMDD_HHMMSS.log
- TryRead/Delete bootstrap-config.json + await-enrollment.json helpers

Self-update (SelfUpdater wired for the first time in V2 startup)
- CleanupPreviousUpdate + CheckAndApplyUpdateAsync called BEFORE logger init
- Cached remote-config.json read → BackendExpectedSha256 + AllowAgentDowngrade
- VersionCheckEventBuilder wired: emits agent_version_check event via
  orchestrator.EventEmitter right after Start (when startup markers exist)

Agent max-lifetime watchdog (EnrollmentOrchestrator)
- New ctor param agentMaxLifetime: TimeSpan?
- New typed API: event EventHandler<EnrollmentTerminatedEventArgs> Terminated
  with EnrollmentTerminationReason (MaxLifetimeExceeded | DecisionTerminalStage)
  + EnrollmentTerminationOutcome (Succeeded | Failed | TimedOut)
- M4.6.α fires MaxLifetimeExceeded only; DecisionTerminalStage is the M4.6.β
  hook for CleanupService self-destruct + SummaryDialog launch
- Program.cs wires the event to the shutdown ManualResetEventSlim

Bootstrap-config cleanup (H-2 mitigation)
- BootstrapConfigCleanup.TryDeleteIfCertReadyAsync now actually called after
  RemoteConfigService.FetchConfigAsync (cert probe confirms mTLS works)

SessionIdPersistence extensions
- New session.created companion file (UTC roundtrip) for the emergency break
- whiteglove.complete resume marker awareness
- SessionExists / LoadSessionCreatedAt / SaveSessionCreatedAt / IsWhiteGloveResume
- Delete now clears session.id + session.created + whiteglove.complete

Tests (+26 in V2.Core.Tests → 295 → 321)
- SessionIdPersistenceTests: +8 tests covering the new methods
- EnrollmentOrchestratorMaxLifetimeTests: watchdog fires once, respects null,
  rejects non-positive, is cancelled by Stop (under SerialThreading collection)
- ProgramGuardsTests: 13 tests covering DetectPreviousExit state machine,
  CheckSessionAgeEmergencyBreak (pass/trip/whiteglove-resume/recover-missing-
  session.created paths), TryRead/Delete bootstrap + await-enrollment configs,
  WriteCrashLog format
- V2.Core.Tests gains ProjectReference to V2 exe + V2 exe exposes
  InternalsVisibleTo so the Program.Guards internal statics are testable
  without duplicating code into V2.Core

All 4 suites green: 73 DecisionCore + 321 V2.Core + 507 Agent.Core + 557 Functions
= 1458 tests green.

Still open for M4.6.β: CleanupService terminal-event wiring, ServerActionDispatcher,
DiagnosticsPackageService, SummaryDialog launch + FinalStatusBuilder,
UserSessionProcessLauncher / UserProfileResolver wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 00:14:48 +02:00
Oliver Kieselbach 0ee68c6192 feat(agent-v2): M4.5.b runtime entry + DefaultComponentFactory
Wires the V2 agent into a runnable state:

- TenantIdResolver: HKLM\SOFTWARE\Microsoft\Enrollments → AADTenantID (EnrollmentType=6)
- SessionIdPersistence: atomic session.id via File.Replace
- IComponentFactory signature: +ISignalIngressSink +IClock
  (bridges the sink/clock access into factory-built adapters)
- DefaultComponentFactory: 5 kernel hosts (EspAndHelloTracker coordinator,
  DesktopArrivalDetector, AadJoinWatcher, ImeLogTracker+ImeProcessWatcher,
  StallProbeCollector) + 2 peripheral hosts (Performance, AgentSelfMetrics)
  conditional on CollectorConfiguration toggles
- Program.cs: full runtime entry (args → Logger → TenantId/SessionId →
  AwaitEnrollment → BackendApiClient+RemoteConfigService → MtlsHttpClientFactory
  → BackendTelemetryUploader → DefaultComponentFactory → EnrollmentOrchestrator
  → Ctrl+C wait → Stop → exit 0)

Production host choice: EspAndHelloTracker as single coordinator entry
(instead of 4 sub-tracker hosts in parallel) — avoids adapter signal
duplication documented in M4.3 tech-debt. StallProbeHost drives its own
60s idle-tick timer (Legacy MonitoringService.IdleCheckCallback is gone).

Tests: +8 SessionIdPersistenceTests (atomic-write, round-trip, corrupt-
recovery, regeneration, multi-instance resume). V2.Core 287→295, total
1424→1432 green.

Known peripheral gaps (tracked for follow-up M4.6): install mode,
SelfUpdater, ServerActionDispatcher, CleanupService, Geo/NTP/Timezone
wiring, Emergency/DistressReporter wiring, AnalyzerManager, SummaryDialog,
DiagnosticsPackageService, gather-rules/ime-matching modes, watchdog
timers, multi-instance guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:48:24 +02:00
Oliver Kieselbach 710504549b refactor(agent): V2-Agent-Split skeleton + infrastructure copy (M2)
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>
2026-04-20 13:31:30 +02:00