diff --git a/README.md b/README.md index 0788dc83f..cf6e02fcd 100644 --- a/README.md +++ b/README.md @@ -741,6 +741,7 @@ kcap daemon service status --json # machine-readable status (pids, bina kcap daemon service stop # stop the running service (stays installed) kcap daemon service start # start it again kcap daemon service start --verify # start, then verify readiness/ownership before exiting 0 +kcap daemon service ensure # install-or-start from a fresh status read (flow-driven) kcap daemon service uninstall # stop and remove the service ``` @@ -748,6 +749,8 @@ kcap daemon service uninstall # stop and remove the service `status --json` prints a machine-readable snapshot (service/job/daemon pids, binary paths, and transaction-marker state) instead of the human summary, and exits non-zero if the underlying service state can't be determined — for scripts that need to decide whether to attach, start, or repair a service without parsing human-readable text. +`ensure` is the flow-driven ladder: from a fresh status read it installs when there is no unit (baking the born-`prompt` consent directive — the daemon is installed `prompt`, so nothing runs unattended on someone else's say-so) or starts when the unit is present but stopped, and reports "already enabled" when the daemon is running — on launchd that additionally requires the running service job to own the validated daemon pid, the same ownership the verified start polls for. On macOS/launchd both arms run the verified transaction exactly as an app-managed start does, so a gate refusal exits with the coded verify exit plus one `start_gate_reason=` line, and `ensure` maps the token to a machine-readable `recovery_surface=takeover|reinstall|attention` (never guessed from prose) — the flow can then offer the right next step. A gated-install viability abort with the engine's coded `package_inconsistent` reason routes to `recovery_surface=reinstall`, and an attributed boot refusal (e.g. `consent_seed_unwritable`) routes through its own table (`storage`/`takeover`/`attention`); refused launchd transactions serialize `"verified":true`, since the verified transaction did run. On Windows/Linux the ladder degrades to plain install/start (no gates, no rollback), and `ensure --json` reports `"verified":false` so the flow's copy can say so. On macOS a resolvable profile is required: a unit baked without one could never pass the start gate's identity half, so `ensure` refuses with `no_profile_configured` rather than report a dead-end install. `ensure` never mutates into an ambiguous state: an unreadable probe, an active transaction, an orphaned label (including a running one whose unit file has disappeared) or a stale marker all fail closed to attention with a coded reason — the ambiguity checks precede even the "already enabled" arm. + `start --verify` polls the started service until it answers a well-formed local-socket hello **and** the OS-reported job pid matches the daemon's own validated pid, rolling back (stopping the service again, plist retained) and exiting non-zero with a coded stderr token (e.g. `verify_readiness_timeout`) if that never happens within the poll budget — useful for scripted installs that need to know the daemon is actually up before proceeding. **`--verify` is macOS/launchd only in this release** — `start --verify` is rejected on Linux/Windows, same as `install --verify` below. `install --verify` (fresh installs only — a service that's already installed exits with the coded `verify_contended`, since clearing an existing label is `--replace`'s job) additionally requires the started daemon's reported version, protocol version, and reported name to match the installing CLI's own expectations, and rechecks the unit file on disk against a fingerprint taken at write time — so a foreign writer replacing the file between install and the recheck is detected (`verify_restore_verification`) rather than silently accepted. On any failure it rolls back by uninstalling the unit it just wrote (never a foreign one) and exits with a coded stderr token. **`--verify` is macOS/launchd only in this release** — `install --verify` is rejected on Linux/Windows. diff --git a/docs/superpowers/specs/2026-08-24-ai2039-daemon-service-ensure-design.md b/docs/superpowers/specs/2026-08-24-ai2039-daemon-service-ensure-design.md new file mode 100644 index 000000000..0496d07aa --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-ai2039-daemon-service-ensure-design.md @@ -0,0 +1,104 @@ +# AI-2039 — `kcap daemon service ensure`: the flow's daemon-install ladder + +## Problem + +The first-run flow's Done detour ("reach this machine from anywhere") needs one action that makes +the daemon service-installed and running. Today the ladder exists only in `Capacitor.App` (the +Avalonia wizard being retired by AI-2053): `DaemonStepViewModel` classifies a fresh +`service status --json`, `DaemonMutationLane` dispatches and classifies the mutation, and +`ReasonRouting` maps `start_gate_reason=` tokens to a recovery surface. The CLI has all the +primitives — `service status --json`, `service install --verify`, `service start --verify`, the +`ServiceVerify` transaction engine with coded exits — but nothing that composes them into the +ladder the flow needs. + +## The ladder + +From a fresh `service status --json`: + +| Fresh state | Action | Notes | +| --- | --- | --- | +| no unit | `service install --verify` | bakes `KCAP_CONSENT_SEED_DEFAULT=prompt` — the app-installed daemon is born `prompt` | +| unit present, stopped | `service start --verify` | gated when the invoking launcher carries the seed directive | +| running (validated daemon pid, and on launchd the service job owning it) | none | already enabled — reachable is the flow's done state; service-vs-manual ownership is the detour's visibility question, not the ladder's | +| anything ambiguous | none — fail closed | unknown probe, txn marker/active, orphan label (running included — a loaded label whose unit file is gone cannot survive a relaunch), running-unconfirmed — attention, never guessed; the repair rows precede even the success arm so ambiguity can never ride a validated pid into "already enabled" | + +Gate failures surface as coded exits (`verify_start_gate` = 28, `verify_start_gate_drift` = 29) +with one machine-readable `start_gate_reason=` line, mapped by `ReasonRouting` to **takeover**, +**reinstall** or **fail-closed attention** — never derived from prose. The engine's other coded +reasons travel the same wire: a gated-install viability abort with `package_inconsistent` routes +to **reinstall**, and an attributed readiness-timeout boot refusal (`consent_seed_unwritable`, +`server_expectation_mismatch`, …) routes through `ReasonRouting.ForBootRefusal` to +**storage**/**takeover**/**attention** — the flow never sees a bare `verify_viability`/ +`verify_readiness_timeout` when the engine already named a reason. Refused launchd transactions +serialize `verified:true` (the verified transaction did run); `verified:false` remains the plain +off-macOS signal. On launchd a resolvable profile is a hard precondition: a unit baked without one +could never pass the start gate's identity half (it demands a non-empty invoking `KCAP_PROFILE`), +so ensure refuses with `no_profile_configured` before any mutation rather than reporting a +dead-end install. Pinning a profile strips any ambient `KCAP_URL` from the unit env — URL +resolution outranks profile resolution, so baking both would point the daemon at the wrong server +and refuse on the expectation mismatch; the pin is the sole URL authority. + +## Changes + +### 1. `ReasonRouting` / `RecoverySurface` move to Core + +Both `Capacitor.Cli` and `Capacitor.App` reference `Capacitor.Cli.Core`; the CLI cannot reference +the app. The pinned token→surface table therefore moves from +`Capacitor.App/Services/Mutation/MutationModel.cs` to Core (namespace `Capacitor.Cli.Core`), and +the app's existing references (its `DaemonMutationLane`, `App.axaml.cs` presentation switch, +`MutationRequestFactory`) pick it up via `using`. The app's tests for the table move with it. This +is the same rescue shape as AI-2167 (classes leaving the app before AI-2053 deletes it). + +### 2. `kcap daemon service ensure` + +New verb in `DaemonServiceCommands`: + +- fresh status query + the same lifecycle evidence `status --json` reads (probe/state/unit + presence, txn marker/active, validated daemon pid, and — for the already-enabled arm on launchd — + the service job's own pid, which must own the validated daemon); +- pure classification (unit absent → install; unit present + stopped → start; running → already + enabled; ambiguous → attention), mirroring `ServiceStatusRender`'s "unknown never masquerades"; +- install path builds the spec env via `ServiceEnvironment.Capture` and **force-bakes** + `KCAP_CONSENT_SEED_DEFAULT=prompt` plus the `KCAP_EXPECT_SERVER_URL` pin (the identity half of + the start gate re-reads it) and the `KCAP_DAEMON_SUPERVISED` pin — the app's `MutationEnv` + equivalent in-process; +- launchd runs the verified transaction with a `gateEnv` carrying the seed directive (so the + start gate fires — this is the app-managed start contract); other platforms run the plain + install/start (the degraded end state whose copy the flow reflects); +- machine-readable result: coded exit + `start_gate_reason=` (already emitted by the engine) + a + `recovery_surface=` line and a `--json` payload the flow can act on without parsing prose — + including on the pre-flight refusals (no server configured, daemon binary missing), which emit + a `refused` row rather than an undocumented empty stdout. + +### 3. README + `help-daemon.txt` + +Document `ensure` in the service command list and help text, noting the macOS/launchd `--verify` +scope and the plain install on Windows/Linux. + +## Established: the Windows answer + +AI-2039's open question asked what the daemon actually does on Windows, because the answer changes +copy on two screens (the install ladder's and the Done detour's). Established while wiring the +install: + +- The daemon itself is **fully cross-platform**: ConPTY (`Pty/Windows/ConPtyProcess`), a Windows + Scheduled Task service manager, job objects, a win-x64 npm package, and Windows CI legs. Hosted + agents (ACP-over-stdio runtimes + ConPTY for PTY vendors) and the server→daemon SignalR path + (agent launches, permission prompts) work there. So the detour's pitch — "reach this machine + from anywhere, runs happen here" — holds on Windows. +- What is Windows-gated: the **local `kcap agent` terminal drive** ("not supported on Windows + yet"), and the **verified transaction** (`install/start --verify`) which is **launchd-only** — on + Windows/Linux the ladder degrades to plain install/start with no gates, no `start_gate_reason=`, + no takeover/reinstall classification, and no rollback. + +So the flow shows the detour everywhere; the copy reflects plain install off-macOS (`--json` +reports `verified:false`). The Avalonia wizard being macOS-only and the MAUI host being +maccatalyst+windows are both irrelevant to this decision — the browser flow is the first surface +that can give "which platforms does first run actually support" a single answer. + +## Out of scope + +- Flow/screen wiring (AI-2048), the flow's CLI create+poll half (AI-2156), and anything server-side. +- The Avalonia wizard itself (AI-2053) — only the shared mapping moves now. +- Takeover as an ensure *action*: ensure classifies and reports the surface; performing + `install --replace --verify` stays a separate, consent-bearing step the flow offers. diff --git a/src/Capacitor.App/Services/Mutation/MutationModel.cs b/src/Capacitor.App/Services/Mutation/MutationModel.cs index 8c40ee00b..2c047c39e 100644 --- a/src/Capacitor.App/Services/Mutation/MutationModel.cs +++ b/src/Capacitor.App/Services/Mutation/MutationModel.cs @@ -1,3 +1,5 @@ +using Capacitor.Cli.Core; + namespace Capacitor.App.Services.Mutation; public enum MutationVerb { Install, Replace, StartVerified, DetachedStart } @@ -5,8 +7,6 @@ public enum MutationVerb { Install, Replace, StartVerified, DetachedStart } public sealed record MutationRequest( MutationVerb Verb, string Profile, string CanonicalServer, string DaemonName); -public enum RecoverySurface { Takeover, Reinstall, Attention, Storage, None } - /// One classified result of a daemon-mutation attempt (spec §3/§4); the mutation lane maps every raw outcome onto exactly one case. public abstract record MutationOutcome { public sealed record Succeeded : MutationOutcome; @@ -19,23 +19,3 @@ public sealed record Failed(int ExitCode, string? Reason, RecoverySurface Surfac } public sealed record OutcomeEnvelope(MutationRequest Request, MutationOutcome Outcome); - -/// Maps a machine-readable reason token to a recovery surface (spec §3/§4 pinned tables); unknown tokens fail closed to Attention. -public static class ReasonRouting { - public static RecoverySurface ForStartGate(string token) => token switch { - "directive_missing" or "directive_invalid" or "identity_mismatch" or "foreign_binary" => RecoverySurface.Takeover, - "package_inconsistent" => RecoverySurface.Reinstall, - _ => RecoverySurface.Attention, - }; - - public static RecoverySurface ForDaemonStart(string token) => token switch { - "package_inconsistent" => RecoverySurface.Reinstall, - _ => RecoverySurface.Attention, - }; - - public static RecoverySurface ForBootRefusal(string token) => token switch { - "server_expectation_mismatch" or "consent_seed_invalid" => RecoverySurface.Takeover, - "consent_seed_unwritable" => RecoverySurface.Storage, - _ => RecoverySurface.Attention, - }; -} diff --git a/src/Capacitor.App/Services/Mutation/MutationRequestFactory.cs b/src/Capacitor.App/Services/Mutation/MutationRequestFactory.cs index e24d865ce..790e4f1a5 100644 --- a/src/Capacitor.App/Services/Mutation/MutationRequestFactory.cs +++ b/src/Capacitor.App/Services/Mutation/MutationRequestFactory.cs @@ -1,3 +1,4 @@ +using Capacitor.Cli.Core; using Capacitor.Cli.Core.Auth; namespace Capacitor.App.Services.Mutation; diff --git a/src/Capacitor.Cli.Core/RecoveryRouting.cs b/src/Capacitor.Cli.Core/RecoveryRouting.cs new file mode 100644 index 000000000..8826545de --- /dev/null +++ b/src/Capacitor.Cli.Core/RecoveryRouting.cs @@ -0,0 +1,32 @@ +namespace Capacitor.Cli.Core; + +/// +/// The recovery a failed daemon mutation offers its caller (spec §3/§4 pinned tables). Shared by the +/// CLI's daemon service ensure ladder and the (retiring) desktop supervisor, so the pinned +/// token→surface mapping has exactly one home. +/// +public enum RecoverySurface { Takeover, Reinstall, Attention, Storage, None } + +/// +/// Maps a machine-readable reason token to a recovery surface (spec §3/§4 pinned tables); unknown +/// tokens fail closed to Attention — a newer CLI's reason must not be destructively interpreted by +/// an older consumer. +/// +public static class ReasonRouting { + public static RecoverySurface ForStartGate(string token) => token switch { + "directive_missing" or "directive_invalid" or "identity_mismatch" or "foreign_binary" => RecoverySurface.Takeover, + "package_inconsistent" => RecoverySurface.Reinstall, + _ => RecoverySurface.Attention, + }; + + public static RecoverySurface ForDaemonStart(string token) => token switch { + "package_inconsistent" => RecoverySurface.Reinstall, + _ => RecoverySurface.Attention, + }; + + public static RecoverySurface ForBootRefusal(string token) => token switch { + "server_expectation_mismatch" or "consent_seed_invalid" => RecoverySurface.Takeover, + "consent_seed_unwritable" => RecoverySurface.Storage, + _ => RecoverySurface.Attention, + }; +} diff --git a/src/Capacitor.Cli.Core/Resources/help-daemon.txt b/src/Capacitor.Cli.Core/Resources/help-daemon.txt index 5b6f5a1db..3b71a8e09 100644 --- a/src/Capacitor.Cli.Core/Resources/help-daemon.txt +++ b/src/Capacitor.Cli.Core/Resources/help-daemon.txt @@ -74,6 +74,27 @@ Subcommands for service (per-user; auto-restarts on crash/SIGKILL, starts at log stop [--name N] Stop the running service (unloads/deregisters it with the OS supervisor; stays installed — returns at next login or `service start`). + ensure [--name N] [--profile P] [--json] + Flow-driven ladder: from a fresh status read, install + when there is no unit (baking the born-prompt consent + directive) or start when the unit is present but + stopped; "already enabled" when the daemon is running + (on launchd only when the service job owns the + validated daemon pid). On macOS/launchd both arms run + the verified transaction, so a gate refusal exits with + the coded verify exit plus start_gate_reason=, mapped + to recovery_surface=takeover|reinstall|attention — + never guessed from prose; coded viability/refusal + reasons route likewise (package_inconsistent → + reinstall, boot refusals → storage/takeover), and + refused launchd transactions report verified:true. + Windows/Linux degrade to plain install/start (no + gates; --json reports verified:false). macOS requires + a resolvable profile (no_profile_configured refusal + otherwise). Ambiguous states (unknown probe, active + transaction, orphan label — running included — stale + marker) fail closed to attention with a coded reason + — never mutated into. status [--name N] [--json] Show installed / running state. --json emits a machine-readable snapshot (service/job/daemon pids, diff --git a/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs b/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs index fcf2caa04..de084ce3c 100644 --- a/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs +++ b/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs @@ -33,6 +33,7 @@ public static async Task DispatchAsync(DaemonStore store, string[] args) { "uninstall" => await verbs.Uninstall(), "start" => await verbs.Start(rest), "stop" => await verbs.Stop(), + "ensure" => await verbs.Ensure(rest), "status" => rest.Contains("--json") ? await verbs.StatusJson() : await verbs.Status(), _ => Usage(), }; @@ -287,6 +288,237 @@ async Task StatusJson() { return exitCode; } + /// + /// The flow's daemon-install ladder: from a fresh status read, install or start so the machine is + /// reachable, baking the born-prompt consent-seed directive on install and gating the start + /// exactly as an app-managed start is. A gate refusal exits with the verify transaction's coded + /// exit plus one start_gate_reason= line, mapped machine-readably to + /// recovery_surface=takeover|reinstall|attention (the pinned + /// table) — never guessed at from prose. On non-launchd the ladder degrades to plain install/start + /// (no gates, no rollback); the JSON reports verified:false so the flow's copy can say so. + /// On launchd a resolvable profile is required up front — a unit baked without one could never + /// pass the start gate's identity half, so ensure refuses rather than report a dead-end install. + /// + internal async Task Ensure(string[] args) { + var json = args.Contains("--json"); + // Same explicit-flag-wins-then-resolved default as Install: the gate's identity half needs a + // non-empty KCAP_PROFILE, so a bare `ensure` on launchd must still carry the resolved name. + var profileName = DaemonCommands.ExtractFlagValue(args, "--profile") ?? AppConfig.ResolvedProfile?.ProfileName; + + var query = manager.Query(id); + var daemonPid = DaemonPidProbe.ValidatedPid(store, id); + var txnActive = ServiceTxnLock.IsHeld(store, id); + var txnMarker = ServiceTxnMarker.Exists(store, id); + + // The classifier gets the job pid plus whether THIS manager can supply one: on launchd, + // already-enabled additionally requires the running job to own the validated daemon pid + // (systemd/Windows cannot report one, so their coarser verified:false arm stands). + var decision = EnsureClassifier.Classify( + query.Probe, query.State, query.UnitPresent, daemonPid, txnMarker, txnActive, + query.JobPid, manager is LaunchdServiceManager); + + var state = ServiceStateToken(query); + + // On launchd the start gate's identity half demands a non-empty invoking KCAP_PROFILE, so a + // unit baked without one can never be gated-started — installing it bakes a dead end. Fail + // closed with a coded reason rather than letting the install report success. (A URL-only + // unit is only coherent off-macOS, where there is no gate; there the plain arms stand.) + if (LaunchdProfileRefusal(manager is LaunchdServiceManager, profileName, decision.Action)) { + var refusedAction = decision.Action == EnsureAction.Install ? "install" : "start"; + return await Report(new ServiceEnsureJson(id, state, refusedAction, "refused", null, "no_profile_configured"), 1, json); + } + + switch (decision.Action) { + case EnsureAction.AlreadyEnabled: + return await Report(new ServiceEnsureJson(id, state, "none", "already_enabled"), 0, json); + case EnsureAction.Attention: + return await Report(new ServiceEnsureJson(id, state, "none", "attention", null, decision.Reason), 1, json); + case EnsureAction.Install: + return await EnsureInstall(profileName, state, json); + case EnsureAction.Start: + return await EnsureStart(profileName, state, json); + default: + // Unreachable: Classify is total over EnsureAction. Kept as a fail-closed tail so an + // added enum member can never fall through to exit 0. + return await Report(new ServiceEnsureJson(id, state, "none", "attention", null, "status_unknown"), 1, json); + } + } + + /// + /// The server URL the unit bakes as KCAP_EXPECT_SERVER_URL, or null when none resolves. + /// An explicit --profile P resolves P's own URL (a flag naming a different profile than + /// the active one must not bake the active profile's URL — the unit would refuse to boot); + /// otherwise the resolved profile's. + /// + static async Task ResolveServerUrlAsync(string? profileName) => + profileName is null + ? AppConfig.ResolvedProfile?.ServerUrl + : await ResolveNamedProfileServerUrlAsync(profileName); + + static async Task ResolveNamedProfileServerUrlAsync(string profileName) { + var config = await AppConfig.LoadProfileConfig(); + return config.Profiles.TryGetValue(profileName, out var p) ? p.ServerUrl : null; + } + + /// Wire token for the fresh-read state. An Unknown probe is reported as "unknown" rather + /// than falling through to a state value — the same never-masquerade rule status --json applies. + /// A unit that is present but stopped reads NotInstalled on launchd (the label is not + /// loaded) yet is genuinely installed — derive from unit presence so the JSON never pairs + /// state:"not_installed" with action:"start". + internal static string ServiceStateToken(ServiceQuery q) => q.Probe == LabelProbe.Unknown + ? "unknown" + : q.State switch { + ServiceState.NotInstalled when q.UnitPresent => "installed", + ServiceState.NotInstalled => "not_installed", + ServiceState.Installed => "installed", + ServiceState.Running => "running", + _ => "unknown", + }; + + /// Emit one ensure result: JSON on stdout when --json, a human line otherwise. + /// The exit code is decided by the caller, never derived from the outcome string. + async Task Report(ServiceEnsureJson result, int exit, bool json) { + if (json) { + await Console.Out.WriteLineAsync(ServiceEnsureRender.RenderJson(result)); + return exit; + } + + var line = result.Outcome switch { + "already_enabled" => $"Service '{id}' is already enabled.", + "attention" => $"Service '{id}': {result.Reason} — no changes made.", + "installed" => $"Service '{id}' installed ({(result.Verified ? "verified" : "plain")}).", + "started" => $"Service '{id}' started ({(result.Verified ? "verified" : "plain")}).", + "refused" => $"Service '{id}': {result.Reason}{(result.Recovery is { } r ? $" — {r} needed" : "")}.", + _ => $"Service '{id}': unexpected outcome '{result.Outcome}'.", + }; + await Console.Out.WriteLineAsync(line); + return exit; + } + + /// Install arm of : force-bake the born-prompt directive (and the + /// expected-server pin the identity half of the start gate re-reads), then run the verified + /// transaction on launchd or a plain install elsewhere. + async Task EnsureInstall(string? profileName, string state, bool json) { + var serverUrl = await ResolveServerUrlAsync(profileName); + if (serverUrl is null) + return await Report(new ServiceEnsureJson(id, state, "install", "refused", null, "no_server_configured"), 1, json); + + var daemonPath = UnitIdentity.ResolveDaemonBinary(); + if (daemonPath is null) { + await Console.Error.WriteLineAsync(DaemonCommands.DaemonNotFoundMessage()); + return await Report(new ServiceEnsureJson(id, state, "install", "refused", null, "daemon_not_found"), 1, json); + } + + // The app's MutationEnv overlay, in-process: seed born-prompt + the expected server the gate + // and the daemon's own boot both re-read. Everything else comes from the ambient capture. + var env = EnsureUnitEnv(profileName, serverUrl, ServiceEnvironment.Capture(profileName)); + env["KCAP_DAEMON_SUPERVISED"] = id; + var logPath = PathHelpers.ConfigPath($"daemon-{id}.log"); + var spec = new ServiceSpec(id, daemonPath, logPath, env, []); + + StartGateReason? gateReason = null; + string? viabilityReason = null; + string? bootRefusalToken = null; + int exit; + if (manager is LaunchdServiceManager) { + var profileUrlValid = await ServiceInstallViability.PinnedProfileServerUrlValidAsync(env); + var engine = new ServiceVerify(store, (LaunchdServiceManager)manager, + n => DaemonPidProbe.ValidatedPid(store, n), (n, t) => HelloProbe.RunAsync(store, n, t), + TimeProvider.System, profileViable: () => profileUrlValid, gateEnv: EnsureGateEnv(profileName, serverUrl)); + exit = await engine.InstallVerifiedAsync(spec, replace: false, CapacitorVersion.Current()); + // InstallVerifiedAsync never returns StartGate — its gated refusals are viability/drift — + // so gateReason stays null here and StartGate recovery never fires from the install arm. + gateReason = engine.LastGateReason; + viabilityReason = engine.LastViabilityReason; + bootRefusalToken = engine.LastBootRefusalToken; + } else { + exit = await InstallPlain(spec, startNow: true); + } + + if (exit != 0) return await EnsureFailure(exit, gateReason, state, "install", json, viabilityReason, bootRefusalToken); + return await Report(new ServiceEnsureJson(id, state, "install", "installed", Verified: manager is LaunchdServiceManager), 0, json); + } + + /// Pure: whether a launchd ensure run must refuse for want of a profile name. The start + /// gate's identity half demands a non-empty invoking KCAP_PROFILE, so a unit baked without + /// one can never be gated-started — installing it would bake a dead end. Only launchd gates at + /// all, so URL-only installs stay coherent off-macOS (no gate); the predicate takes + /// isLaunchd rather than the manager so it is testable without a real launchd manager. + internal static bool LaunchdProfileRefusal(bool isLaunchd, string? profileName, EnsureAction action) => + isLaunchd && string.IsNullOrEmpty(profileName) && action is EnsureAction.Install or EnsureAction.Start; + + /// Pure: the unit env for an ensure install — the ambient capture, overlaid with the + /// born-prompt directive and the expected-server pin, so both are deliberate unit content + /// regardless of what the installing shell exported. prompt WINS over an ambient value + /// (a refusal exported as deny/allow must not survive into the unit): the flow's + /// contract is "an app-installed daemon is born prompt", and the gate's identity half re-reads + /// the expect pin, so a unit without it can never pass a later gated start. + /// When a profile IS pinned, the ambient KCAP_URL is stripped: URL resolution + /// outranks profile resolution (ProfileResolver), so a unit that bakes both would resolve the + /// ambient URL and refuse on the expectation mismatch — the pin must be the sole URL authority. + internal static Dictionary EnsureUnitEnv( + string? profileName, string serverUrl, IReadOnlyDictionary ambient) { + var env = new Dictionary(ambient) { + ["KCAP_CONSENT_SEED_DEFAULT"] = "prompt", + ["KCAP_EXPECT_SERVER_URL"] = serverUrl, + }; + // Same "explicit pin wins" rule as ServiceEnvironment.Build — only a real profile is pinned. + if (!string.IsNullOrEmpty(profileName)) { + env["KCAP_PROFILE"] = profileName; + env.Remove("KCAP_URL"); + } + return env; + } + + /// Start arm of : the gated, app-managed start on launchd (the gate + /// fires because carries the directive), plain elsewhere. + async Task EnsureStart(string? profileName, string state, bool json) { + var serverUrl = await ResolveServerUrlAsync(profileName); + if (serverUrl is null) + return await Report(new ServiceEnsureJson(id, state, "start", "refused", null, "no_server_configured"), 1, json); + + StartGateReason? gateReason = null; + string? bootRefusalToken = null; + int exit; + if (manager is LaunchdServiceManager) { + var engine = new ServiceVerify(store, (LaunchdServiceManager)manager, + n => DaemonPidProbe.ValidatedPid(store, n), (n, t) => HelloProbe.RunAsync(store, n, t), + TimeProvider.System, gateEnv: EnsureGateEnv(profileName, serverUrl)); + exit = await engine.StartVerifiedAsync(id); + gateReason = engine.LastGateReason; + bootRefusalToken = engine.LastBootRefusalToken; + } else { + exit = await StartPlain(); + } + + if (exit != 0) return await EnsureFailure(exit, gateReason, state, "start", json, bootRefusalToken: bootRefusalToken); + return await Report(new ServiceEnsureJson(id, state, "start", "started", Verified: manager is LaunchdServiceManager), 0, json); + } + + /// Gate env for ensure's in-process engine: the directive the app's MutationEnv would + /// overlay on a child, plus the profile/expect the identity half re-reads. Falls through to the + /// process env for anything else, so an operator's own KCAP_* exports still apply. + static Func EnsureGateEnv(string? profileName, string? serverUrl) => k => k switch { + "KCAP_CONSENT_SEED_DEFAULT" => "prompt", + "KCAP_PROFILE" => profileName, + "KCAP_EXPECT_SERVER_URL" => serverUrl, + _ => Environment.GetEnvironmentVariable(k), + }; + + /// Shared non-success tail: the verify transaction already emitted its coded token and + /// start_gate_reason= line; the pure derives the JSON's + /// recovery/reason fields — including the engine's structured viability/boot-refusal reasons — + /// and the recovery_surface= line is re-emitted when a surface is mapped. The verified + /// flag matches this run's transaction (true on the launchd arms), and the exit code always + /// passes through unchanged. + async Task EnsureFailure(int exit, StartGateReason? gateReason, string state, string action, bool json, + string? viabilityReason = null, string? bootRefusalToken = null) { + var verified = manager is LaunchdServiceManager; + var (recovery, reason) = EnsureFailureMap.Map(exit, gateReason, verified, viabilityReason, bootRefusalToken); + if (recovery is not null) await Console.Error.WriteLineAsync($"recovery_surface={recovery}"); + return await Report(new ServiceEnsureJson(id, state, action, "refused", recovery, reason, Verified: verified), exit, json); + } + /// /// UX-evidence-only re-read of the installed unit's baked environment (spec §3): which profile, /// server URL, expectation and consent-seed default it was installed with. Sourced by re-reading the @@ -328,7 +560,7 @@ async Task StatusJson() { } static int Usage() { - Console.Error.WriteLine("Usage: kcap daemon service [--name N]"); + Console.Error.WriteLine("Usage: kcap daemon service [--name N]"); Console.Error.WriteLine(); Console.Error.WriteLine(" install [--name N] [--profile P] [--max-agents N] [--no-start] [--replace] [--verify]"); Console.Error.WriteLine(" --verify (macOS/launchd only) polls readiness/version/ownership and rolls back on failure"); @@ -338,6 +570,8 @@ static int Usage() { Console.Error.WriteLine(" start [--name N] [--verify] Start the installed service now"); Console.Error.WriteLine(" --verify (macOS/launchd only) polls readiness/ownership and rolls back on failure"); Console.Error.WriteLine(" stop [--name N] Stop the running service (stays installed)"); + Console.Error.WriteLine(" ensure [--name N] [--profile P] [--json] Install-or-start from a fresh status read"); + Console.Error.WriteLine(" (bakes the born-prompt consent seed; gate refusals emit recovery_surface=)"); Console.Error.WriteLine(" status [--name N] [--json] Show installed/running state (--json for machine-readable output)"); return 1; } diff --git a/src/Capacitor.Cli/Commands/ServiceEnsure.cs b/src/Capacitor.Cli/Commands/ServiceEnsure.cs new file mode 100644 index 000000000..952f096b7 --- /dev/null +++ b/src/Capacitor.Cli/Commands/ServiceEnsure.cs @@ -0,0 +1,161 @@ +using Capacitor.Cli.Core; +using Capacitor.Cli.Services; + +namespace Capacitor.Cli.Commands; + +/// +/// The decision kcap daemon service ensure makes from a fresh status read (spec §3.4): +/// which verb to run, or which fail-closed attention row to report when no verb is safe. Pure — +/// no I/O, no mutations — so the ladder is directly testable. Mirrors the wizard's step-7 matrix +/// reduced to the flow's needs; every ambiguous row fails closed to attention, never guessed. +/// +public enum EnsureAction { + /// Service already running under a validated daemon pid — nothing to do. + AlreadyEnabled, + + /// No unit: install (baking the consent-seed directive). + Install, + + /// Unit present but stopped: start. + Start, + + /// Nothing was mutated; names the ambiguous row. + Attention +} + +/// One ladder classification. is non-null only for +/// — a coded token naming the row, never prose. +public readonly record struct EnsureDecision(EnsureAction Action, string? Reason = null); + +/// +/// Pure ladder classifier: from the same evidence status --json reads, decide install / +/// start / already-enabled, or fail closed. Precedence mirrors the wizard's step-7 matrix: an +/// unreadable probe, a live transaction, then the repair rows (orphan label, stale marker) — +/// ALL ahead of any success or mutation row, so an ambiguity can never ride a validated pid +/// into "already enabled" (the fail-closed rule the docs state unqualified). +/// +internal static class EnsureClassifier { + public static EnsureDecision Classify( + LabelProbe probe, ServiceState state, bool unitPresent, + int? daemonPid, bool txnMarker, bool txnActive, + int? jobPid = null, bool jobPidAvailable = false) { + // Unreadable evidence is the same fail-closed as status --json's unknown state: an unknown + // probe must never masquerade as the well-defined not_installed row. + if (probe == LabelProbe.Unknown) + return new EnsureDecision(EnsureAction.Attention, "status_unknown"); + + // A held transaction is never mutated into — the wizard waits it out; a CLI verb reports + // it and lets the flow retry. + if (txnActive) + return new EnsureDecision(EnsureAction.Attention, "txn_active"); + + // The repair rows precede the success arm. A loaded label whose unit file is gone — whether + // it reads Installed or Running — is an installation that cannot survive a relaunch: orphaned, + // never "already enabled". A stale marker means a prior transaction never reached a terminal + // state: ambiguous, never success. + if ((state == ServiceState.Installed || state == ServiceState.Running) && !unitPresent) + return new EnsureDecision(EnsureAction.Attention, "orphan_label"); + + if (txnMarker) + return new EnsureDecision(EnsureAction.Attention, "stale_marker"); + + // "Already enabled" means the service job OWNS the validated daemon pid. On launchd the + // manager reports the job's pid, and it must equal the validated daemon pid — a Running + // label whose job pid is absent/unparseable or points elsewhere is unconfirmed, not success. + // Managers that cannot supply a job pid (systemd, Windows) keep the coarser check; their + // already-enabled arm is the documented verified:false one. + if (state == ServiceState.Running && daemonPid is not null + && (!jobPidAvailable || jobPid == daemonPid)) + return new EnsureDecision(EnsureAction.AlreadyEnabled); + + // Running without a validated pid, or on launchd without the job owning it, is unconfirmed. + if (state == ServiceState.Running) + return new EnsureDecision(EnsureAction.Attention, "running_unconfirmed"); + + if (unitPresent) return new EnsureDecision(EnsureAction.Start); + + if (state == ServiceState.NotInstalled) return new EnsureDecision(EnsureAction.Install); + + return new EnsureDecision(EnsureAction.Attention, "status_unknown"); + } +} + +/// Maps a to its machine-readable wire token. +public static class RecoverySurfaceTokens { + public static string Token(RecoverySurface surface) => surface switch { + RecoverySurface.Takeover => "takeover", + RecoverySurface.Reinstall => "reinstall", + RecoverySurface.Attention => "attention", + RecoverySurface.Storage => "storage", + _ => "none", + }; +} + +/// +/// Pure: the wire fields for an ensure failure — recovery and reason. Kept separate +/// from I/O so the JSON contract is testable without a real service manager. A gate refusal maps +/// its start_gate_reason= token through (takeover/reinstall/ +/// attention); drift is the gate's TOCTOU re-check refusing, surfaced as the attention row with +/// its token, never auto-retried; a gated-install viability abort carries the engine's coded +/// viability_reason= token (package_inconsistent → reinstall) when one was emitted; an +/// attributed readiness-timeout boot refusal carries the marker's coded token through +/// (takeover/storage/attention) instead of collapsing +/// to the generic verify token. Every other exit on a verified run carries its verify_* +/// token; a plain (non-launchd) failure carries a plain token, never a verify_* one — the +/// verify prefix must not claim a transaction that never ran. +/// +internal static class EnsureFailureMap { + public static (string? Recovery, string? Reason) Map( + int exit, StartGateReason? gateReason, bool verified, + string? viabilityReason = null, string? bootRefusalToken = null) { + if (exit == VerifyExit.StartGate) { + var reason = gateReason is { } r ? ServiceVerify.GateReasonToken(r) : null; + var surface = reason is not null + ? ReasonRouting.ForStartGate(reason) + : RecoverySurface.Attention; + return (RecoverySurfaceTokens.Token(surface), reason); + } + + if (exit == VerifyExit.StartGateDrift) + // Drift is the gate's TOCTOU re-check refusing — a gate failure, so it surfaces the + // attention row, never auto-retried (same rule as the app's own table); the reason token + // keeps the JSON and the human line from reading empty. + return (RecoverySurfaceTokens.Token(RecoverySurface.Attention), VerifyExitToken(exit)); + + // A gated-install viability abort with a coded reason: the engine's own package_inconsistent + // evidence routes to reinstall (the same token the start gate's table maps), never the + // generic verify_viability token that says nothing about what to do next. + if (exit == VerifyExit.Viability && viabilityReason is not null) + return (RecoverySurfaceTokens.Token(ReasonRouting.ForDaemonStart(viabilityReason)), viabilityReason); + + // An attributed readiness-timeout boot refusal carries the marker's coded token — the flow + // can route consent_seed_unwritable to storage or server_expectation_mismatch to takeover + // instead of seeing only verify_readiness_timeout. Unattributed timeouts fall through. + if (exit == VerifyExit.ReadinessTimeout && bootRefusalToken is not null) + return (RecoverySurfaceTokens.Token(ReasonRouting.ForBootRefusal(bootRefusalToken)), bootRefusalToken); + + // Not a gate refusal. Verified runs carry their verify_* token; plain runs never wear the + // verify prefix (exit 1 is lock contention or a manager error, not a verify outcome). + return (null, verified ? VerifyExitToken(exit) : "plain_failure"); + } + + static string VerifyExitToken(int exit) => exit switch { + VerifyExit.Contended => "verify_contended", + VerifyExit.Viability => "verify_viability", + VerifyExit.BootoutUnknown => "verify_bootout_unknown", + VerifyExit.StopUnconfirmed => "verify_stop_unconfirmed", + VerifyExit.ReadinessTimeout => "verify_readiness_timeout", + VerifyExit.HelloValidation => "verify_hello_validation", + VerifyExit.RollbackBudget => "verify_rollback_budget", + VerifyExit.RestoreVerification => "verify_restore_verification", + VerifyExit.StartGate => "verify_start_gate", + VerifyExit.StartGateDrift => "verify_start_gate_drift", + _ => $"verify_unknown_{exit}", + }; +} + +/// Pure renderer for the ensure result — kept separate from I/O so it's directly testable. +internal static class ServiceEnsureRender { + public static string RenderJson(ServiceEnsureJson result) => + System.Text.Json.JsonSerializer.Serialize(result, ServiceJsonContext.Default.ServiceEnsureJson); +} diff --git a/src/Capacitor.Cli/Commands/ServiceStatusJson.cs b/src/Capacitor.Cli/Commands/ServiceStatusJson.cs index 532d06af2..674c0ce89 100644 --- a/src/Capacitor.Cli/Commands/ServiceStatusJson.cs +++ b/src/Capacitor.Cli/Commands/ServiceStatusJson.cs @@ -17,8 +17,28 @@ public sealed record ServiceStatusJson( string? UnitProfile = null, string? UnitServerUrl = null, string? UnitExpectedServer = null, string? UnitConsentSeed = null); +/// Machine-readable outcome for kcap daemon service ensure — what the ladder did, and +/// (on a refusal) which recovery surface the flow should offer. is non-null on +/// a refusal the engine mapped to a surface: the start gate (exit 28, mapped takeover/reinstall/ +/// attention via the pinned table), the gate's TOCTOU +/// re-check drift (exit 29, always attention — never auto-retried, matching the app's own table), a +/// gated-install viability abort with the engine's coded package_inconsistent reason +/// (reinstall), or an attributed readiness-timeout boot refusal (its marker token routed through +/// — takeover/storage/attention). +/// is the start_gate_reason= token for a start-gate refusal, +/// verify_start_gate_drift for drift, the coded viability/boot-refusal token when one was +/// attributed, the verify_* token for any other verified-transaction failure, or +/// plain_failure for a degraded (non-launchd) failure. reports whether +/// THIS run performed the launchd verified transaction — true on launchd refusals as well as +/// successes, false on plain install/start and on no-op rows; the flow's copy must key off +/// , not off verified. +public sealed record ServiceEnsureJson( + string ServiceId, string State, string Action, string Outcome, + string? Recovery = null, string? Reason = null, bool Verified = false); + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] [JsonSerializable(typeof(ServiceStatusJson))] +[JsonSerializable(typeof(ServiceEnsureJson))] public partial class ServiceJsonContext : JsonSerializerContext; /// Pure renderer for the status JSON — kept separate from I/O so it's directly testable. diff --git a/src/Capacitor.Cli/Services/ServiceVerify.cs b/src/Capacitor.Cli/Services/ServiceVerify.cs index 9e6e236d8..8e4757e85 100644 --- a/src/Capacitor.Cli/Services/ServiceVerify.cs +++ b/src/Capacitor.Cli/Services/ServiceVerify.cs @@ -167,6 +167,24 @@ readPlist is null && plistExists is null /// fail-closed placeholder in dev/test builds. readonly Func _digestMatches = digestMatches ?? DaemonDigest.Matches; + /// The gate reason of the LAST Phase-A refusal, for an in-process caller (the ensure + /// ladder) that must map to a recovery surface without + /// re-parsing its own stderr. Null after a passing start, a non-gate exit, or a fresh engine — + /// and reset at every operation entry, so a reused engine never carries a prior refusal into a + /// later call. The app shells out and reads the token from child stderr instead; this is the + /// in-process twin of the same start_gate_reason= contract. + internal StartGateReason? LastGateReason { get; private set; } + + /// The coded viability_reason= token of the LAST gated-install viability abort + /// (the one emitted producer is package_inconsistent), for the same in-process caller. + /// Null after any other exit — reset at every operation entry like . + internal string? LastViabilityReason { get; private set; } + + /// The coded refusal_reason= token of the LAST attributed readiness-timeout + /// boot refusal (e.g. consent_seed_unwritable), for the same in-process caller. Null + /// when the timeout was not attributed — reset at every operation entry. + internal string? LastBootRefusalToken { get; private set; } + const string ConsentSeedVar = "KCAP_CONSENT_SEED_DEFAULT"; const string ProfileVar = "KCAP_PROFILE"; const string UrlVar = "KCAP_URL"; @@ -192,6 +210,12 @@ static string DescribeQuery(ServiceQuery q) => /// start --verify: no viability check (start writes nothing). Accepts ANY well-formed /// hello — capability-incompatible old daemons included. public async Task StartVerifiedAsync(string serviceId) { + // Entry reset: the in-process evidence properties describe THIS operation only, so a reused + // engine must never carry a prior gate refusal / refusal token into a later call. + LastGateReason = null; + LastViabilityReason = null; + LastBootRefusalToken = null; + using var txn = ServiceTxnLock.TryAcquire(store, serviceId, LockWait); if (txn is null) { @@ -240,6 +264,7 @@ public async Task StartVerifiedAsync(string serviceId) { } if (reason is { } r) { + LastGateReason = r; Say($"start_gate_reason={GateReasonToken(r)}"); Say(VerifyExit.StartGateToken); return VerifyExit.StartGate; @@ -371,7 +396,9 @@ public async Task StartVerifiedAsync(string serviceId) { var rollbackExit = await Rollback(serviceId); - return AttributeReadinessTimeout(store, serviceId, rollbackExit, gated, attributionEnabled, unitExpectation, observedJobPids); + var (exit, refusalToken) = AttributeReadinessTimeout(store, serviceId, rollbackExit, gated, attributionEnabled, unitExpectation, observedJobPids); + LastBootRefusalToken = refusalToken; + return exit; } /// Re-reads the plist and re-checks the digest (both contained — never an escaping @@ -399,16 +426,17 @@ bool RecheckPlistUnchanged(string serviceId, string? phaseAPlistContent) { /// what a boot-refusal marker explains, so passes through /// unchanged in every case; this only ever adds the one stderr line and consumes the marker. /// - static int AttributeReadinessTimeout(DaemonStore store, string daemonName, int rollbackExit, bool gated, bool attributionEnabled, + static (int Exit, string? RefusalToken) AttributeReadinessTimeout(DaemonStore store, string daemonName, int rollbackExit, bool gated, bool attributionEnabled, string? unitExpectation, IReadOnlySet observedJobPids) { if (gated && attributionEnabled && rollbackExit == VerifyExit.ReadinessTimeout && BootRefusalMarker.TryRead(store, daemonName) is { } evidence && Attributable(evidence, daemonName, unitExpectation, observedJobPids)) { Say($"refusal_reason={evidence.Token}"); BootRefusalMarker.TryDelete(store, daemonName); + return (rollbackExit, evidence.Token); } - return rollbackExit; + return (rollbackExit, null); } /// @@ -578,7 +606,7 @@ internal static bool SameBinaryPath(string unitBinaryPath, string installBinaryP return config.Profiles.TryGetValue(profile, out var p) ? p.ServerUrl : null; } - static string GateReasonToken(StartGateReason reason) => reason switch { + internal static string GateReasonToken(StartGateReason reason) => reason switch { StartGateReason.DirectiveMissing => "directive_missing", StartGateReason.DirectiveInvalid => "directive_invalid", StartGateReason.IdentityMismatch => "identity_mismatch", @@ -661,6 +689,12 @@ enum InstallReady { NotReady, Ready, VersionMismatch } /// (spec §3.4): a fresh install refuses to touch an existing label/unit /// (), while --replace clears/takes it over first. public async Task InstallVerifiedAsync(ServiceSpec spec, bool replace, string? expectedVersion) { + // Entry reset: see StartVerifiedAsync — the in-process evidence properties describe THIS + // operation only. + LastGateReason = null; + LastViabilityReason = null; + LastBootRefusalToken = null; + var serviceId = spec.ServiceId; var op = replace ? "replace" : "install"; using var txn = ServiceTxnLock.TryAcquire(store, serviceId, LockWait); @@ -707,7 +741,8 @@ public async Task InstallVerifiedAsync(ServiceSpec spec, bool replace, stri // that as EvidenceUnreadable — install has no third bucket, and either way the binary // about to be installed cannot be trusted, so it's still a viability abort. if (gated && !DigestStillGood(spec.DaemonBinaryPath)) { - Say($"viability_reason={GateReasonToken(StartGateReason.PackageInconsistent)}"); + LastViabilityReason = GateReasonToken(StartGateReason.PackageInconsistent); + Say($"viability_reason={LastViabilityReason}"); return VerifyExit.Viability; } @@ -786,7 +821,9 @@ public async Task InstallVerifiedAsync(ServiceSpec spec, bool replace, stri // plist on disk, so route through the same fingerprint-gated rollback. Say($"{op}: {ex.Message}"); var bootstrapThrowExit = await InstallRollback(serviceId, generated.Path, fingerprint, VerifyExit.ReadinessTimeout, VerifyExit.ReadinessTimeoutToken); - return AttributeReadinessTimeout(store, serviceId, bootstrapThrowExit, gated, attributionEnabled, unitExpectation, observedJobPids); + var (bootstrapExit, bootstrapToken) = AttributeReadinessTimeout(store, serviceId, bootstrapThrowExit, gated, attributionEnabled, unitExpectation, observedJobPids); + LastBootRefusalToken = bootstrapToken; + return bootstrapExit; } ServiceTxnMarker.Write(store, serviceId, new TxnMarker(1, op, "bootstrapped", preState, "no-unit", fingerprint)); @@ -848,7 +885,9 @@ public async Task InstallVerifiedAsync(ServiceSpec spec, bool replace, stri } var readinessTimeoutExit = await InstallRollback(serviceId, generated.Path, fingerprint, VerifyExit.ReadinessTimeout, VerifyExit.ReadinessTimeoutToken); - return AttributeReadinessTimeout(store, serviceId, readinessTimeoutExit, gated, attributionEnabled, unitExpectation, observedJobPids); + var (timeoutExit, timeoutToken) = AttributeReadinessTimeout(store, serviceId, readinessTimeoutExit, gated, attributionEnabled, unitExpectation, observedJobPids); + LastBootRefusalToken = timeoutToken; + return timeoutExit; } /// diff --git a/test/Capacitor.App.Tests.Unit/AppMutationLaneWiringTests.cs b/test/Capacitor.App.Tests.Unit/AppMutationLaneWiringTests.cs index fe4e07510..1228f2937 100644 --- a/test/Capacitor.App.Tests.Unit/AppMutationLaneWiringTests.cs +++ b/test/Capacitor.App.Tests.Unit/AppMutationLaneWiringTests.cs @@ -1,6 +1,7 @@ using Capacitor.App.Services; using Capacitor.App.Services.Mutation; using AppUnderTest = Capacitor.App.App; +using Capacitor.Cli.Core; namespace Capacitor.App.Tests.Unit; diff --git a/test/Capacitor.App.Tests.Unit/AppStartupTests.cs b/test/Capacitor.App.Tests.Unit/AppStartupTests.cs index d550d8457..1e410c18f 100644 --- a/test/Capacitor.App.Tests.Unit/AppStartupTests.cs +++ b/test/Capacitor.App.Tests.Unit/AppStartupTests.cs @@ -10,6 +10,7 @@ using Capacitor.Cli.Core.LocalIpc; using TUnit.Assertions.Enums; using AppUnderTest = Capacitor.App.App; +using Capacitor.Cli.Core; namespace Capacitor.App.Tests.Unit; diff --git a/test/Capacitor.App.Tests.Unit/DaemonClientServiceTests.cs b/test/Capacitor.App.Tests.Unit/DaemonClientServiceTests.cs index fb683e42e..724fe3102 100644 --- a/test/Capacitor.App.Tests.Unit/DaemonClientServiceTests.cs +++ b/test/Capacitor.App.Tests.Unit/DaemonClientServiceTests.cs @@ -5,6 +5,7 @@ using Capacitor.Cli.Core.Config; using Capacitor.Cli.Core.LocalIpc; using TUnit.Assertions.Enums; +using Capacitor.Cli.Core; namespace Capacitor.App.Tests.Unit; diff --git a/test/Capacitor.App.Tests.Unit/DaemonLifecycleControllerTests.cs b/test/Capacitor.App.Tests.Unit/DaemonLifecycleControllerTests.cs index d88c14630..81962c72f 100644 --- a/test/Capacitor.App.Tests.Unit/DaemonLifecycleControllerTests.cs +++ b/test/Capacitor.App.Tests.Unit/DaemonLifecycleControllerTests.cs @@ -1,6 +1,7 @@ using Capacitor.App.Services; using Capacitor.App.Services.Mutation; using Microsoft.Extensions.Time.Testing; +using Capacitor.Cli.Core; namespace Capacitor.App.Tests.Unit; diff --git a/test/Capacitor.App.Tests.Unit/DaemonMutationLaneTests.cs b/test/Capacitor.App.Tests.Unit/DaemonMutationLaneTests.cs index 698a9a375..661704cce 100644 --- a/test/Capacitor.App.Tests.Unit/DaemonMutationLaneTests.cs +++ b/test/Capacitor.App.Tests.Unit/DaemonMutationLaneTests.cs @@ -1,6 +1,7 @@ using Capacitor.App.Services; using Capacitor.App.Services.Mutation; using Microsoft.Extensions.Time.Testing; +using Capacitor.Cli.Core; namespace Capacitor.App.Tests.Unit; diff --git a/test/Capacitor.App.Tests.Unit/DaemonStepViewModelTests.cs b/test/Capacitor.App.Tests.Unit/DaemonStepViewModelTests.cs index 06480660a..16c254f59 100644 --- a/test/Capacitor.App.Tests.Unit/DaemonStepViewModelTests.cs +++ b/test/Capacitor.App.Tests.Unit/DaemonStepViewModelTests.cs @@ -9,6 +9,7 @@ using Capacitor.Cli.Core.Auth; using Capacitor.Cli.Core.LocalIpc; using Microsoft.Extensions.Time.Testing; +using Capacitor.Cli.Core; namespace Capacitor.App.Tests.Unit; diff --git a/test/Capacitor.App.Tests.Unit/MutationRequestFactoryTests.cs b/test/Capacitor.App.Tests.Unit/MutationRequestFactoryTests.cs index 19ea79dec..90870b3ad 100644 --- a/test/Capacitor.App.Tests.Unit/MutationRequestFactoryTests.cs +++ b/test/Capacitor.App.Tests.Unit/MutationRequestFactoryTests.cs @@ -1,4 +1,5 @@ using Capacitor.App.Services.Mutation; +using Capacitor.Cli.Core; namespace Capacitor.App.Tests.Unit; diff --git a/test/Capacitor.App.Tests.Unit/MutationModelTests.cs b/test/Capacitor.Cli.Core.Tests.Unit/ReasonRoutingTests.cs similarity index 93% rename from test/Capacitor.App.Tests.Unit/MutationModelTests.cs rename to test/Capacitor.Cli.Core.Tests.Unit/ReasonRoutingTests.cs index 1a1e5dfc8..9ee8c3b84 100644 --- a/test/Capacitor.App.Tests.Unit/MutationModelTests.cs +++ b/test/Capacitor.Cli.Core.Tests.Unit/ReasonRoutingTests.cs @@ -1,8 +1,6 @@ -using Capacitor.App.Services.Mutation; +namespace Capacitor.Cli.Core.Tests.Unit; -namespace Capacitor.App.Tests.Unit; - -public class MutationModelTests { +public class ReasonRoutingTests { // ---- ForStartGate (spec §3/§4 pinned table) ---- [Test] diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs new file mode 100644 index 000000000..cddbfb09e --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs @@ -0,0 +1,69 @@ +using Capacitor.Cli.Commands; +using Capacitor.Cli.Services; + +namespace Capacitor.Cli.Tests.Unit.Commands; + +/// +/// Dispatch rows of kcap daemon service ensure that need no real service manager: the +/// already-enabled and fail-closed-attention rows are pure classification reads. (The install/start +/// arms on launchd run the ServiceVerify transaction — covered by the engine's own suites; the +/// plain arms need the daemon binary present, which a unit test host does not have.) +/// +/// Bare [NotInParallel]: Ensure writes to the process-global +/// Console.Out, so these cannot run beside a test capturing console streams. +[NotInParallel] +public class DaemonCommandsServiceEnsureTests { + [TempDaemonPaths] public required TempDaemonStore Daemons { get; init; } + + sealed class FakeManager : IServiceManager { + public ServiceQuery QueryResult { get; init; } = + new(LabelProbe.Absent, false, ServiceState.NotInstalled, null, null); + public string Describe() => "fake"; + public IReadOnlyList GenerateFiles(ServiceSpec spec) => []; + public IReadOnlyList ListInstalled() => []; + public ServiceStatus Status(string serviceId) => new(ServiceState.NotInstalled, null); + public ServiceQuery Query(string serviceId) => QueryResult; + public void Install(ServiceSpec spec, bool startNow) { } + public void WriteAndBootstrap(ServiceSpec spec) { } + public bool Uninstall(string serviceId, out string? error) { error = null; return true; } + public bool Start(string serviceId, out string? error) { error = null; return true; } + public bool Stop(string serviceId, out string? error) { error = null; return true; } + } + + [Test] + public async Task Unknown_probe_fails_closed_with_reason() { + var manager = new FakeManager { + QueryResult = new ServiceQuery(LabelProbe.Unknown, false, ServiceState.NotInstalled, null, null) + }; + var exit = await new DaemonServiceCommands(Daemons.Store, manager, "test-id").Ensure(["--json"]); + await Assert.That(exit).IsEqualTo(1); + } + + [Test] + public async Task Active_transaction_reports_attention_without_mutating() { + var manager = new FakeManager { + QueryResult = new ServiceQuery(LabelProbe.Loaded, true, ServiceState.Running, "/b/kcap-daemon", 42) + }; + // Ensure reads the lock via ServiceTxnLock.IsHeld; hold it for real. + using var held = ServiceTxnLock.TryAcquire(Daemons.Store, "test-id", TimeSpan.FromSeconds(1)); + await Assert.That(held).IsNotNull(); + + var exit = await new DaemonServiceCommands(Daemons.Store, manager, "test-id").Ensure(["--json"]); + await Assert.That(exit).IsEqualTo(1); + } + + // F1: on launchd a unit baked without a resolvable profile can never pass the start gate's + // identity half (it demands a non-empty invoking KCAP_PROFILE), so the install/start arms must + // fail closed up front rather than report a success that can never be gated-started. The + // predicate is pure (takes isLaunchd, not the manager) so it runs on every CI leg. + [Test] + [Arguments(true, null, EnsureAction.Install, true)] + [Arguments(true, "", EnsureAction.Start, true)] + [Arguments(true, "acme", EnsureAction.Install, false)] + [Arguments(true, null, EnsureAction.AlreadyEnabled, false)] + [Arguments(false, null, EnsureAction.Install, false)] + public async Task Launchd_profile_refusal_is_conditional_on_platform_profile_and_action( + bool isLaunchd, string? profileName, EnsureAction action, bool expected) { + await Assert.That(DaemonServiceCommands.LaunchdProfileRefusal(isLaunchd, profileName, action)).IsEqualTo(expected); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs new file mode 100644 index 000000000..6d5d5d324 --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs @@ -0,0 +1,316 @@ +using System.Text.Json; +using Capacitor.Cli.Commands; +using Capacitor.Cli.Services; + +namespace Capacitor.Cli.Tests.Unit.Commands; + +/// Pure classifier rows for the flow's daemon-install ladder: every ambiguous +/// state fails closed to attention with a coded reason — never guessed into an install/start. +public class EnsureClassifierTests { + [Test] + public async Task Unknown_probe_fails_closed_before_anything_else() { + var d = EnsureClassifier.Classify(LabelProbe.Unknown, ServiceState.NotInstalled, unitPresent: false, daemonPid: null, txnMarker: false, txnActive: false); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Attention); + await Assert.That(d.Reason).IsEqualTo("status_unknown"); + } + + [Test] + public async Task No_unit_is_install() { + var d = EnsureClassifier.Classify(LabelProbe.Absent, ServiceState.NotInstalled, unitPresent: false, daemonPid: null, txnMarker: false, txnActive: false); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Install); + } + + [Test] + public async Task Unit_present_but_stopped_is_start() { + var d = EnsureClassifier.Classify(LabelProbe.Absent, ServiceState.Installed, unitPresent: true, daemonPid: null, txnMarker: false, txnActive: false); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Start); + } + + // launchd's real stopped-but-installed shape: a present unit reads state NotInstalled when the + // probe is not Loaded — the classifier must still choose Start, not Install. + [Test] + public async Task Launchd_stopped_but_installed_shape_is_start() { + var d = EnsureClassifier.Classify(LabelProbe.Absent, ServiceState.NotInstalled, unitPresent: true, daemonPid: null, txnMarker: false, txnActive: false); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Start); + } + + [Test] + public async Task Running_with_validated_pid_is_already_enabled() { + var d = EnsureClassifier.Classify(LabelProbe.Loaded, ServiceState.Running, unitPresent: true, daemonPid: 42, txnMarker: false, txnActive: false); + await Assert.That(d.Action).IsEqualTo(EnsureAction.AlreadyEnabled); + } + + // A stale marker must fail closed even when the daemon is demonstrably running — the docs state + // the fail-closed rule unqualified, so the repair rows precede the success arm. + [Test] + public async Task Stale_marker_precedes_a_validated_running_daemon() { + var d = EnsureClassifier.Classify(LabelProbe.Loaded, ServiceState.Running, unitPresent: true, daemonPid: 42, txnMarker: true, txnActive: false); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Attention); + await Assert.That(d.Reason).IsEqualTo("stale_marker"); + } + + // A loaded label whose unit file has disappeared — even one reading Running — is an installation + // that cannot survive a relaunch: orphaned, never "already enabled". + [Test] + public async Task Running_label_without_a_unit_file_is_orphan() { + var d = EnsureClassifier.Classify(LabelProbe.Loaded, ServiceState.Running, unitPresent: false, daemonPid: 42, txnMarker: false, txnActive: false); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Attention); + await Assert.That(d.Reason).IsEqualTo("orphan_label"); + } + + // launchd reports the job pid, so already-enabled additionally requires the running job to OWN + // the validated daemon pid (the same ownership predicate ServiceVerify.IsReadyAsync uses). + [Test] + public async Task Launchd_running_with_job_pid_matching_the_validated_daemon_is_already_enabled() { + var d = EnsureClassifier.Classify(LabelProbe.Loaded, ServiceState.Running, unitPresent: true, daemonPid: 42, txnMarker: false, txnActive: false, jobPid: 42, jobPidAvailable: true); + await Assert.That(d.Action).IsEqualTo(EnsureAction.AlreadyEnabled); + } + + [Test] + public async Task Launchd_running_with_a_different_job_pid_is_unconfirmed() { + var d = EnsureClassifier.Classify(LabelProbe.Loaded, ServiceState.Running, unitPresent: true, daemonPid: 42, txnMarker: false, txnActive: false, jobPid: 43, jobPidAvailable: true); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Attention); + await Assert.That(d.Reason).IsEqualTo("running_unconfirmed"); + } + + [Test] + public async Task Launchd_running_without_a_job_pid_is_unconfirmed() { + var d = EnsureClassifier.Classify(LabelProbe.Loaded, ServiceState.Running, unitPresent: true, daemonPid: 42, txnMarker: false, txnActive: false, jobPid: null, jobPidAvailable: true); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Attention); + await Assert.That(d.Reason).IsEqualTo("running_unconfirmed"); + } + + // systemd/Windows cannot supply a job pid — their documented verified:false already-enabled arm + // keeps the coarser Running + validated-daemon check. + [Test] + public async Task Managers_without_a_job_pid_keep_the_coarser_already_enabled_check() { + var d = EnsureClassifier.Classify(LabelProbe.Loaded, ServiceState.Running, unitPresent: true, daemonPid: 42, txnMarker: false, txnActive: false, jobPid: null, jobPidAvailable: false); + await Assert.That(d.Action).IsEqualTo(EnsureAction.AlreadyEnabled); + } + + [Test] + public async Task Running_without_a_validated_pid_is_unconfirmed_attention() { + var d = EnsureClassifier.Classify(LabelProbe.Loaded, ServiceState.Running, unitPresent: true, daemonPid: null, txnMarker: false, txnActive: false); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Attention); + await Assert.That(d.Reason).IsEqualTo("running_unconfirmed"); + } + + [Test] + public async Task Installed_state_without_unit_is_orphan_attention() { + var d = EnsureClassifier.Classify(LabelProbe.Absent, ServiceState.Installed, unitPresent: false, daemonPid: null, txnMarker: false, txnActive: false); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Attention); + await Assert.That(d.Reason).IsEqualTo("orphan_label"); + } + + [Test] + public async Task Stale_marker_precedes_mutation_rows() { + var d = EnsureClassifier.Classify(LabelProbe.Absent, ServiceState.NotInstalled, unitPresent: false, daemonPid: null, txnMarker: true, txnActive: false); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Attention); + await Assert.That(d.Reason).IsEqualTo("stale_marker"); + } + + [Test] + public async Task Active_transaction_is_never_mutated_into() { + var d = EnsureClassifier.Classify(LabelProbe.Loaded, ServiceState.Running, unitPresent: true, daemonPid: 42, txnMarker: false, txnActive: true); + await Assert.That(d.Action).IsEqualTo(EnsureAction.Attention); + await Assert.That(d.Reason).IsEqualTo("txn_active"); + } +} + +/// Born-prompt baking: the unit env for an ensure install forces the seed directive to +/// prompt and pins the expected server, regardless of what the installing shell exported. +public class EnsureUnitEnvTests { + [Test] + public async Task Bakes_prompt_and_expected_server() { + var env = DaemonServiceCommands.EnsureUnitEnv("acme", "https://s.example", new Dictionary()); + await Assert.That(env["KCAP_CONSENT_SEED_DEFAULT"]).IsEqualTo("prompt"); + await Assert.That(env["KCAP_EXPECT_SERVER_URL"]).IsEqualTo("https://s.example"); + await Assert.That(env["KCAP_PROFILE"]).IsEqualTo("acme"); + } + + [Test] + public async Task Prompt_wins_over_an_ambient_refusal() { + var env = DaemonServiceCommands.EnsureUnitEnv("acme", "https://s.example", + new Dictionary { ["KCAP_CONSENT_SEED_DEFAULT"] = "deny" }); + await Assert.That(env["KCAP_CONSENT_SEED_DEFAULT"]).IsEqualTo("prompt"); + } + + [Test] + public async Task Carries_ambient_values_that_are_not_overlaid() { + var env = DaemonServiceCommands.EnsureUnitEnv("acme", "https://s.example", + new Dictionary { ["PATH"] = "/usr/bin", ["KCAP_CODEX_PATH"] = "/x/codex" }); + await Assert.That(env["PATH"]).IsEqualTo("/usr/bin"); + await Assert.That(env["KCAP_CODEX_PATH"]).IsEqualTo("/x/codex"); + } + + [Test] + public async Task Does_not_pin_an_empty_profile() { + var env = DaemonServiceCommands.EnsureUnitEnv(null, "https://s.example", new Dictionary()); + await Assert.That(env.ContainsKey("KCAP_PROFILE")).IsFalse(); + } + + // URL resolution outranks profile resolution (ProfileResolver), so a pinned profile must not sit + // beside an ambient KCAP_URL — the unit would boot against the wrong server and refuse on the + // expectation mismatch. The pin is the sole URL authority. + [Test] + public async Task Pinning_a_profile_strips_an_ambient_kcap_url() { + var env = DaemonServiceCommands.EnsureUnitEnv("acme", "https://s.example", + new Dictionary { ["KCAP_URL"] = "https://other.example", ["PATH"] = "/usr/bin" }); + await Assert.That(env.ContainsKey("KCAP_URL")).IsFalse(); + await Assert.That(env["KCAP_PROFILE"]).IsEqualTo("acme"); + await Assert.That(env["PATH"]).IsEqualTo("/usr/bin"); + } + + // URL-only (no profile) keeps the ambient URL — coherent only off-macOS, where no gate exists. + [Test] + public async Task Url_only_install_keeps_the_ambient_kcap_url() { + var env = DaemonServiceCommands.EnsureUnitEnv(null, "https://s.example", + new Dictionary { ["KCAP_URL"] = "https://s.example" }); + await Assert.That(env["KCAP_URL"]).IsEqualTo("https://s.example"); + await Assert.That(env.ContainsKey("KCAP_PROFILE")).IsFalse(); + } +} + +/// Ensure JSON is snake-cased on the wire and renders through the shared context. +public class ServiceEnsureJsonTests { + [Test] + public async Task Renders_snake_case_fields() { + var json = ServiceEnsureRender.RenderJson(new ServiceEnsureJson("svc", "not_installed", "install", "installed", Verified: true)); + using var doc = JsonDocument.Parse(json); + var r = doc.RootElement; + await Assert.That(r.GetProperty("service_id").GetString()).IsEqualTo("svc"); + await Assert.That(r.GetProperty("action").GetString()).IsEqualTo("install"); + await Assert.That(r.GetProperty("outcome").GetString()).IsEqualTo("installed"); + await Assert.That(r.GetProperty("verified").GetBoolean()).IsTrue(); + } + + [Test] + public async Task Recovery_and_reason_are_nullable() { + var json = ServiceEnsureRender.RenderJson(new ServiceEnsureJson("svc", "installed", "start", "refused", "takeover", "directive_missing")); + using var doc = JsonDocument.Parse(json); + var r = doc.RootElement; + await Assert.That(r.GetProperty("recovery").GetString()).IsEqualTo("takeover"); + await Assert.That(r.GetProperty("reason").GetString()).IsEqualTo("directive_missing"); + } + + // A refused launchd transaction ran the verified transaction — the JSON must say so, so the + // flow's copy can distinguish a verified gate refusal from a degraded plain failure. + [Test] + public async Task Refused_launchd_transaction_serializes_verified_true() { + var json = ServiceEnsureRender.RenderJson(new ServiceEnsureJson("svc", "installed", "start", "refused", "takeover", "identity_mismatch", Verified: true)); + using var doc = JsonDocument.Parse(json); + await Assert.That(doc.RootElement.GetProperty("verified").GetBoolean()).IsTrue(); + } + + [Test] + public async Task Serializes_through_the_shared_context() { + var json = JsonSerializer.Serialize(new ServiceEnsureJson("svc", "running", "none", "already_enabled"), ServiceJsonContext.Default.ServiceEnsureJson); + await Assert.That(json).Contains("\"outcome\":\"already_enabled\""); + } +} + +/// The ensure JSON's state token derives from unit presence: launchd's stopped-but-installed +/// shape reads state NotInstalled (label not loaded) with unitPresent true, and the wire state must +/// not contradict the ladder's own "start" action. +public class ServiceEnsureStateTokenTests { + [Test] + public async Task Stopped_but_installed_unit_reads_installed() { + var q = new ServiceQuery(LabelProbe.Absent, UnitPresent: true, ServiceState.NotInstalled, null, null); + await Assert.That(DaemonServiceCommands.ServiceStateToken(q)).IsEqualTo("installed"); + } + + [Test] + public async Task No_unit_reads_not_installed() { + var q = new ServiceQuery(LabelProbe.Absent, UnitPresent: false, ServiceState.NotInstalled, null, null); + await Assert.That(DaemonServiceCommands.ServiceStateToken(q)).IsEqualTo("not_installed"); + } + + [Test] + public async Task Running_reads_running() { + var q = new ServiceQuery(LabelProbe.Loaded, UnitPresent: true, ServiceState.Running, null, 42); + await Assert.That(DaemonServiceCommands.ServiceStateToken(q)).IsEqualTo("running"); + } + + [Test] + public async Task Unknown_probe_reads_unknown() { + var q = new ServiceQuery(LabelProbe.Unknown, UnitPresent: true, ServiceState.NotInstalled, null, null); + await Assert.That(DaemonServiceCommands.ServiceStateToken(q)).IsEqualTo("unknown"); + } +} + +/// Pure mapping of a failure exit to the JSON's recovery/reason fields — the wire contract +/// the flow consumes, testable without a real service manager. +public class EnsureFailureMapTests { + [Test] + [Arguments(VerifyExit.StartGate, StartGateReason.DirectiveMissing, "takeover", "directive_missing")] + [Arguments(VerifyExit.StartGate, StartGateReason.DirectiveInvalid, "takeover", "directive_invalid")] + [Arguments(VerifyExit.StartGate, StartGateReason.IdentityMismatch, "takeover", "identity_mismatch")] + [Arguments(VerifyExit.StartGate, StartGateReason.ForeignBinary, "takeover", "foreign_binary")] + [Arguments(VerifyExit.StartGate, StartGateReason.PackageInconsistent, "reinstall", "package_inconsistent")] + [Arguments(VerifyExit.StartGate, StartGateReason.EvidenceUnreadable, "attention", "evidence_unreadable")] + internal async Task StartGate_routes_to_its_recovery_surface(int exit, StartGateReason reason, string recovery, string token) { + var (r, reasonToken) = EnsureFailureMap.Map(exit, reason, verified: true); + await Assert.That(r).IsEqualTo(recovery); + await Assert.That(reasonToken).IsEqualTo(token); + } + + [Test] + public async Task StartGate_without_a_reason_fails_closed_to_attention() { + var (r, reason) = EnsureFailureMap.Map(VerifyExit.StartGate, null, verified: true); + await Assert.That(r).IsEqualTo("attention"); + await Assert.That(reason).IsNull(); + } + + [Test] + public async Task Drift_is_the_gates_toctou_refusal_attention_with_its_token() { + var (r, reason) = EnsureFailureMap.Map(VerifyExit.StartGateDrift, null, verified: true); + await Assert.That(r).IsEqualTo("attention"); + await Assert.That(reason).IsEqualTo("verify_start_gate_drift"); + } + + [Test] + public async Task Verified_non_gate_exit_carries_its_verify_token() { + var (r, reason) = EnsureFailureMap.Map(VerifyExit.Viability, null, verified: true); + await Assert.That(r).IsNull(); + await Assert.That(reason).IsEqualTo("verify_viability"); + } + + // A gated-install viability abort with the engine's coded reason routes to reinstall — the same + // package_inconsistent token the start gate's own table maps — never the generic verify token. + [Test] + public async Task Viability_with_a_package_inconsistent_reason_routes_to_reinstall() { + var (r, reason) = EnsureFailureMap.Map(VerifyExit.Viability, null, verified: true, viabilityReason: "package_inconsistent"); + await Assert.That(r).IsEqualTo("reinstall"); + await Assert.That(reason).IsEqualTo("package_inconsistent"); + } + + // An attributed readiness-timeout boot refusal carries its marker token through ForBootRefusal: + // a consent_seed_unwritable refusal routes to storage, never the generic verify_readiness_timeout. + [Test] + public async Task Readiness_timeout_with_attributed_boot_refusal_routes_to_storage() { + var (r, reason) = EnsureFailureMap.Map(VerifyExit.ReadinessTimeout, null, verified: true, bootRefusalToken: "consent_seed_unwritable"); + await Assert.That(r).IsEqualTo("storage"); + await Assert.That(reason).IsEqualTo("consent_seed_unwritable"); + } + + [Test] + public async Task Readiness_timeout_with_a_server_expectation_refusal_routes_to_takeover() { + var (r, reason) = EnsureFailureMap.Map(VerifyExit.ReadinessTimeout, null, verified: true, bootRefusalToken: "server_expectation_mismatch"); + await Assert.That(r).IsEqualTo("takeover"); + await Assert.That(reason).IsEqualTo("server_expectation_mismatch"); + } + + [Test] + public async Task Readiness_timeout_without_attribution_keeps_the_generic_token() { + var (r, reason) = EnsureFailureMap.Map(VerifyExit.ReadinessTimeout, null, verified: true); + await Assert.That(r).IsNull(); + await Assert.That(reason).IsEqualTo("verify_readiness_timeout"); + } + + [Test] + public async Task Plain_failure_never_wears_the_verify_prefix() { + var (r, reason) = EnsureFailureMap.Map(1, null, verified: false); + await Assert.That(r).IsNull(); + await Assert.That(reason).IsEqualTo("plain_failure"); + } +} diff --git a/test/Capacitor.Cli.Tests.Unit/Services/ServiceVerifyInstallTests.cs b/test/Capacitor.Cli.Tests.Unit/Services/ServiceVerifyInstallTests.cs index d030d4858..75f8bfb7f 100644 --- a/test/Capacitor.Cli.Tests.Unit/Services/ServiceVerifyInstallTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Services/ServiceVerifyInstallTests.cs @@ -621,6 +621,8 @@ static Task Hello(string _, TimeSpan __) => await Assert.That(lines).IsEquivalentTo(["viability_reason=package_inconsistent"]); await Assert.That(manager.Calls).IsEmpty(); await Assert.That(ServiceTxnMarker.Exists(Daemons.Store, Id)).IsFalse(); + // F8: the coded reason must also be surfaced in-process, for the ensure ladder's JSON. + await Assert.That(sut.LastViabilityReason).IsEqualTo("package_inconsistent"); } /// The pre-bootstrap TOCTOU recheck. Viability's digest check passes (call 1) @@ -818,6 +820,8 @@ static Task Hello(string _, TimeSpan __) => await Assert.That(exit).IsEqualTo(VerifyExit.ReadinessTimeout); var lines = capture.GetCapturedError().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); await Assert.That(lines.Count(l => l == "refusal_reason=server_expectation_mismatch")).IsEqualTo(1); + // F8: the attributed refusal token must also be surfaced in-process, for the ensure ladder's JSON. + await Assert.That(sut.LastBootRefusalToken).IsEqualTo("server_expectation_mismatch"); } [Test, NotInParallel] @@ -856,5 +860,6 @@ static Task Hello(string _, TimeSpan __) => await Assert.That(exit).IsEqualTo(VerifyExit.ReadinessTimeout); var lines = capture.GetCapturedError().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); await Assert.That(lines.Any(l => l.StartsWith("refusal_reason=", StringComparison.Ordinal))).IsFalse(); + await Assert.That(sut.LastBootRefusalToken).IsNull(); } } diff --git a/test/Capacitor.Cli.Tests.Unit/Services/ServiceVerifyStartTests.cs b/test/Capacitor.Cli.Tests.Unit/Services/ServiceVerifyStartTests.cs index 20c2b9b60..af9686143 100644 --- a/test/Capacitor.Cli.Tests.Unit/Services/ServiceVerifyStartTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Services/ServiceVerifyStartTests.cs @@ -443,6 +443,37 @@ static Task Hello(string _, TimeSpan __) => await Assert.That(ServiceTxnMarker.Exists(Daemons.Store, Id)).IsFalse(); } + // F5: the in-process evidence properties describe ONE operation — a reused engine must not + // carry a prior gate refusal into a later call. A gate-refusing start leaves the reason set; + // the next call's lock contention (a non-gate exit, before any gate evaluation) must observe + // the entry reset. + [Test, NotInParallel] + public async Task Last_gate_reason_is_cleared_at_the_next_operation_entry() { + var manager = new FakeServiceManager(); + + static Task Hello(string _, TimeSpan __) => + Task.FromResult(new HelloProbeResult(true, 1, "1.2.3", "kcap-daemon")); + + var sut = new ServiceVerify(Daemons.Store, manager, _ => 4242, Hello, TimeProvider.System, + readPlist: _ => null, + plistExists: _ => false, + gateEnv: k => k == "KCAP_CONSENT_SEED_DEFAULT" ? "prompt" : null); + + using (var capture = ConsoleOutput.StartErrorCapture()) { + await Assert.That(await sut.StartVerifiedAsync(Id)).IsEqualTo(VerifyExit.StartGate); + } + await Assert.That(sut.LastGateReason).IsNotNull(); + + // Hold the lock so the second call exits Contended before any gate evaluation. + using var held = ServiceTxnLock.TryAcquire(Daemons.Store, Id, TimeSpan.FromSeconds(1)); + await Assert.That(held).IsNotNull(); + + using (var capture = ConsoleOutput.StartErrorCapture()) { + await Assert.That(await sut.StartVerifiedAsync(Id)).IsEqualTo(VerifyExit.Contended); + } + await Assert.That(sut.LastGateReason).IsNull(); + } + [Test] public async Task Plist_drift_between_phase_a_and_phase_b_rolls_back_to_29_without_ever_starting() { // Loaded at the fresh query — the gated path must boot it out (never kickstart it)