# Crux desktop shell The Crux desktop shell is a Tauri v2 connection manager for the existing web console. A named profile either starts the bundled `corecruxd` sidecar or attaches to a daemon that is already running. The shell remains in-tree and is workspace-excluded per OD-31; it does not add a second console implementation. Milestone M1 implements both lifecycle modes and resolves OD-42 by reusing the daemon's existing static agent-token bearer authentication for attach profiles. It does not add a daemon authentication mechanism. The token is resolved by native Rust through the operating-system credential client and injected by a shell-owned loopback proxy. It is never placed in profile JSON, a URL, web storage, page JavaScript, a Tauri command, or an operator-facing error. ## Architecture ```text attach profile +---------------------------+ | existing corecruxd | | HTTP loopback or HTTPS | | static bearer auth | +-------------^-------------+ | native HTTP; bearer injected here +----------------------- Crux desktop shell ------------------------+ | OS credential store --> native broker --> 127.0.0.1: | | profile proxy | | ^ | | | no Tauri IPC | | Tauri webview /console | | | | | bundled profile v | | lifecycle supervisor --> bundled corecruxd on loopback/auth off | +-------------------------------------------------------------------+ ``` There are three crates, each with its own `[workspace]` so none joins the root daemon workspace: | Crate | Path | Local role | |---|---|---| | `crux-shell-lifecycle` | `lifecycle/` | Dependency-free sidecar spawn, readiness, shutdown, and Drop backstop. | | `crux-shell-connection` | `connection/` | Lean profile store, URL policy, local-plan hashing and path authorisation, health model, finite backoff, platform credential broker, secret wrapper, and loopback proxy; its direct dependencies are exact-pinned `blake3` and `getrandom`. | | `crux-desktop-shell` | `app/` | Thin Tauri integration, tray/window policy, and pinned Rustls HTTP adapter. Requires the WebKit/GTK toolchain on Linux. | The app uses exact dependency pins. Its attach transport is `ureq =3.3.0` with Rustls and normal WebPKI certificate and hostname validation. Redirect following, environment proxy discovery, response decompression, and insecure TLS overrides are disabled. Probes have a five-second per-request deadline; forwarded requests have a 30-second deadline, so browser EventSource clients reconnect instead of letting a quiet hostile stream retain an old profile token. Credential lookup uses fixed platform clients, so no extra keychain crate or plaintext fallback is present. ## Connection profiles Non-secret connection configuration is stored durably as `/connection-profiles-v1.json`. The platform app-data root is resolved by Tauri for application identifier `com.cuecrux.crux`; do not assume a portable relative path. Replacement is atomic on Unix and uses a recoverable same-directory backup sequence on Windows. The document is schema version 1 and names exactly one active profile: ```json { "schema_version": 1, "active_profile": "local", "profiles": [ { "name": "local", "mode": "bundled", "url": "", "token-ref": null, "local-plan-root": "/home/operator/workspace/.agent/execplans" }, { "name": "operations", "mode": "attach", "url": "https://crux.example.com", "token-ref": "operations-agent-token", "local-plan-root": null } ] } ``` On first launch, an absent store is created with one active bundled profile named `Local`. An invalid or unreadable existing store is not overwritten: the window shows a native configuration error, profile actions are disabled, and the operator must correct the file and restart Crux. Profile names must be unique. A bundled profile has no token reference; its URL is normally empty because the shell allocates the sidecar port. An attach profile requires an origin-only URL and a `token-ref`. The reference is the credential-store lookup key, not the bearer, and uses only ASCII letters, digits, `.`, `_`, `~`, `-`, or `:` so it is safe at both fixed native-client boundaries. Missing or unknown JSON fields are rejected, which also prevents adding a plaintext `token` field. A missing, locked, malformed, or unavailable credential fails closed and produces an explicit native-owned status page. `local-plan-root` is optional and defaults to `null` for schema-v1 profiles created before M5a. When configured on the active profile, the shell hashes the raw bytes of top-level `.md` files with BLAKE3 and exposes the immutable slug-to-hash map at document start as `window.CRUX_LOCAL_PLAN_HASHES`. Without a configured root, no hash map is injected. A profile switch builds a fresh isolated webview with that profile's own initialization script; hashes never transit through the outgoing page. ### Lifecycle ownership | Mode | Daemon ownership | Authentication | Exit and switch behaviour | |---|---|---|---| | `bundled` | The shell starts the packaged sidecar through `crux-shell-lifecycle`. | Auth off on an ephemeral loopback port, unchanged from the shipped shell. | The shell shuts down only the sidecar handle it owns; shutdown and Drop preserve the no-orphan invariant. | | `attach` | The operator or service manager owns an already-running daemon. | Existing static agent-token bearer, retrieved from the OS credential store. | The shell never starts, restarts, signals, or stops the daemon. Closing the app only stops the shell proxy. | Switching changes and safely persists the active profile without restarting the app. Every activation binds a new `127.0.0.1` ephemeral status-proxy port before the webview is repointed; attach mode continues through that proxy after it becomes ready, while bundled mode navigates directly to its live sidecar. As soon as a switch reserves a new generation, the old proxy is synchronously quiesced into a native status state: its shared forwarding credential is made inactive, tracked sockets are closed, and a newly admitted request cannot reach that upstream. A request already dispatched before quiescence retains only its bounded 30-second transport budget while the old proxy performs a bounded drain. The webview uses an in-memory/incognito browser profile, and the shell clears browsing data before every profile switch; failure to clear blocks the switch. Native status pages clear cache, cookies, and storage. The attach handshake clears cache and storage before redirecting while preserving its newly minted session cookie. The different origin and clearing isolate web storage across profiles, while forwarded daemon request and response cookie headers are removed. Switching away from bundled mode releases only the shell-owned sidecar. Switching away from attach mode cannot affect the external daemon. ## Credential provisioning The daemon and credential store must contain the same static agent token. The profile stores only the lookup reference. Tokens must be 32–256 bytes using the daemon token alphabet: ASCII letters, digits, `.`, `_`, `~`, and `-`. Attach credential lookup is implemented for Linux and Windows; unsupported platforms fail closed. Bundled mode retains its existing platform support. ### Linux Secret Service Runtime prerequisites are an active Secret Service provider in the user's D-Bus session and the client at the fixed path `/usr/bin/secret-tool` (commonly provided by the `libsecret-tools` package). Store the token under the exact attributes used by the broker; this command reads the secret from standard input rather than a command-line argument: ```bash /usr/bin/secret-tool store --label='Crux operations agent token' \ service com.cuecrux.crux token-ref operations-agent-token ``` Enter only the bearer when prompted. `operations-agent-token` must match the profile's `token-ref`. A missing helper, absent D-Bus session, denied unlock, locked collection, missing item, or lookup timeout is an unavailable credential; the app does not consult an environment variable or file instead. ### Windows PasswordVault Runtime lookup uses Windows Credential Manager through WinRT `Windows.Security.Credentials.PasswordVault`, launched by the native host with the fixed Windows PowerShell path. The credential resource is `com.cuecrux.crux`; its user name is the profile's `token-ref`. Windows PowerShell 5.1 and an accessible user Credential Locker are therefore runtime prerequisites. The following provisioning session prompts without putting the token in the command history and clears the temporary unmanaged copy: ```powershell $null = [Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] $secret = Read-Host 'Static agent token' -AsSecureString $pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secret) try { $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer) $vault = New-Object Windows.Security.Credentials.PasswordVault $item = New-Object Windows.Security.Credentials.PasswordCredential( 'com.cuecrux.crux', 'operations-agent-token', $plain) $vault.Add($item) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer) Remove-Variable plain, secret -ErrorAction SilentlyContinue } ``` Replace `operations-agent-token` with the profile reference. Missing, locked, denied, or timed-out PasswordVault access fails closed; there is no plaintext or environment fallback. ## Transport and browser boundary Attach URLs are origins only: no user information, base path, query, fragment, backslash, or zero port. Exact `localhost` is normalized to `127.0.0.1` so it does not depend on ambient DNS or hosts-file resolution. | Attach URL | Result | Reason | |---|---|---| | `http://localhost:14800` | Allowed, normalized to `http://127.0.0.1:14800` | Exact localhost. | | `http://127.0.0.1:14800` or `http://[::1]:14800` | Allowed | Literal loopback address. | | `https://127.0.0.1:14800` | Allowed if its certificate validates normally | HTTPS is accepted; no certificate bypass exists. | | `http://192.168.1.20:14800` or `http://crux.example.com` | Rejected | Plain HTTP is loopback-only. | | `https://crux.example.com` | Allowed if its chain and hostname validate | Non-loopback origins require HTTPS. | | `https://user@crux.example.com/base?x=1` | Rejected | Credentials, paths, queries, and fragments are outside the profile contract. | For attach mode, the webview loads the shell's loopback proxy, never the daemon origin. A one-time shell-minted URL secret establishes an HttpOnly, `SameSite=Strict` proxy-session cookie; every forwarded method must present that in-memory session ID before bearer injection. Browser origin/fetch evidence is an additional check, while all caller cookies (including the proxy cookie), authorization, forwarding, and hop-by-hop headers are removed upstream. It disables upstream redirect following. Same-origin redirects are rewritten to proxy-relative locations; foreign-origin, scheme-relative, and non-HTTP redirects such as `file://` are blocked. `Set-Cookie`, refresh navigation, attachment responses, and unsafe response headers are stripped or blocked. A reflected token blocks a response header name or value and is replaced with `[REDACTED]` in a streamed response body. The default Tauri capability declares an empty permission list and no remote URL grant. Profile, keychain, lifecycle, tray, and navigation operations stay in native Rust; remote console content receives no Tauri IPC, filesystem, shell, dialog, keychain, or updater permission. The bundled fallback document uses a `default-src 'none'` CSP. Proxy responses replace daemon CSP headers with a shell-owned same-origin policy and deny framing, referrers, privileged browser features, and cross-origin resources. Navigation is limited to the active profile proxy plus the live bundled-sidecar origin. An external HTTP(S) target is denied in the webview and placed in a one-item native tray approval queue. A `file:` navigation is likewise denied and may only queue a decoded path that canonicalises to a real lowercase-`.md` file strictly inside the active profile's canonical `local-plan-root`. The operator's native tray action re-authorises a local path immediately before handoff. Scripted navigation cannot spawn a handler or replace a pending target, and a profile switch discards it. Traversal, symlink escape, outside absolute paths, other extensions, directories, and missing files fail closed. Other schemes, new windows, and downloads are denied. Linux handoff uses the fixed `/usr/bin/xdg-open` client; Windows uses the system `rundll32.exe` handler. ## Health, status, and switching Every activation probes `/healthz`, followed by `/v1/version`. The latter is the forward-compatible hook for M2: schema-version-1 `runtime_capabilities` are summarized while unknown fields, an absent descriptor, and future schema versions are tolerated. | Status | Meaning and rendering | |---|---| | `ok` | `/healthz` and `/v1/version` returned usable 2xx responses and no reported degradation was found. The console loads and the tray marks the profile healthy. | | `degraded` | The daemon answered but reported `ok=false`, sync/capability degradation, a non-2xx response, or an unusable version descriptor. The reason is visible in the tray/status surface rather than discarded. | | `unreachable` | The daemon cannot be reached, or native credential, transport, or startup work failed. The proxy renders an escaped native-owned error page instead of a blank webview. | Before a probe completes, the status proxy serves a connecting page and the tray reason says that a connection check is in progress. Attach retries are finite and visible: the default exponential schedule permits five delays (`250ms`, `500ms`, `1s`, `2s`, `4s`) after the initial probe and then leaves the profile explicitly unreachable. Bundled startup retains the lifecycle crate's bounded readiness budget and likewise stops at a rendered error. There is no background infinite retry loop. The tray contains one status-bearing row per profile plus explicit Retry and Quit actions. Retry or a new profile selection starts a fresh bounded activation; bundled Retry restarts only the shell-owned daemon. ## Local verification Both support crates are WebKit-independent and can be checked on a host without webkit2gtk. Lifecycle is std-only; connection has two exact-pinned direct dependencies: ```bash (cd shells/desktop/lifecycle && cargo fmt --check && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked) (cd shells/desktop/connection && cargo fmt --check && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked) ``` The connection test suite contains checks for profile round-trip and secret-field rejection, URL policy, schema-v1 health parsing, degradation and unreachable states, bounded backoff, same-origin enforcement, bearer replacement, the one-time session handshake, cookie enforcement/stripping and rotation, credential-output validation, token redaction, and a hostile upstream that returns cookies and foreign or `file://` redirects. M5a also covers raw-byte plan hashes, missing/ignored plan roots, canonical path escape attempts, exact origin matching, public-link classification, and stale generation rejection. On a machine without the WebKit/GTK development packages, limit app checks to formatting and structural validation: ```bash rustfmt --edition 2021 --check shells/desktop/app/src/main.rs shells/desktop/app/src/upstream.rs cargo metadata --locked --no-deps --manifest-path shells/desktop/app/Cargo.toml python3 -m json.tool shells/desktop/app/tauri.conf.json >/dev/null python3 -m json.tool shells/desktop/app/capabilities/default.json >/dev/null typos shells/desktop/ git diff --check ``` The Linux job in `.github/workflows/desktop-shell.yml` is the compile gate for the Tauri crate and must build it with the checked-in lockfile and WebKit/GTK packages. Packaging still stages `corecruxd` into Tauri's `externalBin` slot; the existing icon generation and platform signing/notarisation process is unchanged. ## Operator validation checklist Run the manual M1 security and lifecycle pass on packaged Linux and Windows builds. Record platform, app build, daemon build, and date with the result. - [ ] Configure one bundled profile and two attach profiles targeting different live daemons; provision distinct static tokens under their `token-ref` values. - [ ] Switch between all profiles from the tray. Each console re-renders without restarting the app, browsing data from the prior origin is cleared, and the connecting page remains visible until the tray settles on `ok`/`degraded`/`unreachable`. - [ ] Make an attach daemon unreachable. Confirm a reasoned native error page, the finite visible retry sequence, and no silent retry after exhaustion. - [ ] Lock or disable the platform credential store and remove a credential. Confirm both cases fail closed with a sanitized state and do not read a token from profile JSON, environment, URL, console storage, or a file. - [ ] Capture webview storage, HTTP traffic, and shell/daemon logs while using attach mode. Confirm the bearer appears only on the native proxy-to-daemon request and nowhere in browser-visible content or persisted shell data. - [ ] Exercise a hostile daemon response containing `Set-Cookie`, a foreign redirect, a `file://` redirect, an attachment, and reflected token bytes. Confirm navigation/download is denied and no token reaches a response. - [ ] Attempt Tauri IPC, a new window, a non-HTTP external scheme, and access from the previous profile origin. Confirm each is denied; confirm a normal external HTTP(S) link queues exactly one labelled tray approval and opens in the system browser only after that native action is selected. Confirm scripted repeats do not spawn handlers or replace the pending target. - [ ] Switch bundled to attach and quit. The attach daemon must survive; the previously shell-owned sidecar and both profile proxies must not. - [ ] Switch attach to bundled and quit. The external daemon must survive; the current bundled sidecar must shut down, with Drop/exit as the backstop. - [ ] Repeat the existing clean-VM install, bundled-sidecar SHA, startup failure, and no-orphan checks before shipping an installer. The runtime credential prompts, OS-store lock behaviour, system-browser handoff, real WebKit navigation hooks, packet/log inspection, cross-daemon switching, and process ownership assertions are intentionally operator-gated because the headless development host cannot prove them.