From e1d9f6365b7d077d2b8685ee532a2b7dd3f8c8b5 Mon Sep 17 00:00:00 2001 From: George Payne Date: Mon, 24 Aug 2026 13:34:07 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20kcap=20daemon=20service=20ensure=20?= =?UTF-8?q?=E2=80=94=20the=20flow's=20daemon-install=20ladder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'kcap daemon service ensure': from a fresh status read, install when there is no unit or start when the unit is stopped, baking the born-prompt consent 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 via the pinned ReasonRouting table — never guessed at from prose. On non-launchd the ladder degrades to plain install/start; --json reports verified:false so the flow's copy can say so. Ambiguous states (unknown probe, active transaction, orphan label, stale marker) fail closed to attention with a coded reason. Moves ReasonRouting/RecoverySurface from the retiring Capacitor.App into Capacitor.Cli.Core so the CLI and the app share one pinned mapping (the same rescue shape as AI-2167). Adds --json output (ServiceEnsureJson), pure classifier + failure-map, and unit tests. --- README.md | 3 + ...-24-ai2039-daemon-service-ensure-design.md | 70 +++++++ .../Services/Mutation/MutationModel.cs | 24 +-- .../Mutation/MutationRequestFactory.cs | 1 + src/Capacitor.Cli.Core/RecoveryRouting.cs | 32 +++ .../Resources/help-daemon.txt | 15 ++ .../Commands/DaemonServiceCommands.cs | 191 +++++++++++++++++- src/Capacitor.Cli/Commands/ServiceEnsure.cs | 131 ++++++++++++ .../Commands/ServiceStatusJson.cs | 12 ++ src/Capacitor.Cli/Services/ServiceVerify.cs | 10 +- .../AppMutationLaneWiringTests.cs | 1 + .../AppStartupTests.cs | 1 + .../DaemonClientServiceTests.cs | 1 + .../DaemonLifecycleControllerTests.cs | 1 + .../DaemonMutationLaneTests.cs | 1 + .../DaemonStepViewModelTests.cs | 1 + .../MutationRequestFactoryTests.cs | 1 + .../ReasonRoutingTests.cs} | 6 +- .../DaemonCommandsServiceEnsureTests.cs | 51 +++++ .../Commands/ServiceEnsureTests.cs | 177 ++++++++++++++++ 20 files changed, 702 insertions(+), 28 deletions(-) create mode 100644 docs/superpowers/specs/2026-08-24-ai2039-daemon-service-ensure-design.md create mode 100644 src/Capacitor.Cli.Core/RecoveryRouting.cs create mode 100644 src/Capacitor.Cli/Commands/ServiceEnsure.cs rename test/{Capacitor.App.Tests.Unit/MutationModelTests.cs => Capacitor.Cli.Core.Tests.Unit/ReasonRoutingTests.cs} (93%) create mode 100644 test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs create mode 100644 test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs diff --git a/README.md b/README.md index 0788dc83f..a21afc3b0 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 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. 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. `ensure` never mutates into an ambiguous state: an unreadable probe, an active transaction, an orphaned label or a stale marker all fail closed to attention with a coded reason. + `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..7e34f09fa --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-ai2039-daemon-service-ensure-design.md @@ -0,0 +1,70 @@ +# 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) | 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-unconfirmed — attention, never guessed | + +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. + +## 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); +- 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. + +## 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..094219f81 100644 --- a/src/Capacitor.Cli.Core/Resources/help-daemon.txt +++ b/src/Capacitor.Cli.Core/Resources/help-daemon.txt @@ -74,6 +74,21 @@ 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 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. Windows/Linux degrade to plain + install/start (no gates; --json reports + verified:false). Ambiguous states (unknown probe, + active transaction, orphan label, 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..657ff78e6 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,192 @@ 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. + /// + internal async Task Ensure(string[] args) { + var json = args.Contains("--json"); + var profileName = DaemonCommands.ExtractFlagValue(args, "--profile"); + + var query = manager.Query(id); + var daemonPid = DaemonPidProbe.ValidatedPid(store, id); + var txnActive = ServiceTxnLock.IsHeld(store, id); + var txnMarker = ServiceTxnMarker.Exists(store, id); + + var decision = EnsureClassifier.Classify( + query.Probe, query.State, query.UnitPresent, daemonPid, txnMarker, txnActive); + + var state = ServiceStateToken(query); + + 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. + static string ServiceStateToken(ServiceQuery q) => q.Probe == LabelProbe.Unknown + ? "unknown" + : q.State switch { + 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; + 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; + } else { + exit = await InstallPlain(spec, startNow: true); + } + + if (exit != 0) return await EnsureFailure(exit, gateReason, state, "install", json); + return await Report(new ServiceEnsureJson(id, state, "install", "installed", Verified: manager is LaunchdServiceManager), 0, json); + } + + /// 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. + 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; + 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; + 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; + } else { + exit = await StartPlain(); + } + + if (exit != 0) return await EnsureFailure(exit, gateReason, state, "start", json); + 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, and the recovery_surface= line is re-emitted when a gate + /// refused. The exit code always passes through unchanged. + async Task EnsureFailure(int exit, StartGateReason? gateReason, string state, string action, bool json) { + var (recovery, reason) = EnsureFailureMap.Map(exit, gateReason, verified: manager is LaunchdServiceManager); + if (recovery is not null) await Console.Error.WriteLineAsync($"recovery_surface={recovery}"); + return await Report(new ServiceEnsureJson(id, state, action, "refused", recovery, reason), 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 +515,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 +525,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..958581a4d --- /dev/null +++ b/src/Capacitor.Cli/Commands/ServiceEnsure.cs @@ -0,0 +1,131 @@ +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) ahead +/// of any mutation — never install/start into an ambiguous state. +/// +internal static class EnsureClassifier { + public static EnsureDecision Classify( + LabelProbe probe, ServiceState state, bool unitPresent, + int? daemonPid, bool txnMarker, bool txnActive) { + // 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"); + + // A validated pid means the daemon is up; that is the flow's done state. + if (state == ServiceState.Running && daemonPid is not null) + return new EnsureDecision(EnsureAction.AlreadyEnabled); + + // State says installed but no unit file on disk — the label is orphaned. Repair, never a blind start. + if (state == ServiceState.Installed && !unitPresent) + return new EnsureDecision(EnsureAction.Attention, "orphan_label"); + + // A stale marker precedes every mutation row, never a blind reinstall (§6a). + if (txnMarker) + return new EnsureDecision(EnsureAction.Attention, "stale_marker"); + + // Running without a validated pid is unconfirmed, not success. + 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 (gate refusals only) 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 never auto-retried (attention); 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) { + 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) + return (RecoverySurfaceTokens.Token(RecoverySurface.Attention), null); + + // 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..ea2e8a610 100644 --- a/src/Capacitor.Cli/Commands/ServiceStatusJson.cs +++ b/src/Capacitor.Cli/Commands/ServiceStatusJson.cs @@ -17,8 +17,20 @@ 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 only +/// on a gate refusal (takeover/reinstall/attention); is the start_gate_reason= +/// token for a gate refusal, 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 — 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..2133029ae 100644 --- a/src/Capacitor.Cli/Services/ServiceVerify.cs +++ b/src/Capacitor.Cli/Services/ServiceVerify.cs @@ -167,6 +167,13 @@ 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. + /// 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; } + const string ConsentSeedVar = "KCAP_CONSENT_SEED_DEFAULT"; const string ProfileVar = "KCAP_PROFILE"; const string UrlVar = "KCAP_URL"; @@ -240,6 +247,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; @@ -578,7 +586,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", 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..15b0aebcd --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs @@ -0,0 +1,51 @@ +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.) +/// +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); + } +} 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..bf4bca36b --- /dev/null +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs @@ -0,0 +1,177 @@ +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); + } + + [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(); + } +} + +/// 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"); + } + + [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\""); + } +} + +/// 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_never_auto_retried_always_attention() { + var (r, reason) = EnsureFailureMap.Map(VerifyExit.StartGateDrift, null, verified: true); + await Assert.That(r).IsEqualTo("attention"); + await Assert.That(reason).IsNull(); + } + + [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"); + } + + [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"); + } +} From 8dcee40fc0ebffd678d3d3d40104da21e99b6f20 Mon Sep 17 00:00:00 2001 From: George Payne Date: Mon, 24 Aug 2026 13:40:56 +0200 Subject: [PATCH 2/6] docs: record the Windows answer in the AI-2039 design doc --- ...-24-ai2039-daemon-service-ensure-design.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 index 7e34f09fa..24ea67fa3 100644 --- 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 @@ -62,6 +62,27 @@ New verb in `DaemonServiceCommands`: 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. From 222549731bca8c5cd19a19c1b798b8bbbc5aa423 Mon Sep 17 00:00:00 2001 From: George Payne Date: Mon, 24 Aug 2026 13:52:18 +0200 Subject: [PATCH 3/6] fix: address qodo findings on the ensure ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default the profile to the resolved active one, so a bare 'ensure' on launchd still carries KCAP_PROFILE for the start gate's identity half (matches how Install resolves the pin). - Drift now carries verify_start_gate_drift as its reason alongside the attention surface — the JSON and the human line no longer read empty. - Mark the console-writing dispatch tests [NotInParallel]. --- src/Capacitor.Cli/Commands/DaemonServiceCommands.cs | 4 +++- src/Capacitor.Cli/Commands/ServiceEnsure.cs | 12 ++++++++---- src/Capacitor.Cli/Commands/ServiceStatusJson.cs | 9 +++++---- .../Commands/DaemonCommandsServiceEnsureTests.cs | 3 +++ .../Commands/ServiceEnsureTests.cs | 4 ++-- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs b/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs index 657ff78e6..4a375514b 100644 --- a/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs +++ b/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs @@ -299,7 +299,9 @@ async Task StatusJson() { /// internal async Task Ensure(string[] args) { var json = args.Contains("--json"); - var profileName = DaemonCommands.ExtractFlagValue(args, "--profile"); + // 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); diff --git a/src/Capacitor.Cli/Commands/ServiceEnsure.cs b/src/Capacitor.Cli/Commands/ServiceEnsure.cs index 958581a4d..ff1a81a9b 100644 --- a/src/Capacitor.Cli/Commands/ServiceEnsure.cs +++ b/src/Capacitor.Cli/Commands/ServiceEnsure.cs @@ -86,9 +86,10 @@ public static class RecoverySurfaceTokens { /// Pure: the wire fields for an ensure failure — recovery (gate refusals only) 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 never auto-retried (attention); 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. +/// (takeover/reinstall/attention); drift is the gate's TOCTOU re-check refusing, surfaced as the +/// attention row with its token, never auto-retried; 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( @@ -102,7 +103,10 @@ public static (string? Recovery, string? Reason) Map( } if (exit == VerifyExit.StartGateDrift) - return (RecoverySurfaceTokens.Token(RecoverySurface.Attention), null); + // 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)); // 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). diff --git a/src/Capacitor.Cli/Commands/ServiceStatusJson.cs b/src/Capacitor.Cli/Commands/ServiceStatusJson.cs index ea2e8a610..b7ffc30ac 100644 --- a/src/Capacitor.Cli/Commands/ServiceStatusJson.cs +++ b/src/Capacitor.Cli/Commands/ServiceStatusJson.cs @@ -20,10 +20,11 @@ public sealed record ServiceStatusJson( /// 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 only /// on a gate refusal (takeover/reinstall/attention); is the start_gate_reason= -/// token for a gate refusal, 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 — false on plain install/start and on no-op rows; -/// the flow's copy must key off , not off verified. +/// token for a gate refusal, the verify_* token for any other verified-transaction failure +/// (drift included — attention surface plus its token), or plain_failure for a degraded +/// (non-launchd) failure. reports whether THIS run performed the launchd +/// verified transaction — 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); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs index 15b0aebcd..a8513e4e3 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs @@ -9,6 +9,9 @@ namespace Capacitor.Cli.Tests.Unit.Commands; /// 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; } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs index bf4bca36b..af8549bc8 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs @@ -155,10 +155,10 @@ public async Task StartGate_without_a_reason_fails_closed_to_attention() { } [Test] - public async Task Drift_is_never_auto_retried_always_attention() { + 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).IsNull(); + await Assert.That(reason).IsEqualTo("verify_start_gate_drift"); } [Test] From f9222982926d2e640d7cce0eed03dcec2c3c9c9a Mon Sep 17 00:00:00 2001 From: George Payne Date: Mon, 24 Aug 2026 14:01:23 +0200 Subject: [PATCH 4/6] docs: pin the drift wire contract as a gate-family refusal --- src/Capacitor.Cli/Commands/ServiceStatusJson.cs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Capacitor.Cli/Commands/ServiceStatusJson.cs b/src/Capacitor.Cli/Commands/ServiceStatusJson.cs index b7ffc30ac..f3a73aa07 100644 --- a/src/Capacitor.Cli/Commands/ServiceStatusJson.cs +++ b/src/Capacitor.Cli/Commands/ServiceStatusJson.cs @@ -18,13 +18,15 @@ public sealed record ServiceStatusJson( 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 only -/// on a gate refusal (takeover/reinstall/attention); is the start_gate_reason= -/// token for a gate refusal, the verify_* token for any other verified-transaction failure -/// (drift included — attention surface plus its token), or plain_failure for a degraded -/// (non-launchd) failure. reports whether THIS run performed the launchd -/// verified transaction — false on plain install/start and on no-op rows; the flow's copy must key off -/// , not off verified. +/// (on a refusal) which recovery surface the flow should offer. is non-null on +/// a gate-family refusal — the start gate (exit 28, mapped takeover/reinstall/attention via the +/// pinned table) or the gate's TOCTOU re-check drift +/// (exit 29, always attention — never auto-retried, matching the app's own table). +/// is the start_gate_reason= token for a start-gate refusal, verify_start_gate_drift for +/// drift, 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 — 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); From 4d368a56d39c7ef36822d4edf997228d892a318d Mon Sep 17 00:00:00 2001 From: George Payne Date: Mon, 24 Aug 2026 16:14:49 +0200 Subject: [PATCH 5/6] fix: address peer review on the ensure ladder (7 findings) --- README.md | 2 +- ...-24-ai2039-daemon-service-ensure-design.md | 21 ++- .../Resources/help-daemon.txt | 28 ++-- .../Commands/DaemonServiceCommands.cs | 64 ++++++-- src/Capacitor.Cli/Commands/ServiceEnsure.cs | 64 +++++--- .../Commands/ServiceStatusJson.cs | 21 ++- src/Capacitor.Cli/Services/ServiceVerify.cs | 49 ++++-- .../DaemonCommandsServiceEnsureTests.cs | 19 +++ .../Commands/ServiceEnsureTests.cs | 139 ++++++++++++++++++ .../Services/ServiceVerifyInstallTests.cs | 5 + .../Services/ServiceVerifyStartTests.cs | 31 ++++ 11 files changed, 377 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index a21afc3b0..cf6e02fcd 100644 --- a/README.md +++ b/README.md @@ -749,7 +749,7 @@ 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 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. 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. `ensure` never mutates into an ambiguous state: an unreadable probe, an active transaction, an orphaned label or a stale marker all fail closed to attention with a coded reason. +`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. 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 index 24ea67fa3..0496d07aa 100644 --- 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 @@ -19,12 +19,24 @@ From a fresh `service status --json`: | --- | --- | --- | | 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) | 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-unconfirmed — attention, never guessed | +| 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. +**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 @@ -42,7 +54,8 @@ is the same rescue shape as AI-2167 (classes leaving the app before AI-2053 dele New verb in `DaemonServiceCommands`: - fresh status query + the same lifecycle evidence `status --json` reads (probe/state/unit - presence, txn marker/active, validated daemon pid); + 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** diff --git a/src/Capacitor.Cli.Core/Resources/help-daemon.txt b/src/Capacitor.Cli.Core/Resources/help-daemon.txt index 094219f81..3b71a8e09 100644 --- a/src/Capacitor.Cli.Core/Resources/help-daemon.txt +++ b/src/Capacitor.Cli.Core/Resources/help-daemon.txt @@ -78,17 +78,23 @@ Subcommands for service (per-user; auto-restarts on crash/SIGKILL, starts at log 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 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. Windows/Linux degrade to plain - install/start (no gates; --json reports - verified:false). Ambiguous states (unknown probe, - active transaction, orphan label, stale marker) fail - closed to attention with a coded reason — never - mutated into. + 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 4a375514b..11ec98d6a 100644 --- a/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs +++ b/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs @@ -296,6 +296,8 @@ async Task StatusJson() { /// 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"); @@ -308,11 +310,25 @@ internal async Task Ensure(string[] args) { 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.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 (manager is LaunchdServiceManager && string.IsNullOrEmpty(profileName) + && decision.Action is EnsureAction.Install or EnsureAction.Start) { + 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); @@ -346,10 +362,14 @@ profileName is 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. - static string ServiceStateToken(ServiceQuery q) => q.Probe == LabelProbe.Unknown + /// 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", @@ -398,6 +418,8 @@ async Task EnsureInstall(string? profileName, string state, bool json) { 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); @@ -407,12 +429,14 @@ async Task EnsureInstall(string? profileName, string state, bool json) { 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; + 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); + 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); } @@ -421,7 +445,10 @@ async Task EnsureInstall(string? profileName, string state, bool json) { /// 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. + /// 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) { @@ -429,7 +456,10 @@ internal static Dictionary EnsureUnitEnv( ["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; + if (!string.IsNullOrEmpty(profileName)) { + env["KCAP_PROFILE"] = profileName; + env.Remove("KCAP_URL"); + } return env; } @@ -441,18 +471,20 @@ async Task EnsureStart(string? profileName, string state, bool json) { 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; + gateReason = engine.LastGateReason; + bootRefusalToken = engine.LastBootRefusalToken; } else { exit = await StartPlain(); } - if (exit != 0) return await EnsureFailure(exit, gateReason, state, "start", json); + 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); } @@ -468,12 +500,16 @@ async Task EnsureStart(string? profileName, string state, bool json) { /// 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, and the recovery_surface= line is re-emitted when a gate - /// refused. The exit code always passes through unchanged. - async Task EnsureFailure(int exit, StartGateReason? gateReason, string state, string action, bool json) { - var (recovery, reason) = EnsureFailureMap.Map(exit, gateReason, verified: manager is LaunchdServiceManager); + /// 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), exit, json); + return await Report(new ServiceEnsureJson(id, state, action, "refused", recovery, reason, Verified: verified), exit, json); } /// diff --git a/src/Capacitor.Cli/Commands/ServiceEnsure.cs b/src/Capacitor.Cli/Commands/ServiceEnsure.cs index ff1a81a9b..952f096b7 100644 --- a/src/Capacitor.Cli/Commands/ServiceEnsure.cs +++ b/src/Capacitor.Cli/Commands/ServiceEnsure.cs @@ -30,13 +30,15 @@ public enum EnsureAction { /// /// 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) ahead -/// of any mutation — never install/start into an ambiguous state. +/// 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? 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) @@ -47,19 +49,26 @@ public static EnsureDecision Classify( if (txnActive) return new EnsureDecision(EnsureAction.Attention, "txn_active"); - // A validated pid means the daemon is up; that is the flow's done state. - if (state == ServiceState.Running && daemonPid is not null) - return new EnsureDecision(EnsureAction.AlreadyEnabled); - - // State says installed but no unit file on disk — the label is orphaned. Repair, never a blind start. - if (state == ServiceState.Installed && !unitPresent) + // 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"); - // A stale marker precedes every mutation row, never a blind reinstall (§6a). if (txnMarker) return new EnsureDecision(EnsureAction.Attention, "stale_marker"); - // Running without a validated pid is unconfirmed, not success. + // "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"); @@ -83,17 +92,22 @@ public static class RecoverySurfaceTokens { } /// -/// Pure: the wire fields for an ensure failure — recovery (gate refusals only) 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; 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. +/// 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) { + 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 @@ -108,6 +122,18 @@ public static (string? Recovery, string? Reason) Map( // 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"); diff --git a/src/Capacitor.Cli/Commands/ServiceStatusJson.cs b/src/Capacitor.Cli/Commands/ServiceStatusJson.cs index f3a73aa07..674c0ce89 100644 --- a/src/Capacitor.Cli/Commands/ServiceStatusJson.cs +++ b/src/Capacitor.Cli/Commands/ServiceStatusJson.cs @@ -19,14 +19,19 @@ public sealed record ServiceStatusJson( /// 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 gate-family refusal — the start gate (exit 28, mapped takeover/reinstall/attention via the -/// pinned table) or the gate's TOCTOU re-check drift -/// (exit 29, always attention — never auto-retried, matching the app's own table). -/// is the start_gate_reason= token for a start-gate refusal, verify_start_gate_drift for -/// drift, 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 — false on plain install/start and on no-op rows; the flow's copy must -/// key off , not off verified. +/// 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); diff --git a/src/Capacitor.Cli/Services/ServiceVerify.cs b/src/Capacitor.Cli/Services/ServiceVerify.cs index 2133029ae..8e4757e85 100644 --- a/src/Capacitor.Cli/Services/ServiceVerify.cs +++ b/src/Capacitor.Cli/Services/ServiceVerify.cs @@ -169,11 +169,22 @@ readPlist is null && plistExists is null /// 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. - /// 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. + /// 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"; @@ -199,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) { @@ -379,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 @@ -407,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); } /// @@ -669,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); @@ -715,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; } @@ -794,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)); @@ -856,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.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs index a8513e4e3..e15811385 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs @@ -51,4 +51,23 @@ public async Task Active_transaction_reports_attention_without_mutating() { 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 arm must fail + // closed up front rather than report a success that can never be gated-started. Driven with a + // real LaunchdServiceManager whose launchctl calls are stubbed to "no such service" — the + // refusal fires before any transaction work. + [Test] + public async Task Launchd_install_without_a_profile_fails_closed() { + Capacitor.Cli.Core.Config.AppConfig.ResetResolvedStateForTesting(); + var manager = new LaunchdServiceManager( + runProcess: (_, _) => (1, "", "Could not find service: io.kurrent.kcap.daemon.test-id"), + runBounded: (_, _, _) => (1, "", "Could not find service: io.kurrent.kcap.daemon.test-id", false)); + + using var capture = ConsoleOutput.StartCapture(); + var exit = await new DaemonServiceCommands(Daemons.Store, manager, "test-id").Ensure(["--json"]); + + await Assert.That(exit).IsEqualTo(1); + await Assert.That(capture.GetCapturedOutput()).Contains("no_profile_configured"); + } } diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs index af8549bc8..6d5d5d324 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ServiceEnsureTests.cs @@ -40,6 +40,54 @@ public async Task Running_with_validated_pid_is_already_enabled() { 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); @@ -100,6 +148,27 @@ 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. @@ -124,6 +193,15 @@ public async Task Recovery_and_reason_are_nullable() { 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); @@ -131,6 +209,35 @@ public async Task Serializes_through_the_shared_context() { } } +/// 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 { @@ -168,6 +275,38 @@ public async Task Verified_non_gate_exit_carries_its_verify_token() { 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); 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) From 30dfd2462e4cad66e197da5d5b9cd0a700bf16ec Mon Sep 17 00:00:00 2001 From: George Payne Date: Mon, 24 Aug 2026 16:34:34 +0200 Subject: [PATCH 6/6] fix: make the launchd no-profile refusal a pure predicate (Windows-safe test) --- .../Commands/DaemonServiceCommands.cs | 11 ++++++-- .../DaemonCommandsServiceEnsureTests.cs | 26 ++++++++----------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs b/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs index 11ec98d6a..de084ce3c 100644 --- a/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs +++ b/src/Capacitor.Cli/Commands/DaemonServiceCommands.cs @@ -323,8 +323,7 @@ internal async Task Ensure(string[] args) { // 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 (manager is LaunchdServiceManager && string.IsNullOrEmpty(profileName) - && decision.Action is EnsureAction.Install or EnsureAction.Start) { + 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); } @@ -440,6 +439,14 @@ async Task EnsureInstall(string? profileName, string state, bool json) { 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 diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs index e15811385..cddbfb09e 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs @@ -53,21 +53,17 @@ public async Task Active_transaction_reports_attention_without_mutating() { } // 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 arm must fail - // closed up front rather than report a success that can never be gated-started. Driven with a - // real LaunchdServiceManager whose launchctl calls are stubbed to "no such service" — the - // refusal fires before any transaction work. + // 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] - public async Task Launchd_install_without_a_profile_fails_closed() { - Capacitor.Cli.Core.Config.AppConfig.ResetResolvedStateForTesting(); - var manager = new LaunchdServiceManager( - runProcess: (_, _) => (1, "", "Could not find service: io.kurrent.kcap.daemon.test-id"), - runBounded: (_, _, _) => (1, "", "Could not find service: io.kurrent.kcap.daemon.test-id", false)); - - using var capture = ConsoleOutput.StartCapture(); - var exit = await new DaemonServiceCommands(Daemons.Store, manager, "test-id").Ensure(["--json"]); - - await Assert.That(exit).IsEqualTo(1); - await Assert.That(capture.GetCapturedOutput()).Contains("no_profile_configured"); + [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); } }