diff --git a/.github/workflows/eval-with-entra-id.yml b/.github/workflows/eval-with-entra-id.yml index ba10d72fc..41ae23606 100644 --- a/.github/workflows/eval-with-entra-id.yml +++ b/.github/workflows/eval-with-entra-id.yml @@ -1,9 +1,9 @@ name: eval-with-entra-id on: push: - branches: [main] + branches: [main, v3] pull_request: - branches: [main] + branches: [main, v3] permissions: contents: read diff --git a/.github/workflows/pr-eval-shell-tests.yml b/.github/workflows/pr-eval-shell-tests.yml index c4e42933c..ed36e2a72 100644 --- a/.github/workflows/pr-eval-shell-tests.yml +++ b/.github/workflows/pr-eval-shell-tests.yml @@ -1,9 +1,9 @@ name: pr-eval-shell-tests on: push: - branches: [main] + branches: [main, v3] pull_request: - branches: [main] + branches: [main, v3] permissions: contents: read diff --git a/.github/workflows/pr-l1-static-fast.yml b/.github/workflows/pr-l1-static-fast.yml index be454ca57..1ff095460 100644 --- a/.github/workflows/pr-l1-static-fast.yml +++ b/.github/workflows/pr-l1-static-fast.yml @@ -4,9 +4,9 @@ name: pr-l1-static-fast on: pull_request: - branches: [main] + branches: [main, v3] push: - branches: [main] + branches: [main, v3] permissions: contents: read diff --git a/AGENTS.md b/AGENTS.md index 196e8ddff..392d98ad1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -405,8 +405,9 @@ Optional **desktop companion** pieces also live in sibling flakes: Consumer flakes that combine these pieces keep a single nixpkgs and toolkit revision by using `inputs.d2b.inputs.nixpkgs.follows = "nixpkgs"`, `inputs.d2b-toolkit.inputs.nixpkgs.follows = "nixpkgs"`, and -`inputs.d2b-wlterm.inputs.d2b-toolkit.follows = "d2b-toolkit"` (same for -WeezTerm). The exact copy-paste boilerplate lives in +`inputs.d2b-wlterm.inputs.d2b-toolkit.follows = "d2b-toolkit"`. WeezTerm +follows only `nixpkgs`; its flake does not expose a toolkit input. The exact +copy-paste boilerplate lives in [`docs/how-to/configure-desktop-terminal-integration.md`](./docs/how-to/configure-desktop-terminal-integration.md). The composition pattern is intentionally one-way: d2b core does not import @@ -954,13 +955,14 @@ Touch these only with a clear plan and a corresponding test run. | GPU sidecar (graphics VMs) | `nixos-modules/components/graphics.nix` + broker `SpawnRunner` for cloud-hypervisor on graphics VMs; pidfd handed back via `OpenPidfd` and supervised by `d2bd` | Graphics VMs run cloud-hypervisor with the GPU device attached. Restarting `d2bd` no longer terminates CH — pidfd handoff means the child outlives a daemon reconnect — but the broker spawn path is the only audited place CH is launched. Bypassing it breaks the audit trail. Validate with `tests/video-sidecar-hardening-eval.sh`. | | Video sidecar (graphics VMs) | `nixos-modules/components/video/guest.nix`, `nixos-modules/processes-json.nix`, `pkgs/vhost-user-video/`, `packages/d2b-host/src/video_argv.rs`, broker `SpawnRunner{role: Video}` | `graphics.videoSidecar = true` is an explicit opt-in H264 decode path: guest `virtio_media` + patched Cloud Hypervisor `--vhost-user-media` + patched crosvm `device video-decoder --backend vaapi`. There is no per-VM video systemd unit, no stock crosvm/CH fallback, and no free-form video extra args. The video runner MUST use the dedicated `d2b--video` principal, not `d2b--gpu`, so broker/activation ACLs can deny host Wayland/PipeWire/Pulse sockets to video without breaking GPU cross-domain. The broker masks `/dev` for the video runner and exposes only the declared device allowlist: default `/dev/dri/renderD128`, plus `/dev/nvidiactl`, `/dev/nvidia0`, and `/dev/nvidia-uvm` only when `graphics.videoNvidiaDecode = true`. `virtio_media` is a guest module, not a host `/proc/modules` preflight requirement. Firefox/VA-API uses the separate experimental `graphics.virglVideo` GPU path; it is default-off and must not be treated as stable video-sidecar coverage. Validate with `tests/video-contract-eval.sh`, `tests/video-argv-shape.sh`, and `tests/minijail-validator-video.sh`. | | UI color contract / niri backend | `nixos-modules/ui-colors.nix`, `nixos-modules/niri-vm-borders.nix`, `docs/reference/ui-colors.{md,json}`, `tests/unit/nix/cases/niri-vm-borders.nix`, and sibling consumers such as `vicondoa/d2b-wlcontrol` | The compositor-agnostic `d2b.site.ui` / `d2b.envs..ui` / `d2b.vms..ui` color model is the source of truth for host/env/VM/state colors. Generated `/etc/d2b/ui-colors.json` and `/etc/d2b/ui-colors.css` are public presentation metadata, not authz or policy inputs. Niri-specific settings belong only under `d2b.site.ui.compositors.niri`; do not add compositor-specific color source options. Keep the JSON schema, reference docs, GTK CSS `@define-color` names, and nix-unit artifact-shape tests in sync. Downstream tools must fail visibly but remain usable when the artifact is missing or malformed, without reading root-owned d2b state directly. | -| Unsafe-local provider and launcher items | `nixos-modules/options-realms-workloads.nix`, `nixos-modules/unsafe-local-workloads-json.nix`, `packages/d2b-core/src/unsafe_local_workloads.rs`, `packages/d2b-contracts/src/unsafe_local_wire.rs`, and `docs/reference/unsafe-local-provider.md` | `unsafe-local` is explicit and default-denied. It runs only as the exact authenticated requesting uid and provides no isolation boundary. Public metadata carries item identity/presentation but never argv; configured argv comes only from the integrity-pinned private bundle. The helper connects outward to a daemon-owned socket, and user scopes/Wayland rails are lifecycle/presentation mechanisms, not same-uid containment. Do not add cross-uid execution, a direct compositor fallback, VM state/network/device semantics, a root service, or a broker free-form command operation. | +| Unsafe-local provider, launcher, and persistent-shell helper | `nixos-modules/options-realms-workloads.nix`, `nixos-modules/unsafe-local-workloads-json.nix`, `packages/d2b-core/src/unsafe_local_workloads.rs`, `packages/d2b-contracts/src/unsafe_local_wire.rs`, `packages/d2b-unsafe-local-helper/src/{shell_runtime,shell_supervisor,shell_socket,output_ring,tty_exec}.rs`, and `docs/reference/unsafe-local-provider.md` | `unsafe-local` is explicit and default-denied. It runs only as the exact authenticated requesting uid and provides no isolation boundary. Public metadata never carries configured argv or shell policy; those come only from the integrity-pinned private bundle. A persistent-shell supervisor in a verified transient USER scope—not the reconnectable helper or d2bd—owns the login-shell PTY, bounded merged-output ring, attachment, and private same-UID listener. Ledger adoption preserves ambiguous sessions as degraded; teardown closes the PTY and signals only the exact re-verified scope. The helper-wide ring reservation is bounded, terminal responses transfer exactly one CLOEXEC stream fd, and shell names, supervisor ids, paths, environment, process/unit identity, and bytes stay out of Debug/errors/audit. Do not add cross-uid execution, a direct compositor fallback, VM state/network/device semantics, a root service, per-VM unit, broker op, free-form shell command, or broad same-UID cleanup. | | Manifest contract | `docs/reference/manifest-schema.{md,json}` + `nixos-modules/manifest.nix` | Version-pinned via `manifestVersion`. Adding, removing, or renaming a per-VM field requires bumping the version, updating the schema, and noting it in the CHANGELOG. The `static.sh` md↔json drift gate catches partial updates. | | Manifest bundle — private artifacts | `docs/reference/manifest-bundle.md` + `docs/reference/schemas/v2/*.json` + `packages/d2b-core/src/{bundle,host,processes,privileges,closures,minijail_profile}.rs` + `nixos-modules/{bundle,bundle-artifacts,host-json,processes-json,privileges-json,closures-json,minijail-profiles}.nix` + `packages/xtask/src/main.rs` (`gen-schemas`) | Sensitive bundle artifacts install at `root:d2bd` 0640 and ground every broker/sandbox/runner behaviour. `d2b-core` DTOs are canonical; `d2b._bundle` is the typed internal artifact table that owns JSON data, install names, classifications, and `/etc/d2b` materialization for every bundle artifact. Add new bundle artifacts through `nixos-modules/bundle-artifacts.nix` instead of hand-writing parallel install logic in each emitter. Committed schemas under `docs/reference/schemas/v2/` ARE the contract and the `tests/unit/gates/drift-check.sh` gate enforces `xtask gen-schemas` + `git diff --exit-code` through `make test-drift`. Breaking the schema without an intentional `bundleVersion`/`schemaVersion` bump silently breaks every downstream consumer. | | Control plane — `d2bd` + `d2b-priv-broker` | `packages/d2b-contracts/**` + `packages/d2b-core/**` + `packages/d2bd/**` + `packages/d2b-priv-broker/**` (sibling workspace; `unsafe_code = "deny"` with quarantined `src/sys.rs` for fd-passing FFI) + `packages/d2b/**` + `docs/reference/{cli-contract,daemon-api,error-codes,privileges}.md` + the daemon Layer-1 gate set in `tests/static.sh` | The **only** persistent root surfaces the framework declares. `d2b-priv-broker.socket` is socket-activated: systemd creates/binds/listens/sets-ACL before the broker starts; the broker adopts fd 3 via `SD_LISTEN_FDS` and MUST NOT self-bind, self-fchmod, or self-fchown when `SD_LISTEN_FDS=1`. `d2bd.service` carries `Wants=d2b-priv-broker.socket` (not `Requires=`) so the daemon keeps serving while the broker is idle. The broker reloads the current bundle resolver per accepted request so it does not dispatch stale runner intents after a switch. The broker drops to the `d2bd` group and uses `SO_PEERCRED` at accept time for authz (launcher / admin / deny). Every host mutation flows through a typed broker op (cgroup v2 delegation, TAP/bridge lifecycle, `ApplyNftables`, `ApplyNmUnmanaged`, `ApplySysctl`, `UpdateHostsFile`, `ModprobeIfAllowed`, `UsbipBindFirewallRule`, `SpawnRunner`, `OpenPidfd`) and is recorded as an `OpAuditRecord` in `/var/lib/d2b/audit/broker-.jsonl` (root-owned `0640 root:d2bd`, append-only `O_APPEND`, daily rotation, 14-day default retention overridable via `d2b.site.audit.retentionDays`). Relevant tests: `tests/broker-socket-activation-eval.sh`, `tests/broker-caps-eval.sh`, `tests/d2bd-startup-smoke.sh`, `tests/legacy-unit-denylist-eval.sh`. See [ADR 0015](./docs/adr/0015-daemon-only-clean-break.md). | | Storage lifecycle / restart / synchronization | Planned generated contracts in `d2b-core::{storage,process_restart,sync}` + Nix emitters, broker storage/sync ops, daemon lifecycle DAG integration, and docs [ADR 0034](./docs/adr/0034-storage-lifecycle-restart-and-synchronization.md) / [`docs/explanation/storage-lifecycle.md`](./docs/explanation/storage-lifecycle.md) | Managed paths, restart adoption, locks, leases, cleanup, and degraded-state reporting are control-plane contracts. Normal daemon restarts are continuation events: do not broad-sweep `/run/d2b`; first re-discover adoptable runners from declared cgroup leaves, open fresh pidfds, verify identity, and quarantine/degrade ambiguity. Pidfds are not persisted. New advisory locks use OFD locks with `O_CLOEXEC`, explicit fd transfer only, and total acquisition order. The broker resolves storage/lock mutations from opaque bundle ids through anchored `openat2`/fd-relative path walking; daemon-owned ledgers are diagnostics, never repair authority. | | Eval-time assertions | `nixos-modules/assertions.nix` | These are the framework's contract with consumers. Loosening one silently turns a previously-rejected misconfig into runtime breakage. New assertions need a matching case in `tests/assertions-eval.sh`. | -| Guest-control exec session table | `packages/d2bd/src/{exec_session,exec_session_real}.rs`, `run_exec_owner` in `packages/d2bd/src/lib.rs`, `packages/d2b/src/exec_client.rs`, `packages/d2b-contracts/src/public_wire.rs` (`ExecOp`/`ExecOpResponse`) | `d2b vm exec` is **admin-only** and runs through `d2bd` plus authenticated guest-control vsock to `guestd`. Attached exec uses the daemon's in-process **session table**: per-session workers own one authenticated guest-control client and proxy typed exec ops. **guestd runs every exec as the VM's workload user (`ssh.user`) inside a real PAM login session (`systemd-run --property=PAMName=login --uid=`) — never as root; the wire `user` field is ignored and the target user is host-fixed, bare `argv[0]` is resolved by the workload user's login `PATH`, and each attached exec runs in a process-unique named transient unit (`d2b-exec-<…>.service`) that teardown stops via `systemctl kill` so a quiet command cannot outlive owner-disconnect, cancel, or the runtime ceiling. Operators elevate with `sudo` inside the session.** Detached non-TTY exec is enabled with `d2b vm exec -d -- ` and managed through VM-first verbs (`d2b vm exec list`, `logs `, `status `, `kill `); command forms always require `--`, so those verb words remain valid VM names. Detached jobs also run as the workload user, never root: the root detached runner only owns trusted slot/log files, re-validates the non-root uid before spawning the workload unit, and fails terminally rather than falling back to direct root execution. Guestd reconciles detached runner/workload units on startup, cleans orphaned workloads, and runs a periodic reaper for terminal records and retained logs; `kill` maps to idempotent two-phase `ExecCancel` (SIGTERM/grace/SIGKILL). There is **no per-VM systemd unit, no new broker op, and no SSH** — the guest owns the PTY; the host only flips termios for attached TTY via an RAII raw-mode guard restored on every exit/error/panic. The admin `SO_PEERCRED` check runs BEFORE any session lookup/slot reservation/connect or detached create; old/non-guest-control generations fail closed (exit `70`) with no proxy and no SSH fallback. Session-table caps (global/per-UID/per-VM), detached slot/log quotas, and rate limits are enforced before connect/auth or create. Attached audit emits one redacted kind=critical session-establishment event (vm/peer_uid/tty); detached create/kill daemon audit carries only vm/peer_uid/action/result/exec_id. Opaque session handles, argv, stdio, env, cwd, and paths never reach any Debug/trace/audit/metric surface. Validate with the `exec_session`/`exec_client` hermetic test matrices. | +| Guest-control exec session table | `packages/d2bd/src/{exec_session,exec_session_real}.rs`, `run_exec_owner` in `packages/d2bd/src/lib.rs`, `packages/d2b/src/exec_client.rs`, `packages/d2b-contracts/src/public_wire.rs` (`ExecOp`/`ExecOpResponse`) | Arbitrary `d2b vm exec` is **admin-only**; configured `d2b launch` local-VM items may use the same detached guest-control backend with launcher authority because argv is resolved exclusively from the hash-verified private bundle. Both run through `d2bd` plus authenticated guest-control vsock to `guestd`. Attached exec uses the daemon's in-process **session table**: per-session workers own one authenticated guest-control client and proxy typed exec ops. **guestd runs every exec as the VM's workload user (`ssh.user`) inside a real PAM login session (`systemd-run --property=PAMName=login --uid=`) — never as root; the wire `user` field is ignored and the target user is host-fixed, bare `argv[0]` is resolved by the workload user's login `PATH`, and each attached exec runs in a process-unique named transient unit (`d2b-exec-<…>.service`) that teardown stops via `systemctl kill` so a quiet command cannot outlive owner-disconnect, cancel, or the runtime ceiling. Operators elevate with `sudo` inside the session.** Detached non-TTY exec is enabled with `d2b vm exec -d -- ` and managed through VM-first verbs (`d2b vm exec list`, `logs `, `status `, `kill `); command forms always require `--`, so those verb words remain valid VM names. Detached jobs and configured local-VM launches also run as the workload user, never root: the root detached runner only owns trusted slot/log files, re-validates the non-root uid before spawning the workload unit, and fails terminally rather than falling back to direct root execution. Guestd reconciles detached runner/workload units on startup, cleans orphaned workloads, and runs a periodic reaper for terminal records and retained logs; `kill` maps to idempotent two-phase `ExecCancel` (SIGTERM/grace/SIGKILL). There is **no per-VM systemd unit, no new broker op, and no SSH** — the guest owns the PTY; the host only flips termios for attached TTY via an RAII raw-mode guard restored on every exit/error/panic. The admin `SO_PEERCRED` check runs before arbitrary exec session setup; configured launch instead requires local launcher/admin authority and a trusted configured item. Old/non-guest-control generations fail closed (exit `70`) with no proxy and no SSH fallback. Session-table caps (global/per-UID/per-VM), detached slot/log quotas, and rate limits are enforced before connect/auth or create. Attached audit emits one redacted kind=critical session-establishment event (vm/peer_uid/tty); detached create/kill daemon audit carries only vm/peer_uid/action/result/exec_id, while configured-launch audit adds target/item/operation correlation without execution details. Opaque session handles, argv, stdio, env, cwd, and paths never reach any Debug/trace/audit/metric surface. Validate with the `exec_session`/`exec_client` hermetic test matrices. | +| Unsafe-local persistent shells | `packages/d2bd/src/{workload_dispatch,unsafe_local_helper,unsafe_local_terminal,shell_backend}.rs`, shell owner dispatch in `packages/d2bd/src/lib.rs`, `packages/d2b-unsafe-local-helper/src/{shell_runtime,shell_supervisor}.rs`, and `tests/host-integration/unsafe-local-helper.nix` | `d2b shell` remains **admin-only** for every provider. Unsafe-local target identity and `defaultName`/`maxSessions` come only from the hash-verified private bundle; public `ShellOp` keeps protocol v3 and carries no policy, uid, argv, env, cwd, or path. The daemon dispatches helper protocol v2 to the exact `SO_PEERCRED` uid, validates exactly one connected CLOEXEC stream fd, and multiplexes terminal protocol v1 behind a fresh opaque public handle. Disconnect/`CloseAttach` detach but never kill; `Kill` targets only the helper-verified transient user scope. Shells survive CLI, daemon, and helper reconnects while that scope and the non-lingering user manager live. User logout ends them by design. User scopes provide lifecycle ownership, **not containment from other processes with the same host uid**. There is no root unit, broker op, per-VM service, SSH path, host-shell fallback, direct-compositor fallback, or automatic replay after an ambiguous daemon timeout. Never log/audit/label shell names, supervisor ids, public handles, terminal bytes, helper diagnostics, PIDs, unit names, argv, env, cwd, or paths; audit may use configured target/peer uid and fixed digests, while metrics use closed provider/component/operation/outcome/error labels. | | Lifecycle permission group | `nixos-modules/host-users.nix` | Membership in `d2b` + `SO_PEERCRED` at `public.sock` accept time is the **only** lifecycle authorisation surface. There is no polkit allowlist; wiring anything else into the group inverts the threat model. **Exception:** the guarded `ExecStop` shutdown hook runs as uid 0 and receives the narrow `HostShutdown` role, which is permitted only for `vmStop` during host-shutdown teardown (see `packages/d2bd/src/admission.rs`). This exception is scoped strictly: all other admin-only operations (exec, USB attach, key rotation, host prepare, audit export) are denied for this role. The daemon-restart continuation guard is preserved: `Restart=on-failure` restarts never receive `HostShutdown` treatment because the restarting daemon re-adopts runners and the shutdown hook only runs under systemd stop with a live `stopping` system state check. | | SSH key generation / rotation | `nixos-modules/host-keys.nix`, `host-activation.nix` | The framework owns `${cfg.site.keysDir}/_ed25519`. `d2b keys rotate` MUST NOT touch consumer-supplied keys. | | virtiofsd sandbox model | `nixos-modules/minijail-profiles.nix` (virtiofsdProfiles), `packages/d2b-priv-broker/src/sys.rs` (`clone3_spawn_runner` user-NS path), `nixos-modules/processes-json.nix` (argv emit) | virtiofsd profiles MUST declare zero host capabilities (`capabilities = []`), `requiresStartRoot = false`, and a `userNamespace` block mapping in-NS UID/GID 0 to the per-share principal. Normal VM shares map to `d2b--runner`; the guest-control token share (`d2b-gctl`) maps to the narrower `d2b--gctlfs` principal. The broker pre-establishes the user namespace via `clone3(CLONE_NEWUSER)` + `pipe2` sync + `/proc//uid_map` writes BEFORE virtiofsd's first instruction runs. virtiofsd argv MUST include `--sandbox=chroot --inode-file-handles=never` and `--readonly` for every `readOnly` share (`ro-store`, `d2b-gctl`). Reintroducing host caps, `requiresStartRoot=true`, or `--sandbox=namespace` violates [ADR 0021](./docs/adr/0021-broker-user-namespace-for-virtiofsd.md). Validate with `tests/minijail-validator-virtiofsd.sh` + `tests/virtiofsd-argv-shape.sh`. | diff --git a/CHANGELOG.md b/CHANGELOG.md index e64829e53..520104f5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,1954 +10,168 @@ deprecations ship one minor release before removal. ## [Unreleased] -### Added - -- Added the same-UID unsafe-local runtime foundation: `d2bd` now owns the - bounded, peer-credential-authenticated helper socket and one-generation-per- - UID registry; eligible users receive a fail-closed global systemd user - service; and the new `d2b-unsafe-local-helper` copies the current user-manager - environment into verified transient scopes with bounded operation ledgers, - reconnect snapshots, exact InvocationID/cgroup adoption, and no graphical - direct-display fallback. - -- Added provider-neutral `d2b-wayland-proxy` identity and readiness contracts. - Canonical workload targets and typed provider kinds now drive app-id/title - rewriting, unsafe-local warning rails, clipboard bridge attribution, and - bounded upstream/listener/first-client readiness events while legacy VM - arguments remain available for compatibility. Unsafe-local graphical launches - require an explicit compositor upstream and never use the legacy direct-child - host-terminal path. - -- Added the provider-neutral launcher contract for realm workloads. Generic - `launcher.items.` entries can describe configured `exec` actions or - persistent `shell` actions with item-owned names and icons; applications such - as Firefox and OpenObserve are ordinary exec items rather than - application-specific configuration. Added the explicit, default-denied - `unsafe-local` provider option and typed no-isolation/environment/identity - posture, argv-free launcher schema v2, private configured-item artifact, - private helper wire v1, conditional host socket-buffer maxima for its bounded - seqpacket frames, protocol-v3 feature flags, and bundle format v10. - Runtime dispatch remains feature-gated until the daemon/helper implementation - lands. - -- Added ADR 0044 for an explicit `unsafe-local` runtime provider that treats - host user/session/network workloads as first-class realm workloads for - desktop launch, Wayland-proxy identity rails, and host-local persistent shell - UX while preserving the no-isolation security warning. - -- Wayland proxy rail now defaults to realm identity when a VM maps - unambiguously to a single realm workload via `legacyVmName`. The active - rail color defaults to the realm's resolved accent color (from - `d2b.realms..network.ui.accentColor`), the rail label defaults to - `.`, and the realm target defaults to - `..d2b`. Inactive and urgent colors remain derived - from the VM border palette. Explicit operator `border.label.text` overrides - are fully preserved. When the mapping is ambiguous (multiple realms claim - the same VM) or absent (classical `d2b.vms` entry without a realm - workload), behavior is unchanged: VM border colors, VM-name label, and - `.local.d2b` transitional target. - -- Added first-class realm colors to the UI color contract. Enabled realms are - now included in `ui-colors.json` under a `realms` key, each with `path` and - `accent` fields. Corresponding `@define-color d2b_realm__accent` entries - are emitted in `ui-colors.css`. Colors are sourced from - `d2b.realms..network.ui.accentColor` and fall back to a deterministic - palette color derived from the realm name. The `ui-colors-schema.json` - schema is updated to document the new optional `realms` field. Realm colors - are presentation metadata only and carry no authorization semantics. - -- Hardened realm workload launcher metadata contract for desktop consumers - (Waybar, wlcontrol, wlterm, clip-picker). The generated - `realm-workloads-launcher.json` artifact now exposes three additional fields - per workload row: - - `workloadId` — explicit DTO-named alias for `workloadName`, matching the - `WorkloadIdentity.workloadId` field used by daemon/broker consumers. - - `iconId` — raw `launcher.icon.id` option value (null when not set); - allows consumers to round-trip the XDG icon theme id without re-parsing - the resolved `icon` string. - - `iconName` — raw `launcher.icon.name` fallback value (null when not set). - - `iconGroupKey` — stable clustering key for duplicate-icon / app-chooser - semantics; equals `iconId` when set, else `iconName`, else null. Desktop - consumers use this to cluster workloads representing the same application - type across realms (e.g. "Firefox" in both work and personal realms). - The workload index (`_index.realms.workloads`) also derives these fields so - any consumer of the index has access to them, not only the launcher emitter. - New nix-unit cases cover all field shapes and the duplicate-icon grouping - invariant; new contract tests enforce the emitter and index wiring. - -- Added restart/adoption validation gates for workload identity. Four hermetic - type-2 unit tests in `WorkloadTargetIndex` prove the index is deterministic - when rebuilt from the same config before/after a daemon restart cycle, that a - config JSON round-trip does not lose any identity field, and that transitional - workloads remain identity-free. Four source-lint gates in `policy_restart_adoption` - enforce the design invariants: `RunnerSnapshotRecord` carries no - `workload_identity` field (process adoption is keyed on `(pid, start_time_ticks)`, - not realm identity), `WorkloadTargetIndex` is rebuilt per-request not stored in - `ServerState`, `identity_for_vm` is used in list/status population, and the - hermetic restart-invariant unit test is present. - ### Changed -- Extended `d2b-clipd` bridge configuration and attribution to canonical - workload endpoints, including explicit `unsafe-local` provider metadata. - App-id/title data remains presentation-only, direct endpoint offers remain - discovery-only, and payload fulfillment still requires picker `Select` and - remains exclusively owned by `d2b-clipd`. +- Enabled the required Layer-1, eval-shell, and Entra example PR gates for + changes targeting the `v3` branch as well as `main`. -- Updated the consumer-facing realm/workload documentation to describe - `d2b.realms..workloads.` as the realm-native workload and - desktop metadata surface, with `d2b.envs`/`d2b.vms` documented as the v2 - transition runtime substrate. +## [1.4.1] - 2026-07-12 -### Fixed +### Added -- Fixed provider-neutral desktop readiness and bridge access. The Wayland proxy - reports its first accepted client even when no timeout is configured, and the - clipd bridge tmpfiles posture grants the configured Wayland user parent - traversal while preserving that user's declared primary group on same-UID - unsafe-local endpoint directories. - -- Fixed unsafe-local launches stalling behind an idle control-socket read or - racing systemd's asynchronous transient-unit property publication. Completed - operations now wake both bounded control loops immediately; sockets are - created close-on-exec, writes are time-bounded, and receive buffers are reused. - The helper reuses one D-Bus connection with bounded method calls, while scope - identity verification reads cgroup identity from the scope interface and - retries within a strict deadline without weakening mismatch rejection. - -- Fixed first boot with d2b enabled. Store-sync activation now defers - next-generation pointer publication when `/run/d2b` has not yet been - materialized, while systemd-tmpfiles owns boot-time pointer creation and - activation continues to reject unsafe parent and leaf path types. - -- Fixed host-local realm daemon startup after `/etc/d2b` private bundle - artifacts are materialized. Realm daemon users and services now carry the - canonical `d2bd` supplementary group, preserving read access to - `root:d2bd 0640` private bundle artifacts (`bundle.json`, - `realm-controllers.json`, and `realm-identity.json`) without a parallel - activation-time ACL path. - -- Fixed local CLI routing for realm workload targets whose workload id differs - from the legacy VM substrate name. `d2b vm status ..d2b` and - local lifecycle/exec routing now resolve through the generated workload - identity bundle before falling back to gateway-style dotted target handling. - -- Fixed `cmd_vm_lifecycle_verb` migration hint to check the raw user-supplied - target string rather than the resolved local VM name. For host-local realms - the router strips the realm suffix (e.g. `corp-vm.work.d2b` → `corp-vm`), - so the previous check (`!vm.contains('.')`) always evaluated true after - resolution and incorrectly emitted the "use the canonical form" hint to users - who had already typed the canonical form. The raw input is now preserved - before the shadow binding and used for both the dot-guard and the - `migration_hint_for_bare_vm` call. - -- Fixed `realm-controllers.json` workload identity emitter: workload identity - fields are now nested under `identity: { ... }` matching the - `RealmControllerLocalWorkload.identity: Option` Rust DTO - field instead of being flat-merged into the workload root (which violated - `deny_unknown_fields` on `RealmControllerLocalWorkload`). Field names now - match `WorkloadIdentity`: required `workloadId`, `realmId`, `realmPath` - (emitted as a label array via `lib.splitString`), `canonicalTarget`; optional - `legacyVmName`, `runtimeKind`, `providerId` (renamed from the incorrect - `runtimeProviderId` key). The `kind` field is removed from the identity block - (it has no corresponding field in `WorkloadIdentity`). Transitional env-based - workload entries correctly omit the identity object entirely. -- Fixed `launcher.app.targetRealm` nix-unit test to use a valid - `WorkloadTarget`-format address ending in `.d2b` (`corp-laptop.alt.d2b`). - Added `realmWorkloadTargetAssertions` in `assertions.nix` to reject invalid - `targetRealm` values at eval time (must match - `..d2b` with `[a-z][a-z0-9-]*` labels). -- Fixed `realm_workload_schema_contract` contract tests to assert the correct - nested `WorkloadIdentity` shape and field names. The old tests checked for - the wrong field name `runtimeProviderId` (renamed to `providerId` in the DTO) - and did not verify nested identity structure or guard against the invalid - `kind` key. Updated tests now verify: `identity =` nesting, presence of all - required identity field names (`workloadId`, `realmId`, `realmPath`, - `canonicalTarget`, `providerId`), and absence of `kind = workloadRow.kind` - and `runtimeProviderId =` as JSON keys. - -- Narrowed the group ACL on host-local realm run directories from `g::rwx` to - `g::r-x` so realm-access-group members can traverse and list but cannot - write. The previous `g::rwx` ACL combined with the sticky `1770` base mode - allowed a local group member to pre-create `broker.sock` or `daemon.lock`, - preventing the daemon from binding its sockets or acquiring its lock (local - DoS). The base mode `1770` and the explicit daemon-user `u::rwx` ACL - entry are unchanged; the mask `m::rwx` is unchanged so the daemon retains - full effective access. -- Granted host-local realm daemon users read ACLs for the shared - `realm-controllers.json` and `realm-identity.json` metadata files so startup - can load realm metadata after switching to per-realm principals. -- Gave host-local realm daemon users traverse access to `/run/d2b` and made - realm run directories match the daemon lock-parent contract - (`root:` sticky `1770`) so realm daemons can validate - their own lock files. -- Ordered generated host-local realm daemon units after the root privileged - broker socket/service so switch-time activation does not race broker - socket-activation startup. -- Made the privileged broker's generated UID/GID environment file optional at - `ExecStartPre` time so socket activation works on a fresh `/run` without a - pre-existing `/run/d2b/broker/priv-broker.env`. -- Aligned realm gateway target routing and generated entrypoints with ADR 0043's - canonical `..d2b` form so the Rust CI gate no longer hangs - on stale node-qualified gateway tests. -- Updated the `crossbeam-epoch` lockfile entry to a non-vulnerable release for - the RustSec advisory check. +- Added ADR 0045, defining parent-owned workload-hosted realm + controllers; explicit runtime, infrastructure, transport, substrate, + credential, and display provider responsibilities; type-first sortable + provider crates; generic Unix/vsock/direct/Azure-Relay byte transports; + Noise-authenticated component sessions with ttrpc/protobuf control services; + Entra and YubiKey credential placement; and policy-authorized peer shortcuts + over inherited shared transport fabrics. -### Changed +### Fixed -- Amended ADR 0043 (realm-native control plane) to specify: hierarchical - cgroup layout (`d2b.slice///`), `mkRemovedOptionModule` - tombstones for retired `d2b.envs` and `d2b.vms` pointing to the v1.2-to-v2 - migration guide, `internal = true; visible = false` for generated substrate - options, 1:1 state-path mapping from workload id to legacy - `/var/lib/d2b/vms/` with no implicit activation-time state moves, - desktop JSON realm-to-workload association requirement, CLI transition - behavior for old `d2b vm`/`d2b env` commands, MAC preservation and - interface-rename/firewall-drift warnings for net VM renamed from - `sys--net` to `sys--net`, eval-time assertion requirement for - cross-realm `externalNetwork` uplink conflicts, and explicit - workload/provider telemetry label bounding and workload-identity audit - redaction rules. -- Amended ADR 0043 with further design requirements from the R2 panel review: - per-realm run directory `r-x` group-class ACL invariant (code hotfix PR #263 - tracked separately); `/etc/d2b/realm-identity.json` public-identity-only - constraint; additive vs breaking schema versioning rule for - `realm-controllers.json`/display-list shape changes; mandatory strongly typed - `WorkloadTarget` parser in `d2b-core` with no ad hoc string splitting; - `SpawnRunner` typed/polymorphic envelope separating universal workload identity - from provider-specific backend config; and a Visual presentation requirements - section codifying Waybar left-border realm accents, wlcontrol realm group card - borders, realm-colored Wayland rail, and wlterm/clip-picker realm grouping. +- Fixed unsafe-local persistent shells inheriting `TERM=dumb` from the systemd + user manager by supplying a fixed true-color terminal baseline while + preserving the rest of the manager environment and login-shell startup. +- Made the mkfs diagnostic bound test exercise the formatter directly instead + of depending on unrelated existing-image repair stages. +- Made output-ring wake coverage observe data and EOF as separate valid + notifications instead of racing both producer events into one read. +- Made disk-init test directories use exclusive process-local IDs so parallel + tests cannot silently share and remove each other's scratch state. +- Made failed-fd-send coverage track the original pipe identity so concurrent + numeric fd reuse cannot produce a false leak report. +- Stabilized shell-supervisor teardown coverage by allowing its asynchronous + socket cleanup the same bounded reconciliation horizon used by the runtime, + waking the supervisor accept loop so its owned listener unlinks before forced + scope teardown, and ensuring a missing/replaced control socket cannot block + verified scope collection or ledger cleanup. + +- Fixed the provider-neutral `launch` command missing from the public + authorization matrix and generated privileges schema. Configured launches + remain scoped per workload/realm to launcher or admin callers, audited, and + broker-free. + +## [1.4.0] - 2026-07-12 ### Added -- `d2bd` now populates `workloadIdentity` in `ListEntry` and `VmStatus` public - wire responses from realm workload metadata when a `realm-controllers.json` - config is present. Fields populated: `workloadId`, `realmId`, `realmPath`, - `canonicalTarget`, and `legacyVmName` where available. -- New `WorkloadTargetIndex` internal module in `d2bd` provides index-backed - resolution of VM targets from canonical targets (`..d2b`), - workload-id aliases, and legacy VM name fast-path. Alias resolution is - unambiguous-only: ambiguous workload-id matches fail closed. -- Two new `TypedError` variants: `WorkloadTargetNotFound` (exit code 2) and - `WorkloadAliasConflict` (exit code 2), returned when a `vm` filter specifies a - canonical target not in the index or an ambiguous workload-id alias. -- The `vm` filter in list and status requests now resolves canonical targets and - unambiguous workload-id aliases through the workload index before matching - against manifest entries. -- Added `canonical_target` field to `ListItemOutputV2` and `StatusVmOutputV2` - CLI output structs. When the daemon advertises workload identity for a VM, - the field is populated with the canonical workload target address - (e.g. `corp-vm.work.d2b`); it is absent (not serialized) when no workload - identity is available, preserving backward compatibility with old daemons. -- `d2b vm list` human output now shows a `WORKLOAD TARGET` column when at least - one listed VM has a canonical workload target. -- `d2b vm status` human output now shows a `workload target:` line when the - queried VM has a canonical workload target. -- `d2b vm ` commands emit a non-fatal compatibility note to stderr when a - bare VM name is used and the daemon has advertised a canonical workload target - for it, suggesting the canonical form (e.g. - `note: target 'corp-vm' is a bare VM name; consider using 'corp-vm.work.d2b'`). - The bare-name local fast path continues to work unchanged. -- Env-qualified VM names missing the required `.d2b` suffix - (e.g. `corp-vm.work` instead of `corp-vm.work.d2b`) are now rejected - fail-closed with error code `old-env-style-target` and a clear remediation - message suggesting the canonical form. This closes the migration UX gap - for operators moving from legacy env-scoped targeting. - -- Wired `VmProcessDag.workload_identity` through `VmStartRunner` so every - `SpawnRunner` broker request for a VM workload runner carries the realm - workload identity from the process DAG. `VmStartRunner` now holds - `workload_identity: Option` set from the DAG at - construction time; the broker can record the identity in the audit trail - without re-reading the process DAG. Per-env usbipd runners - (`BrokerPerEnvUsbipdSpawner`) correctly remain `None` — they are - framework infrastructure services, not realm workloads, and no realm workload - row exists for them. -- Added `workload_identity: Option` to `VmProcessDag` in - `d2b-core::processes`. Process JSON artifacts now carry the universal - realm-scoped identity alongside the VM process DAG when the VM is declared as - a realm workload. Absent for classical `d2b.vms.` entries without a realm - workload row. Backend-specific config (vm_id, role, runner argv) stays in - existing per-node fields — the identity never duplicates backend data. -- Added `workload_identity: Option` to `SpawnRunnerRequest` - in `d2b-contracts::broker_wire`. The broker SpawnRunner request now - structurally carries the universal workload identity separate from the typed - backend fields (`vm_id`, `role`, `role_id`, `bundle_runner_intent_ref`). The - field is additive: `None` means "no realm identity available" and old brokers - continue to function normally. -- Updated `nixos-modules/processes-json.nix` to emit `workloadIdentity` in - `VmProcessDag` for both `vmDag` (Cloud Hypervisor / NixOS VMs) and - `qemuMediaDag` (QEMU media VMs). The field is gated on - `cfg._index.realms.workloads.byVm.${name}` and omitted when no realm workload - row exists, preserving backward compatibility with classical VM entries. -- Added `process_workload_identity_contract.rs` contract tests covering: - schema presence of `workloadIdentity` on `VmProcessDag` and - `SpawnRunnerRequest`; additive (non-required) invariant in both schemas; - `additionalProperties: false` preservation; backward-compat deserialization - without the field; `skip_serializing_if = "Option::is_none"` serialization - invariant; Nix emitter source-lints; and source-lint proving `VmStartRunner` - propagates the DAG's `workload_identity` to every `SpawnRunner` request. -- `processes.json` and `wire-protocol.json` schemas regenerated to reflect the - new `workloadIdentity` property on `VmProcessDag` and `SpawnRunnerRequest`. -- Added `d2b.realms..workloads.` option for declaring - realm-owned workloads. Each workload optionally references an existing - `d2b.vms.` substrate via `vmRef` for runtime kind and provider id - derivation, and carries stable launcher metadata (`label`, `icon`, - `actionId`, `capabilityRefs`, `preflightRefs`) for desktop consumers. -- Extended the internal `_index.realms` with a `workloads` sub-index - (flat `all`/`enabled` lists and a `byVm` map) and `externalNetworkConflicts` - advisory data for cross-realm attachment interface collisions. -- Added `targetAddress` (`..d2b`), `substrateId`, - `runtimeKind`, and `runtimeProviderId` fields to each realm workload index - row; each realm row now exposes `workloads`, `workloadNames`, and - `enabledWorkloadNames`. -- Added `realm-workloads-launcher.json` bundle artifact (installed at - `root:d2bd 0640`) containing stable desktop launcher metadata for all - enabled realm workloads. Includes `targetAddress`, `actionId`, `label`, - `icon`, `capabilityRefs`, `preflightRefs`, `runtimeKind`, `runtimeProviderId`, - advisory `vsockCid`, and explicit invariant markers confirming no secrets, - credentials, provider tokens, command payloads, or opaque session handles. -- Updated `realm-controllers.json` generation to prefer explicit - `realm.workloads` declarations (with `vmRef`) as the primary source for - local runtime workload entries, falling back to env-based matching for VMs - not covered by explicit declarations. Backward compat is fully preserved for - realms without workload declarations. -- Added cross-realm vsock CID collision assertion in `assertions.nix`: fires - when two workloads in different realms reference different NixOS VMs that - compute to the same vsock CID. -- Added cross-realm external network attachment conflict detection: advisory - assertion records when realms share an attachment interface across their - associated envs; demoted to non-failing in metadata-only runtime state with - a clear upgrade note for when realm-native networking activates. -- Added nix-unit coverage for realm-owned workload index and launcher metadata - contracts: workload index row fields (`targetAddress`, `substrateId`, - `runtimeKind`, `runtimeProviderId`, `capabilityRefs`), all/enabled/byVm index - accessors, `realm-workloads-launcher.json` shape and invariants, bundle - artifact registration, cross-realm vsock CID collision assertion, - cross-realm external-network conflict index, and empty-realm edge cases. -- Added Rust contract tests (`realm_workload_schema_contract`) for realm - workload DTOs and artifacts: schema presence and definition of - `WorkloadIdentity` / `RealmTarget` in `realm-controllers.json` and - `wire-protocol.json`; additive-field invariant verifying `identity` and - `workloadIdentity` are not in `required[]`; wire/CLI schema separation - (workload identity travels in the daemon-wire schema only, not in CLI output - schemas); `realm-workloads-launcher.json` emitter contract markers - (`noSensitiveCommandPayloads`, `canonicalTarget`, `appCommand`, `actions`, - classification); controller config emitter wires identity fields and does not - reference removed field `vmRef`; `deny_unknown_fields` source-lint for - `WorkloadIdentity` and sibling structs; module-level version policy doc gate - (`bundleVersion` + `schemaVersion` bump requirement); absence of sensitive - credential fields in `realm-controllers.json`. -- Extended realm workload index rows with `canonicalTarget` (derived from - `launcher.app.targetRealm` override or the standard `..d2b` - formula), `appCommand` (from `launcher.app.command`), and `actions` (from - `launcher.actions`) so downstream emitters have full desktop launch metadata - without accessing raw workload options. -- Extended `realm-workloads-launcher.json` to expose `canonicalTarget`, - `appCommand`, and `actions` (each action carries `id`, `label`, and - `command`). Commands are static operator-declared launch metadata, not - sensitive payloads; the invariant is refined to `noSensitiveCommandPayloads` - with accompanying security contract notes. -- Extended `realm-controllers.json` workload entries for explicit realm - workload declarations: each entry now carries `kind`, `realmPath`, - `canonicalTarget`, `legacyVmName`, `runtimeKind`, and `runtimeProviderId` - alongside existing runtime/path fields. Transitional env-based entries - remain unchanged. Fixed a bug where the emitter still referenced the removed - `vmRef` field instead of the correct `legacyVmName`. - -- Added stacked-PR workflow documentation to AGENTS.md covering branch naming, - PR-only merges, panel/review evidence requirements, integrator ownership of - CI, retarget/rebase, merge sequencing, and helper-script constraints. -- Added `WorkloadIdentity`, `WorkloadTarget`, `WorkloadBackend`, and - `WorkloadRuntimeIntent` types to `d2b-core::workload_identity`. `WorkloadTarget` - is a type alias for `RealmTarget` that makes the bundle-artifact parse boundary - explicit (no ad hoc string splitting past `WorkloadTarget::parse`). - `WorkloadIdentity` carries the universal realm-scoped identity (workload id, - workload name, realm id, realm path, canonical target, optional legacy VM name, - runtime kind, provider id) independently of any backend runtime config. - `WorkloadBackend` provides the typed envelope separating the universal identity - from provider-specific runtime details (`LocalVm` / `LocalQemuMedia`). - `WorkloadRuntimeIntent` combines both for process-intent DTOs. Module-level - doc comment codifies the additive-vs-breaking DTO version policy. -- Extended `RealmControllerLocalWorkload` in `d2b-core` with an additive - `identity: Option` field. `None` for bundle artifacts emitted - by Nix before this change; present once the emitter is updated. -- Added optional `workload_identity: Option` to the - `ListEntry` and `VmStatus` public output structs in `d2b-contracts`. `None` - for classical `d2b.vms` VMs not yet associated with a realm; present for - realm-adopted workloads. - -- **Realm workload and network option schema** (`d2b.realms..workloads` - and `d2b.realms..network`): the v2 public surface for workloads and - network declarations. - - - `d2b.realms..workloads.` supports `kind = "local-vm"`, - `"qemu-media"`, and `"provider-placeholder"`. Each workload carries - `localVm.*` (config, memoryMiB, vcpus, networkIndex, ssh, graphics, tpm, - autostart), `qemuMedia.*` (source, removableSlots, resources, security), - and desktop-launcher metadata (`launcher.*`: label, icon id/name, - app.command, app.targetRealm, actions, capabilities). - - - `d2b.realms..network` is extended from the stub `network.*` block - with the full env-replacement shape: `lanSubnet`, `uplinkSubnet`, `mtu`, - `mssClamp`, `lan.allowEastWest`, `externalNetwork.*` (attachment, egress, - portForwards, mDNS), and `ui.accentColor`. `network.mode` drives - behaviour: `none` (default/safe), `inherit-env` (delegate to existing - `d2b.envs`), `declared` (realm owns network), `external`. - - - State path policy: `workload.stateDir` defaults to - `/var/lib/d2b/vms/` preserving 1:1 mapping with legacy - `d2b.vms.`. Use `legacyVmName` to explicitly reference an existing VM - entry when the workload id differs. - - - Tombstone/migration UX: descriptions on `d2b.envs` and `d2b.vms` updated - to note they are transitional surfaces pointing at the replacement - declarations and the v1.2-to-v2 migration guide. Soft advisory warnings - (`config.warnings`) fire when a realm links to existing `d2b.envs` entries - but has no workloads and `network.mode = "none"`, nudging toward completion - of the transition without blocking activation. - - - Updated `docs/how-to/migrate-d2b-v1-2-to-v2.md` with step-by-step - instructions for declaring realm workloads and optionally switching to the - realm-declared network. - -- Added ADR 0043: Realm-native control plane, documenting the realm-as-control - plane architecture, per-realm daemon/broker/socket/state/audit boundaries, - strict parent/child routing, dynamic relay discovery, realm-qualified VM - addresses, and migration from legacy local grouping into first-class realms. -- Added tree route admission and decision support for signed-expiring route - advertisements, strict descendant namespaces, replay/expiry checks, - loop/multiparent refusal, nearest-common-ancestor path decisions, bounded - discovery queue decisions, direct shortcut metadata, and low-cardinality route - audit/telemetry events without enabling live transport. - -- Added topology validation coverage for bounded discovery queues, - unauthenticated-peer drop-new/rate-limit behavior, route-advertisement - expiry/replay rejection, capability denials, direct-shortcut policy denials, - no-raw-tunnel route decisions, generated schema/docs exposure, and - ancestor-mediated tree route decisions. -- Documented the metadata-only realm discovery and strict tree routing contract, - including parent/child route advertisements, namespace validation, - queue/rate/replay bounds, direct shortcut constraints, correlation/audit - chaining, and the explicit no VPN/overlay/SSH/raw-tunnel runtime boundary. -- Added tree routing/discovery data models for bounded - discovery queues, unverified-peer/session admission, replay windows, signed - route advertisements, namespace allocations, route decisions, direct shortcut - metadata, audit labels, and low-cardinality telemetry counters. -- Added an in-memory realm identity metadata store for - enrollment pins, controller-generation rotation, revocation-list merge, - recovery state transitions, teardown directives, and redacted lifecycle audit - metadata. -- Added metadata-only realm identity lifecycle models for - identity refs/fingerprints, controller-generation credentials, enrollment - trust anchors/key pins, key rotation, revocation-list propagation, session - teardown directives, recovery procedures, and redacted audit metadata. -- Documented the realm identity lifecycle contract for refs/fingerprints versus - key material, parent trust anchors, child key pins, controller-generation - rotation, revocation lists, teardown directives, recovery metadata, and the - live-enforcement boundary. -- Documented the realm access resolver contract for canonical realm target - grammar, alias/default-realm resolution, direct host-local access bindings, - capability preflight, typed resolver diagnostics, and contract-only routing - boundaries. -- Documented the local-root allocator contract for typed host-resource - leases, opaque resource ids, deterministic acquisition order, reconciliation, - quarantine/reclaim, immutable host-file boundaries, and contract-only status. -- Added local-root allocator data models for realm host-resource - leases, opaque resource ids, allocation/reconciliation responses, bounded - allocator audit/metric metadata, and generated JSON schema coverage. -- Added a pure local-root allocator engine over fake ledger, - observation, and liveness backends for deterministic lease allocation, - idempotency replay, reconciliation decisions, and bounded low-cardinality - audit/metric metadata. -- Added private `allocator.json` bundle metadata rooted in `d2b.realms`, covering - enabled realms, metadata-only local-root allocator resource requests, - path/socket partitions, provider placement, and the transitional env bridge - without starting an allocator runtime service. -- Added private `realm-controllers.json` bundle metadata rooted in `d2b.realms`, - covering deterministic per-realm daemon, broker, socket, state, audit, - allocator binding, provider placement, and direct-access metadata. -- Added metadata-only local runtime provider/workload rows to - `realm-controllers.json`, binding host-local realms to existing VM runtime - providers, preserved VM state/run/store-view paths, and explicit runtime - operation capability summaries without moving state during activation. -- Added a host-provider qemu-media runtime adapter that validates the typed - qemu-media argv scaffold, redacts argv/path inputs from debug output, and - fails closed for lifecycle calls until daemon runtime control is wired. -- Extended the realm access resolver so host-local `localRuntime` metadata - contributes bounded operation capabilities to capability preflight, while - preserving typed denials for missing capabilities. -- Added canonical realm-target and picker/clipd capability-preflight metadata - to the clipboard picker protocol so desktop pickers can display trusted - d2b-provided VM identity without using guest titles or app ids as authority. -- Added d2b-asserted Wayland proxy `realmTarget` / `--realm-target` metadata so - downstream desktop tools can identify proxied VM windows from host-provided - realm metadata while preserving the existing app-id/title rewrite behavior. -- Added trusted identity source and display capability-preflight metadata to - `d2b vm display list --json` so desktop helpers can consume bounded - d2b-provided realm target state instead of guest window metadata. -- Added an explicit ACA guestd endpoint provider seam that advertises no - guestd/persistent-shell capability for execute-only sandboxes and fails closed - before any Azure data-plane call when endpoint status is requested. -- Tightened remote full-host registration so provider-managed-isolation - capability sets cannot be retained as full-host nodes. -- Rewrote the v1.2-to-v2 migration guide for the realm-native metadata-first - transition, including local VM preservation, host-local realm declarations, - provider/remote fail-closed behavior, desktop metadata, cleanup, and rollback. -- Added host-local realm control-plane materialization from `d2b.realms`, - including deterministic bounded unit names, daemon/broker users and groups, - socket access groups for allowed users, runtime/state/audit directories, and - parent-before-child ordering. -- Added Rust daemon, broker, and bundle-resolver loading for - `realm-controllers.json` artifacts, including strict parsing and validation - while keeping runtime realm routing inert. -- Added private `realm-identity.json` bundle metadata and strict daemon, - broker, and bundle-resolver loading for realm identity refs/fingerprints only, - without loading secret material or enabling live trust sessions. -- Added Layer-1 realm identity coverage for strict data-model/store behavior, rendered - `realm-identity.json` bundle/storage contracts, loader redaction, schema drift, - and eval-time rejection of secret-shaped identity refs. -- Added realm access resolver contract models for canonical target resolution, - direct host-local bindings that preserve `SO_PEERCRED`, alias/default-realm - diagnostics, conflict candidates, capability preflight, and stale/missing - realm-controller refusals without changing runtime routing. -- Added d2bd local-root realm access resolver helpers that select direct Unix - socket bindings from realm-controller metadata without introducing a byte - proxy or changing the public socket protocol. -- Added Layer-1 realm access coverage for CLI target routing diagnostics, - host-local resolver fail-closed paths, no-proxy direct socket semantics, and - generated schema/docs exposure. -- Added Layer-1 coverage for host-local realm controller units, sockets, - principals, tmpfiles paths, disabled-realm omissions, bundle classification, - daemon/broker loading defaults, and bundle-resolver loading. - - -- Env net VMs can now opt into external network plumbing with a macvtap-backed - `external0` NIC, separate workload-to-home egress NAT, and explicit - external network port forwards. -- `d2b-wayland-proxy` now draws compositor-agnostic, per-VM colored borders - with an optional VM-name label for proxied graphics windows. Borders use the - existing `d2b.vms..ui.border` color model, are enabled by default with - the Wayland proxy, and can be disabled per VM. -- Added the `d2b-clipd` Rust crate with picker NDJSON DTOs, bounded framing, - clipboard policy primitives, FD safety models, picker supervision, - fail-closed audit / droppable metrics queues, host data-control integration, - and the clipboard fallback arm control socket. -- Renamed the host-side Wayland package/binary to `d2b-wayland-proxy`. -- Renamed the per-VM graphics options from `graphics.waylandFilter.*` to - `graphics.waylandProxy.*`; the old option path remains as a compatibility - alias for this release. -- `d2b-wayland-proxy` now virtualizes the standard guest clipboard locally: - it advertises a synthetic `wl_data_device_manager`, never binds guest - `wl_data_*` objects into the host compositor clipboard namespace, routes - same-VM transfers inside the proxy, and keeps primary-selection, privileged - data-control, and DND denied. -- `d2b-wayland-proxy` virtual clipboard now correctly sends `wl_data_source.cancelled` - to the previous source owner when a new selection supersedes it; `vm_name` - attribution is threaded through all four virtual clipboard handlers - (`VirtualDataDeviceManagerHandler`, `VirtualDataSourceHandler`, - `VirtualDataDeviceHandler`, `VirtualOfferHandler`) and logged at each - clipboard lifecycle event (source created/destroyed, MIME announced, - selection set, offer received); source-gone EOF paths fail closed with EOF. -- `d2b-clipd` now has host/Niri integration with tolerant Niri JSON - IPC models, bounded Unix-socket request/response helpers, focused-window - best-effort attribution cache behavior, and fallback arming state-machine tests. -- `d2b-clipd` now has a real desktop notification backend for bounded, - content-free fallback-ready and failure notifications. -- The flake now exports `packages..d2b-clipd` so host configurations can - wire the clipboard authority user service without local package workarounds. -- Added clipboard architecture Nix/docs/schema wiring: a default-off - `d2b.site.clipboard` module, user-service wiring for `d2b-clipd`, explicit - picker package/path configuration with no bundled GPL input, bridge runtime - path policy, eval assertions, and reference docs for the authority split, - picker protocol, and policy caps. -- Added `d2b clipboard arm` CLI subcommand for the explicit picker-driven - paste action; it sends an arm request to the running `d2b-clipd` - control socket with bounded read/write deadlines, reports structured - `--json` failures, and treats picker launch/handshake failure as a typed - daemon failure rather than success-shaped clipboard writes. -- Added clipboard test gates: scaffold-detection test asserting `d2b-clipd` - uses no `thread::park()` stub; picker handshake integration test - (`CLIPD_TEST_PICKER` env gated); `policy_clipboard` contract tests verifying - flake export of `d2b-clipd`, `Clipboard` subcommand existence in the CLI, - Wayland proxy synthetic-clipboard invariant, and no-regression checks against - reintroducing a substrate-gap marker. -- `d2b-clipd` now implements the host-authority event loop: - connects to the host Wayland compositor via `ext-data-control-v1` - (preferring the stable extension, falling back to `zwlr-data-control-v1` - when only the WLR variant is advertised); subscribes Niri IPC events for - focused-window attribution via `$NIRI_SOCKET`; uses the Niri event-stream - cache for native clipboard events so the Wayland event loop does not block - on synchronous compositor IPC; materialises host copy - events with `FocusedWindowGuess` attribution; holds paste write-FDs open - until the picker resolves or a 30-second deadline fires; launches the - picker process over a `socketpair` using `CommandPickerSpawner`; falls - back to native-paste arming (no synthetic input) when no picker command is - configured; logs notification errors without exposing raw clipboard content. -- `d2b-clipd` now writes selected payloads to Wayland transfer FDs from a - bounded helper task instead of blocking the daemon event loop, and bridge / - picker failures emit content-free, rate-limited warnings such as - `connect-failed`, `handoff-failed`, and picker-closed-before-selection. -- Added the `d2b-clip-debug wl-copy ` and - `d2b-clip-debug wl-paste [mime]` Wayland probe binary for local clipboard - validation without relying on session-only helper binaries. -- Staged the console/audio contract surface: public - `ConsoleOp`/`AudioOp` wire DTOs, audio CLI JSON DTOs, provider - console/audio capability descriptors for Cloud Hypervisor NixOS, - qemu-media, and ACA sandboxes, generated schemas, the provider - capability matrix reference, and a step-by-step console/audio how-to. - The docs cover broker-owned qemu chardev posture, console stream QoS, - OFD audio lock semantics, provider-specific enforcement modes, and - d2b-wlcontrol badge/control constraints. -- Console output ring buffer (`d2b-core::console_ring`) with monotonic - offset tracking, per-byte drop accounting, and fast-forward detection - for slow clients. EOF flag propagation notifies waiters when the VM console - closes. -- `d2b console ` CLI command attaches to the VM console via the daemon, - polls output in a 200 ms raw-mode loop, forwards stdin, supports Ctrl-] - detach, and exits cleanly on EOF or session expiry. -- `ConsoleOp` daemon dispatch: `d2bd` handles all console operations - (Attach, ReadOutput, WriteStdin, Resize, Wait, Close) via a per-VM - `ConsoleSessionTable`. Cloud Hypervisor VMs get a d2bd-internal tokio - drainer task that connects to the CH serial socket and reconnects on - drop; qemu-media and ACA targets return typed errors directing operators - to use the appropriate broker-fd or provider-relay path. -- `QemuMediaArgvInput.console_fd` field: when provided, QEMU emits - `-chardev socket,id=con0,fd=N -serial chardev:con0` instead of - `-serial none`. - Accepts only fds >= 3 (rejects stdin/stdout/stderr). -- `d2bd` now dispatches `AudioOp` (status, set-volume, mute/off) for all - provider types. Cloud Hypervisor NixOS VMs use OFD-locked atomic - reads and writes of `/run/d2b/audio/.json` guarded by - `/run/d2b/locks/audio-.lock`; qemu-media VMs report - guest-enforcement as unsupported; ACA sandbox VMs route exclusively - through provider guest-control (no local audio state is created). - Provider capability resolution runs before any state access. Host - PipeWire enforcement and guestd `AudioStatus` / `AudioSet` integration - are connected, and responses report `host-and-guest`, `host-only`, - `guest-only`, or `unsupported` according to the enforcement actually - applied. -- `guestd` `AudioStatus` and `AudioSet` handlers now use real `wpctl` - argv-only subprocesses targeting the workload user's PipeWire session. - The `--wpctl-path` flag (set to `wireplumber/bin/wpctl` by the guest - audio component) enables the runtime; capabilities are only advertised - when the binary exists and the workload UID is known at startup. - `PIPEWIRE_RUNTIME_DIR` is set per-user so wpctl never touches root's - PipeWire socket. Level > 100 returns `AudioLevelOutOfRange`; missing - PipeWire returns typed `AudioPipeWireUnavailable`. -- `audioService` (`d2b--snd.service`) is fully retired: the field - is unconditionally `null` in all manifest and daemon-access paths; - `ProcessRole::Audio` is the sole source of truth for audio runner - identity. -- `d2bd` now preserves a `TypedError::OtelHostBridgeReadinessTimeout` typed - error as a structured `degraded` field in the `vm start` success JSON - envelope when the OtelHostBridge readiness gate times out in non-strict - mode. Operators and `d2b host doctor` can detect the degraded condition - from the structured response without log parsing. -- `d2bd` now recognizes `uid=0` connections as a narrow `HostShutdown` - authority scoped exclusively to `vmStop` during host-shutdown teardown. This - fixes the long-standing post-reboot failure where the guarded `ExecStop` - shutdown hook was rejected with `authz-not-admin` for every VM. Workload VMs - stop before net VMs. All other admin-only operations (exec, USB attach, key - rotation, host prepare, audit export) are explicitly denied for this role. -- Wayland proxy startup after reboot is now reliable: the broker grants the - per-VM wlproxy UID a traverse ACL on the runtime directory - (`/run/user/`) before spawning the proxy. This fixes post-reboot - failures where the `0700` parent directory blocked access to the Wayland - socket. The broker also verifies the runtime directory exists and is owned by - the declared Wayland user before granting any ACL. Missing or mis-owned - directories produce actionable `graphical-session-not-active` errors. -- USBIP backend ACL grant is now retry-safe across transient device - re-enumeration: the retry loop tolerates device-node changes between verify - and grant as long as VID/PID identity is stable, revokes ACLs from stale - nodes before each retry, and treats missing old nodes as benign during revoke - (kernel removes `/dev/bus/usb/B/D` during re-enumeration). -- Kernel module detection now checks `/sys/module` directory entries in - addition to `/proc/modules` and `modules.builtin`. Built-in virtio/KVM - modules compiled as `=y` are now correctly detected as present without - the `D2B_SKIP_KERNEL_MODULE_CHECK` operator override. -- `d2b list --json` now exposes `guestClosureOutPath` for VMs whose - bundle closure metadata is available, giving host-side scanners a public - VM-to-guest-closure mapping for `sbomnix` without private path conventions. - - -- VM lifecycle CLI: added explicit `--force` / `-f` stop intent for - `vm stop`, `down`, `vm restart`, and `restart`, with backward-compatible - public wire serialization that omits `force = false`. -- Broker QMP lifecycle operations for qemu-media now expose typed - `system_powerdown`, `query-status`, and `quit` requests, with bounded QMP - parsing and audit-safe lifecycle fields. -- VM shutdowns now attempt provider-aware graceful guest shutdown for supported - Cloud Hypervisor/qemu-media VMs before forced pidfd cleanup, with bounded - daemon audit and metrics outcomes. -- Persistent shell CLI: added top-level `d2b shell ` attach and - management forms for persistent named guest shell sessions. - -- UI colors: added a compositor-agnostic d2b color contract under - `d2b.site.ui`, `d2b.envs..ui`, and - `d2b.vms..ui`, with resolved JSON and GTK-compatible CSS - artifacts at - `/etc/d2b/ui-colors.{json,css}` and a niri backend that renders - active/inactive/urgent VM borders from the shared model. The CSS artifact - uses GTK-compatible `@define-color` declarations with underscore names. - -- Constellation observability: added `d2b op inspect` for bounded current - operation and realm-state inspection, with optional TraceContext fields, - degraded partial results, generated CLI schema coverage, and reference docs - for redaction/cardinality constraints. - -- Realm policy: added `d2b realm list` and `d2b realm inspect` to make - host-resident vs gateway-backed realms discoverable, documented the - default-deny cross-realm policy, and added migration guidance for explicit - realm gateways. - -- Display and virtual I/O: added explicit display capability helpers and a - `d2b vm display list|close` gateway display-session surface that returns - only bounded non-secret session metadata, including the authorizing - operation id and principal. Added reference documentation that keeps - display, clipboard, audio, USB/HID, GPU, video, and provider display - streaming as separate opt-in capabilities. - -- Runtime providers: added the host-side Cloud Hypervisor runtime provider - adapter and explicit provider-selection policy. `local-cloud-hypervisor` - remains the default VM runtime, plans carry only bounded provider/workload - metadata, and crosvm, QEMU, Firecracker, and qemu-media ids fail closed - rather than silently falling back. Firecracker-shaped selections refuse - desktop, guest-control, virtiofs/store, graphics, audio, and USB workloads - before side effects. Added reference documentation for runtime provider - selection and cross-links from component/runtime docs. - -- Constellation: added a preview remote full-host node adapter. A gateway - guest can now register a remote d2b host as a named node in a realm, - route typed lifecycle and exec/logs operations to it, and receive typed - responses through the remote host's own `d2bd`/broker/guest-control - stack. The adapter validates registration (node id, realm path, schema - shape, capability set, authenticated gateway principal), tracks heartbeat/liveness, - gates every routed operation against the node's declared capabilities, and - enforces the non-tunneling boundary (no raw broker frames, no guest-control - frame forwarding, no fd/pidfd transfer, no host path or credential - exposure across the transport session). Remote-side idempotency deduplication - is layered on top of the gateway-level dedup so reconnect recovery queries - remote state before retrying side effects. Peer disconnect marks the node - unavailable immediately, and new-generation re-registration makes - old-generation operations fail stale. Relay identity remains reachability only - and is never mapped to a local or realm principal. This adapter is - **experimental/preview**: it is validated with mock and loopback peer clients - only. Production transports (Azure Relay over a live WAN, QUIC, SSH), - remote host install, remote host prepare, and network mutation are not yet - supported. See `docs/reference/remote-full-host-nodes.md` for the full - reference. - -- Bundle: the private manifest bundle now emits `storage.json` and - `sync.json` contracts for managed paths, process restart/adoption - policies, degraded-state taxonomy, and lock/lease synchronization - policy. - -- Broker: added bundle-resolved `ReconcileStorageScope` and - `ValidateLockSpec` operations so storage and synchronization contracts - can be inspected and, for static directory specs, reconciled without - daemon-supplied raw paths. -- Daemon: startup now performs a read-only storage/restart/sync contract - check and persists `storage-lifecycle-report.json` for degraded-state - adoption work and future doctor/status UX. -- CLI: `d2b host doctor --read-only` now surfaces - `storage-lifecycle-report.json` with bounded issue kinds and inline - remediation for storage/restart/sync contract drift. -- CLI: `d2b host doctor --read-only` now treats the private broker - socket, optional metrics endpoint absence, and current swtpm namespace - posture as healthy when those surfaces match the deployed policy. -- CLI: added `d2b host migrate-storage --dry-run`, which emits a - checkpoint ID, exact rollback command, preserved-data inventory, - cutover-only cleanup candidates, and fail-closed hazards for the - planned storage layout migration. -- Tests: added storage lifecycle report schema and serialization - regression coverage so doctor/status consumers see the same camelCase - contract that the daemon writes. -- Documentation: ADR 0034 and the storage lifecycle explanation now - define the planned generated contracts for managed paths, process - restart/adoption, synchronization, lock ownership, degraded-state - reporting, and the one-time storage cutover. -- ADR 0032 gateway lifecycle: gateway-mode `d2b vm - start/stop/restart ` now routes through lifecycle - operations backed by the ACA preview REST data plane. Gateway config - can declare non-secret ACA subscription/resource-group/sandbox-group/ - region/image coordinates, and the provider creates/reuses disk images - and sandboxes by d2b workload labels instead of shelling out to the - preview `aca` CLI. -- ADR 0032 ACA display: gateway config can now carry the non-secret ACA - managed-identity client id used by local validation probes, while the - live display sender receives a gateway-minted short-lived Relay Send - bearer instead of the long-lived Relay rule key. -- NixOS: added `d2b.site.usePrebuiltHostTools` so development hosts - can validate source-built `d2b`, `d2bd`, and activation helper - binaries before matching release prebuilts exist. -- CI: merging `main` after cutting a new dated changelog section now - auto-tags the release and publishes pre-built `x86_64-linux` host - binary tarballs for `d2bd`, `d2b`, `d2b-priv-broker`, - `d2b-wayland-filter`, and `d2b-activation-helper`, alongside - `SHA256SUMS`, on the matching GitHub Release. -- CI: after publishing a GitHub Release, the release workflow now - computes Nix SRI hashes for each tarball, writes `nix/prebuilt.json`, - and auto-commits the manifest back to `main` so consuming flakes can - fetch the published host binaries by hash without manual updates. -- `d2b.vms..qemuMedia.window.niriBorderColor` lets - qemu-media host QEMU windows use a VM-specific niri border color. - qemu-media windows now route through the d2b Wayland filter proxy - so the generated niri include can match the VM-prefixed app-id - `d2b..*`. -- `qemuMedia` image-file sources can now be declared directly with an - absolute `path` and `format = "raw"`; physical USB sources continue to - use opaque refs plus config/probe-driven runtime selection. -- `d2b.vms..qemuMedia.bootDrive.slot` adds boot-drive selector - metadata for future qemu-media runtime planning without changing the - current QEMU argv shape. -- ADR 0032 realm entrypoints now publish a host-visible - `realm-entrypoints.json` table, allow separate gateway guests for - separate realm/env segments, and add `d2b realm enter/run` plus - manifest-backed realm target routing for gateway-backed VM verbs. -- ADR 0032 auth/audit foundations now define redacted daemon-access - principal mapping records, tamper-evident audit-chain DTOs, daemon - audit hash chaining, explicit audit-sink health reports, and - host-boundary tests proving gateway relay/provider material stays out - of host daemon artifacts. -- ADR 0032 peer protocol foundations now expose explicit - handshake-accepted/rejected frames, bind codec schema fingerprints into - plain and secure peer handshakes, and document the length-delimited - semantic frame skeleton for future gateway transports. -- Named stream plumbing now supports resumable log cursors, - deterministic stream draining, and retry-safe cancellation for future - remote execution and display sessions. -- Remote execution groundwork now supports reliable reconnects, bounded - retained-log reads, and safe repeated cancellation for future durable - remote exec sessions. -- Documentation: ADR 0036 records the current qemu-media runtime contract, - and ADR 0037 defines the shared local hypervisor runtime/service seam for - qemu-media and Cloud Hypervisor/crosvm workloads. -- Capability negotiation now rejects operations and streams when a - session lacks the required capability, with typed missing-capability - errors. -- Gateway credentials can now be enrolled and rotated inside the gateway - guest as a sealed runtime envelope, while host-side gateway credential - reads and Relay Send bearer minting are rejected. -- Transport conformance now covers loopback session capacity, byte-exact - concurrent sessions, shutdown, frame-cap rejection, truncated frames, - capability intersection, stream backpressure, and retry-safe stream - cancellation. -- Azure Relay now has a constellation `TransportProvider` adapter that - wraps Relay WebSocket rendezvous into bounded transport sessions for - gateway-owned listeners and sandbox senders. -- Local TCP test transport support now proves the transport interface is - not Azure-specific, with loopback-only binds, explicit URI targets, and - redacted typed errors for negative network cases. -- Host substrate provider adapters now wrap the existing host-check report - for NixOS and generic Linux/Ubuntu capability discovery, returning typed - remediation when prerequisites fail. -- Host-to-realm isolation is now documented and checked with a redacted - host egress policy artifact, so host daemon/broker/CLI surfaces remain - free of realm relay credentials and sessions. -- Provider-managed sandboxes: the Azure Container Apps adapter now handles - provider-layer 429/rate-limit responses with `Retry-After`-aware - backoff metadata and a shared circuit breaker. When the circuit is open, - `Backpressure` errors include the remaining open duration. Probe attempts - have a bounded timeout, stale probes reopen the circuit, and repeated - transient failures use bounded exponential backoff with jitter. Concurrent - 429 responses from the same request batch can extend an already-open circuit. - Circuit state is shared across provider instances targeting the same Azure Container Apps - endpoint, subscription, resource group, and sandbox group so sibling - instances cannot bypass the breaker for the same upstream. Retry hint - metadata remains internal to the provider layer; no change to the public - `ConstellationError` schema. -- Provider-managed sandboxes: Azure Container Apps adapter authentication now - enforces workload identity first, then managed identity, in production. - Ambient developer credential chains (Azure CLI tokens, environment - variable secrets, developer-toolchain fallbacks) are not present in the - production resolution order. Non-production local-validation contexts - inject test credentials explicitly and are not a runtime fallback. -- Provider-managed sandboxes: Azure REST error diagnostics are - now gated by an allowlist. Only case-stable allowlisted `error.code` - values (or `unknown`), a length-bounded sanitized `error.message`, the - HTTP status code, and the opaque `x-ms-correlation-request-id` header - appear in provider errors, structured log spans, and audit records. - Full response bodies, endpoint URLs, subscription IDs, internal diagnostic - details, resource IDs, tokens, payload content, and workload output are never - forwarded. -- Documentation: added `docs/reference/provider-managed-sandboxes.md` - covering the Azure Container Apps adapter capability matrix, absent capabilities, - rate-limit/backoff/circuit behavior, credential boundary, diagnostics - redaction rules, error shapes, `provider-managed-isolation`, and scope - limitations including the absence of guestd, systemd, broker, KVM, vsock, - cgroup, namespace, SSH, and full-host lifecycle. Cross-referenced from - `docs/reference/remote-full-host-nodes.md`. +- Added the realm-native control plane under `d2b.realms.`, including + canonical `..d2b` targets, provider-neutral workload + identity, realm network and UI metadata, generated realm artifacts, bounded + realm/operation inspection commands, and metadata-only topology, access, and + resource-allocation layers. +- Added the explicit, default-denied `unsafe-local` provider for host-user + workloads. Generic typed `exec` and `shell` launcher items now work across + local VMs, qemu-media, and unsafe-local targets through `d2b launch`, with + persistent host shells, same-UID helper supervision, Wayland identity rails, + and visible no-isolation posture. +- Added daemon-owned serial-console and audio operations, including + provider-capability dispatch and host/guest mute and volume controls. +- Added an opt-in FIDO2/WebAuthn security-key proxy that presents a host device + to opted-in guests as virtual HID over vsock without transferring USB + ownership, plus status, session, cancellation, test, and notification + commands. +- Added explicit USB attachment for any physically present device to an + eligible VM, with preflight validation, audited ownership, and rollback. +- Added the opt-in `d2b-clipd` clipboard authority, picker-driven cross-realm + paste, virtualized guest clipboard transport, and Niri focused-window + attribution. +- Added a compositor-agnostic UI color contract rendered as + `/etc/d2b/ui-colors.json` and `/etc/d2b/ui-colors.css`, with a Niri VM-border + backend and per-realm accent metadata. +- Added macvtap-backed external network attachment for env net VMs, including + independent egress NAT, port forwards, and mDNS/`.local` reflection. +- Added generated storage and synchronization contracts, read-only startup + validation, `d2b host doctor --read-only`, and + `d2b host migrate-storage --dry-run`. +- Added provider-aware graceful VM shutdown with configurable global and per-VM + timeouts, plus explicit `--force` lifecycle overrides. +- Added experimental remote full-host and provider-managed Azure Container Apps + adapters with capability matrices, bounded backoff/circuit behavior, and + redacted diagnostics. Production remote transport remains out of scope. +- Added release automation that creates a version tag and GitHub release with + host binaries, checksums, and a Nix hash manifest when a dated changelog + section reaches `main`. ### Changed -- **BREAKING:** Unsupported operations and streams (file-copy, port-forward, - clipboard, audio, and device streams) are explicitly rejected with an - unsupported error instead of falling back to generic byte streams. Older - clients that relied on fallback byte streams must route only supported - operations: lifecycle, exec, logs, persistent shell, node health, and display. -- **BREAKING:** Legacy `d2b.gateways` and nested gateway/ACA sandbox - configuration now fail evaluation with migration errors that point operators - to `d2b.realms`; configs using those legacy surfaces must migrate before - evaluation succeeds. -- **BREAKING:** Explicit `d2b://` CLI targets that omit the reserved `.d2b` - suffix now fail with a target grammar diagnostic instead of falling back to - local VM routing. -- Routed CLI VM target resolution through the realm access contract DTOs while - preserving the existing local VM fast path and manifest-backed gateway - behavior until the daemon access API is implemented. -- Renamed the Rust realm foundation crates from - `d2b-constellation-*` to `d2b-realm-*` without changing runtime behavior, - establishing realm-native package/import names for follow-up parser and DTO - work. -- Aligned realm-core reference pages and generated schema companions - with `d2b-realm-core` naming and the realm-qualified target - grammar. -- Kept realm operator-troubleshooting identifiers visible in Rust `Debug` - output while preserving redaction for credential-, key-, and principal-like - identifiers. -- Tightened `allocator.json` config typing so realm paths, provider kinds, - and transitional env-bridge modes carry bounded schema/runtime validation. - - -- `d2b-wayland-proxy` now treats `graphics.waylandProxy.border.thickness`, - `graphics.waylandProxy.border.label.position`, `--border-thickness`, and - `--border-label-position` as deprecated legacy shape knobs; generated d2b - proxy runners use the fixed-width left wrapper rail and vertical label. -- Reference docs (`cli-contract.md`, `daemon-api.md`, `display-io-capabilities.md`, - `runtime-provider-selection.md`, `components-audio.md`, `error-codes.md`) now - point `console` and `audio` surfaces at the provider capability - matrix. -- The host-side `d2b-wayland-proxy` proxy is source-built from the checked-out - workspace even when other host tools use release prebuilts, so local eval - gates do not depend on a matching release tarball for this policy binary. -- The `with-entra-id` eval workflow now overrides GitHub inputs to the - committed lock revisions and authenticates Nix fetches with the Actions token - to avoid transient unauthenticated API rate limits. -- Host activation grants `d2bd` narrow access to the Wayland user's - PipeWire/Pulse sockets so daemon-owned audio policy enforcement does not - depend on host-local ACL overrides. -- Console drainers now spawn on a daemon-owned Tokio runtime even when console - attach requests are handled by synchronous public-socket worker threads. -- Clipboard documentation now follows Diataxis placement more closely: the - architecture overview is indexed as Explanation, while `d2b-clip-debug` - diagnostic command examples live in the clipboard picker how-to. -- Renamed the project to **d2b: Double Dutch Bus** as an intentional breaking - change. Commands, packages, services, sockets, Nix options, runtime paths, - schemas, telemetry identifiers, and generated artifacts now use only `d2b` - naming; old names are unsupported and no compatibility aliases are provided. - - -- Public daemon list/status handling now uses a request-scoped artifact snapshot - so manifest, process, host, and bundle resolver reads are shared within one - request without cross-request caching. -- VM activation now keeps guest systemd isolated from the host: `switch - --apply`, `test --apply`, and live `rollback --apply` fail closed when the - VM is stopped/offline or does not advertise the guest activation capability, - while `boot --apply` is the explicit offline staging path for the next start. -- `d2bd` now orchestrates live VM activation as broker prepare, - guest-control activation, and broker commit, with per-VM serialization, - crash-consistent pending markers, bounded activation metrics, and degraded - status/list reporting for unresolved activation state. -- Guest-control now exposes authenticated in-guest system activation start/status - RPCs, with guestd-owned transient systemd units and restart-safe status. -- Examples and the default template now describe the daemon-only lifecycle, - Rust CLI, and `d2b` group authorization model without stale per-VM - systemd, polkit, route-preflight, or bash-CLI references. -- Human `d2b status ` output now labels daemon and runner state with - daemon-owned terms instead of retired per-VM systemd template names. -- Broker user-namespace sync-pipe creation and parent-side sync I/O now use safe - fd wrappers while preserving `O_CLOEXEC`, cleanup, and reap semantics. -- The PR checklist and policy tests now include an efficiency ratchet for host - gate N/A justifications, AI/model metadata hygiene, metric label cardinality, - noisy PID logging, and file-wide unsafe-code allowances. -- Runtime capability projection for qemu-media list/status output now goes - through focused helpers with direct regression coverage, preserving public JSON - and human output shape. -- Provider/realm policy coverage now explicitly guards host daemon, broker, and - bundle artifacts from storing realm credentials, remote registries, or realm - audit state while keeping capability-denied remote dispatch fail-closed. -- Renamed the internal contract/DTO crate to `d2b-contracts` and added - workspace taxonomy checks for contract and standalone workspace coverage. -- CI workflow make-target policy coverage moved from a shell meta gate to a Rust - contract test with pinned successor coverage. -- The Tier 0 first-pass implementation moved under `tests/tools/` while keeping - the stable `make check-tier0` target. -- `storage-lifecycle-report.json` now includes bounded `contractId` and - `offendingId` fields on storage/sync contract validation issues, and broker - storage I/O diagnostics redact absolute managed paths as - `storage-path#`. -- QEMU media's redacted registry index is now private to declared daemon/broker - readers (`0640`) and QEMU media state lives under a dedicated per-VM - `qemu-media` subdirectory. - -- `d2b vm exec` and `d2b vm exec -d` no longer time out with - `guest-control-timeout` (exit 69) when a long-running GUI application - (such as Firefox) is starting up in the target VM. During peak startup - the VM is under heavy virtiofs I/O load; vsock connection setup and the - six-step authenticated handshake (connect, Hello, broker sign, - Authenticate, broker sign, Health) each approach their 3-second per-op - cap, requiring up to 18 s of budget. The establishment deadline was - raised from 12 s to 20 s (covering all six operations at their full cap - plus 2 s headroom). The detached-create RPC deadline was raised from - 12 s to 20 s for the same reason. -- Broker VM activation requests now split store-view preparation, guest-completed - metadata commit, and offline metadata-only staging so the privileged broker no - longer executes VM `switch-to-configuration` scripts on the host. -- `/run/d2b` tmpfiles ACL ordering now reasserts the ACL mask after per-VM - traversal entries, so `d2bd` keeps effective write access to its daemon - lock after a host switch. -- `qemu-media` TAP synchronization locks now render the TAP identifier as a - resource id instead of a non-path `pathTemplate`, so the generated `sync.json` - deserializes through the Rust `SyncJson` DTO and `d2bd` can load the bundle - after hosts with qemu-media VMs switch. -- Broker SIGCHLD reaper startup now installs the child-signal stream before the - runtime is returned, closing a load-sensitive child-reap race surfaced by the - broker reap tests. -- Daemon startup no longer lets the diagnostic bridge preflight pre-skip - autostarted net VMs on cold boot; net VMs now get to run their host-prep DAG - and workloads degrade only if their env net VM actually fails to start. -- Host OTel collector no longer has directory write authority over - `/run/d2b/otel`: the collector's access ACL is now `--x` (traverse only, - no create/unlink authority on `host-egress.sock` or sibling entries). The - default ACL is retained at `rw` (not `rwx`) with a clamped `rw` mask so the - collector inherits read+write on `host-egress.sock` when the broker-spawned - bridge creates the socket after boot, while execute bits never propagate to new - entries. `StartLimitIntervalSec = 0` ensures systemd does not permanently - disable the service if the bridge socket is momentarily absent across restarts. -- The `/run/d2b` runtime parent is now root-owned with an explicit - `d2bd` ACL, avoiding systemd-tmpfiles unsafe-path-transition failures - that skipped per-VM `guest-control` runtime directories after reboot. -- Broker request handling no longer emits a per-request `Bundle resolver loaded` - info log, and USBIP proxy reconciliation now treats absent locked hardware as - a non-fatal ACL-refresh skip instead of spamming paired broker/daemon warnings. -- `storage.json` validation-evidence rows now use bounded contract identifiers - for actor values, so rendered fixtures deserialize through the Rust storage - contract DTOs. -- Activation now grants every numeric per-role runtime UID traversal on both - `/run/d2b` and `/run/d2b/vms`, so broker-spawned runners can reach - their per-VM runtime socket directories after a host switch. -- Public daemon `status` keeps guest USBIP import state, but now uses a short - status-specific guest-control budget so stale or slow guest USB probes cannot - push wlcontrol past its public-socket timeout. -- Public daemon `list` and `status` responses now build per-VM status entries - in parallel, so slow provider or guest-control probes cannot serially push - wlcontrol past its public-socket timeout. -- Daemon-native runtime parent directories under `/run/d2b/vms`, - `/run/d2b-gpu`, `/run/d2b-video`, and `/run/d2b-wlproxy` - are root-owned again while preserving daemon-owned per-VM leaves, so - broker path-safety checks no longer reject VM starts after a host switch. -- `d2bd.service` now reports systemd readiness only after the daemon has - rebound its public socket and completed startup adoption, so post-switch - scripts no longer race `/run/d2b/public.sock`; daemon updates may restart - `d2bd` without restarting running VMs, which are re-adopted afterward. -- VM stop/restart now has the Nix and manifest configuration surface for - provider-aware graceful guest shutdown, including global/per-VM enable and - 1–600 second timeout controls, `manifestVersion = 7`, daemon-config - rendering, and host-shutdown `d2bd.service` ordering/timeout budgeting. -- Host shutdown and reboot now gracefully stop workload VMs before env net VMs. -- Broker disk initialization now validates existing d2b-owned ext4 raw - images before treating `ifAbsent` as satisfied, automatically repairs safe - declared owner/mode drift, safely formats only proven-empty images, and fails - closed before VM spawn for malformed or ambiguous image data. -- Persistent shell guests now render the shpool daemon unit with a store-backed - start script instead of an inline multi-line `ExecStart` command, avoiding - systemd quote parsing failures. -- Persistent shell attach/detach now accepts daemon owner error frames that carry - the envelope `opId`, matching the successful shell response and exec owner - framing. -- Persistent shell detach now treats a successful daemon best-effort close as a - clean owner close even if the first close-attach RPC reports a transient - guest-control transport error. -- Persistent shell detach now treats the known close-attach transport-unavailable - response as a successful local detach, matching the daemon's owner-disconnect - cleanup semantics. -- Persistent shell attach now starts the guest shpool daemon, probes readiness - through the workload helper, and wires shell terminal RPCs to the PTY-backed - attach helper instead of returning disabled shell I/O. -- `d2b list --json` now preserves daemon-reported failed lifecycle state as - `status = "failed"` instead of collapsing it to `unknown`. -- `d2b list` and `d2b status` now use a short provider-status probe - timeout instead of the graceful shutdown operation timeout, keeping status - queries responsive when a VM's provider API socket is slow. -- `d2b usb attach --apply` now fails immediately for stopped - VMs with copy-pasteable start-and-retry remediation instead of surfacing a - generic guest-control transport failure. -- Privileged USB broker IPC now rejects malformed bus IDs, traversal-shaped - intent identifiers, and invalid module names before host USB/module actions, - rate-limits direct broker peers plus daemon-forwarded requests by stable - bounded/evicted UID/role/operation buckets with separate direct/daemon bucket - pools so peer floods cannot starve daemon-forwarded operations, caps audit - writes, returns typed fail-secure peer-credential refusals, and redacts - sensitive USB/bundle details from public error envelopes. -- Broker audit write limiting now gives unprivileged refusal spam a separate - bounded bucket from privileged operation audit writes and records visible drop - counters when the limiter refuses audit records while aggregating journal - warnings for dropped records. -- USBIP bind/unbind broker requests now carry only bundle-resolved opaque - intent IDs, with broker-side physical VID/PID and bus/port topology checks - before bind or replay. -- USB serial-correlation key-rotation audit records are now deduplicated per - previous/current key pair so repeated USB binds during a grace window do not - consume privileged audit tokens. -- USBIP proxy firewall carve-outs now fail closed unless they can scope TCP/3240 - to the env's uplink bridge, host bridge IP, and host-visible net-VM source IP; - proxy listeners also reject wildcard bind addresses. -- USBIP detach now fails with an actionable `usbip-revocation-not-isolated` - error unless immediate stream revocation can first block/withdraw the firewall - carve-out and then target a proven VM/proxy conntrack or TCP socket tuple - whose source is not SNAT-obscured and whose anti-spoofing posture is proven, - preserving the USBIP session claim instead of silently leaving an established - stream or bouncing unrelated same-env streams. -- USBIP carrier cleanup now keeps withdrawing firewall state and unbinding the - host carrier when guest detach fails because the VM is dead or unreachable, - while preserving the failed guest-detach report for degraded/audit visibility. -- USBIP step and revocation failures now name the target busid while keeping - remediation concise and free of raw sysfs paths or serials. -- USBIP attach failure rollback now filters shared per-env backend/proxy - sidecar checks out of the single-busid rollback order, avoiding disruption - to unrelated same-env USBIP streams. -- USBIP host unbind now drains bounded helper stderr concurrently so verbose - failures cannot stall detach/restart cleanup before the helper exits. -- USBIP bind now revokes the backend device ACL and unbinds the host carrier if - the terminal broker success audit record cannot be written, avoiding - unaudited bind state after audit rate limiting or write failures. -- USBIP bind/unbind error paths now release busid locks after failed bind - convergence, ACL grant rollback, or post-unbind ACL revoke failures unless the - device is still proven bound to `usbip-host` for manual recovery. -- VM start now reconciles same-host-session same-VM USBIP claims after guest-control - readiness by replaying host bind/proxy state and re-importing in-guest - devices; stop/restart cleanup now preserves the session claim and refuses - sysfs host unbind unless firewall withdrawal plus targeted stream cleanup can - be proven first. -- VM stop/restart now suppresses USBIP cleanup degradation when the trusted - bundle is unavailable and no matching host-session lock exists, while still - warning if a matching lock exists or lock probing fails. -- USB serial-correlation HMAC key rotation windows now emit a broker audit - record with only key IDs and rotation metadata, preserving the forensic trail - without logging key material or raw serials. -- `d2b usb probe` and `d2b status` now split USB session claim, - host, guest, topology/policy, degraded reason, and remediation state so stale - lock-only USBIP claims are not reported as healthy bound devices. -- USBIP reference docs and CLI output artifacts now document the host-session - claim versus active carrier model, including that `/run/d2b/locks/usbip` - survives VM stop/restart and daemon restart but not host reboot, plus restart - reconciliation, probe JSON schema, degraded reasons, prerequisites, and - copy-paste remediation commands. -- Required USBIP policy failures during VM-start claim replay now fail before - device exposure and roll back boot, while runtime absence/proxy/guest - availability issues remain visible degraded USB state. -- VM stop/restart USBIP cleanup now has a reusable daemon plan that detaches - guest imports, withdraws host carrier/firewall/flow state when safely - isolated, preserves same-VM USBIP session claims for restart, and reserves - claim release for successful explicit detach. -- `/run/d2b/locks/usbip` is now created by tmpfiles before daemon/broker - startup as `root:d2bd 0750`, keeping USBIP lock claims broker-written - while allowing daemon status reads. -- `d2b-priv-broker.service` now explicitly uses `Delegate=true` and - `KillMode=process` in `d2b.slice` so broker restarts do not tear down - broker-spawned runner cgroups. -- Broker-spawned runners now use `clone3(CLONE_INTO_CGROUP)` against the - systemd-delegated `d2b.slice` role leaf when available, with the legacy - `cgroup.procs` attach kept only as the fork fallback; `d2b.slice` - delegates the required `cpuset` controller as well. -- The broker's `cgroup.procs` fallback now writes the child PID from the parent - before releasing user-namespace runner children, avoiding in-namespace - cgroupfs permission failures. -- Runtime per-VM socket directories and store-view top-level directories are now - created and postured by tmpfiles instead of ad-hoc activation mkdir/chown/chmod - snippets. -- Host tmpfiles ACL rules now append entries instead of replacing previous ACLs - on the same path. -- Per-VM state-root posture, static state traversal ACLs, TPM parent traversal - ACLs, and next-generation runtime leaf directories are now tmpfiles-owned; - net-VM `var.img` creation/posture remains broker `DiskInit`-owned instead of - being repaired by host activation. - - -- CI: nix-unit eval coverage is now split into multiple - `nix-unit-` flake checks plus a cheap global `nix-unit` - presence/pin check, so PR flake evaluation can fan the slow corpus - out across the existing x86 matrix. -- **Breaking:** VMs with `d2b.vms..usbip.yubikey = true` must - now also enable `d2b.vms..guest.control.enable = true`. USBIP - guest attach/detach is owned by guestd over authenticated - guest-control; there is no SSH fallback. -- Broker: `d2b-priv-broker.service` now defaults `RUST_LOG` to - `info` instead of `debug`, keeping high-volume broker diagnostics out - of normal journal/OTel log exports unless an operator opts into debug - logging. -- CI: the PR aarch64 flake leg now runs only the lightweight - `smoke-eval-aarch64.nix` check instead of the full native aarch64 - flake sweep. -- NixOS module: `d2b.site.usePrebuiltHostTools = false` now also - forces `d2b-priv-broker` to build from the local source checkout, - keeping the broker wire/bundle parser aligned with `d2bd`. -- `bundleVersion` 5 → 6: adds the private storage lifecycle and - synchronization artifacts to the trusted bundle. -- CI: pull requests now fail closed when Rust/Nix/Cargo changes do not - update `CHANGELOG.md`, or when the changelog is missing - `## [Unreleased]`, uses duplicate/out-of-order version headers, or - carries non-semver versions / non-ISO release dates. -- `manifestVersion` 5 → 6: per-VM entries now carry runtime/provider - metadata and provider capability summaries. Provider-specific socket - and vsock fields are nullable so `qemu-media` entries do not fabricate - Cloud Hypervisor or guest-control artifacts. -- ADR 0032 constellation core contracts now publish and document hardened - schema roots: target and identifier parsing is bounded and - fail-closed, capabilities are positive assertions, mutating operations - require idempotency keys, and audit/error/trace payloads carry only - redacted, bounded metadata. This is a contributor-facing contract; it - does not change CLI, daemon, or host behavior. -- Daemon audit JSONL records now carry `prev_hash` and `record_hash` - chain fields, with verifier and sink-health helpers that report - tampering, write unavailability, and retention-floor degradation - without exposing filesystem paths or credential-shaped detail. -- `qemu-media` VMs now emit a typed QMP-only QEMU runner process node - instead of being absent from `processes.json`; they still do not emit - Cloud Hypervisor, store/virtiofs, or guest-control runner data. -- Daemon and CLI list/status output now include positive - `runtimeCapabilities` and `serviceCapabilities` alongside unsupported - capability summaries. -- Runtime/provider capability metadata now carries shared `operations` - and `services` summaries for both Cloud Hypervisor-backed NixOS VMs and - qemu-media VMs while keeping the legacy flat capability booleans. +- **Breaking:** Renamed the project to **d2b: Double Dutch Bus**. Commands, + packages, services, sockets, Nix options, paths, schemas, and telemetry now + use only `d2b` naming; no legacy aliases are provided. +- **Breaking:** Removed the legacy `d2b.gateways` and nested gateway/ACA sandbox + configuration surfaces. Configurations must migrate to `d2b.realms`. +- **Breaking:** Explicit `d2b://` targets must include the reserved `.d2b` + suffix; omitted suffixes no longer fall back to local VM routing. +- **Breaking:** Unsupported constellation streams and operations now return + typed unsupported errors instead of falling back to generic byte streams. +- **Breaking:** VMs using `usbip.yubikey = true` must enable guest control; + USBIP attach and detach no longer have an SSH fallback. +- Advanced the public manifest schema to version 7 and the private bundle + contract to version 11. The release adds runtime/provider capabilities, + graceful-shutdown metadata, realm artifacts, configured launcher items, + unsafe-local helper policy, and storage/synchronization contracts. +- Renamed the Wayland proxy package and binary to `d2b-wayland-proxy` and the + configuration surface to `graphics.waylandProxy.*`. The former option path is + retained as a compatibility alias for this release. +- Moved audio process identity entirely into the daemon-managed audio runner and + retired the former audio service path. +- Changed live VM activation to a broker-prepare, guest-control activation, and + broker-commit flow. Offline activation now fails closed except for explicit + boot staging. +- Changed daemon list/status handling to use request-scoped artifact snapshots + and parallel per-VM status probes, improving consistency and desktop-client + latency. +- Changed runtime/state creation to rely on tmpfiles-owned parents and + narrowly-scoped ACLs instead of activation-time permission repair. +- Changed `d2b-priv-broker.service` default logging from `debug` to `info`. ### Fixed -- Hardened route refresh handling so stale/equal advertisements cannot downgrade - topology or capabilities, expired entries are treated absent without hot-path - full sweeps, admissions remain atomic without full map cloning, and normal - proactive refresh sequences do not exhaust replay capacity. -- Added fixed in-memory capacities to the pure realm route engine so valid - unexpired parent, route, and replay advertisement state rejects new entries - fail-closed instead of growing without bound. -- Bounded the pure realm route engine's replay/route expiry state, refreshed - existing route capabilities and ids on newer advertisements, and made - local-root route capability decisions explicit. -- Aligned protobuf authorization-scope encoding with identity lifecycle scopes - and route contracts. -- Documented the private `realm-controllers.json` contract for deterministic - host-local realm controller unit/socket naming, direct realm socket - authorization, local-root allocator resolution, state/audit separation, and - the access-layer/routing boundary. -- Added Layer-1 allocator coverage for rendered `allocator.json` bundle wiring, - realm allocator metadata, fake-engine conflict/replay/reconcile paths, and - bounded reconciliation reports. -- Added the public `d2b.realms.` Nix option schema foundation for - the realm-native control plane without changing existing `d2b.envs` - runtime behavior, with normalized realm index metadata, eval-time - assertions for realm identity/parent/path uniqueness, and reference - documentation for placement, user access, provider/relay/policy/key - references, and the transitional env/network bridge. -- Added Layer-1 nix-unit coverage for the realm option schema, - normalized realm index, parent/path collision assertions, legacy gateway/ACA - migration guidance, and minimal/multi-env eval compatibility. -- Added realm `placementProvider` metadata and AF_UNIX socket path length - assertions for realm public and broker socket declarations. -- Added the `RealmTarget` parser with canonical realm-qualified - rendering, bare-alias ambiguity diagnostics, and old node-qualified target - migration errors. -- Added an accepted realm-native control-plane decision record, superseding the - host-centric constellation model with per-realm daemon, broker, state, and - audit boundaries; first-class `home`, `dev`, and `work` realms to replace - the legacy grouping model; and a clean cutover from old realm/ACA sandbox - surfaces into `d2b.realms`. -- Added core realm data models for controller placement, - access bindings, provider/workload placement summaries, tree route - advertisements, enrollment/key lifecycle metadata, and migration-error - envelopes. -- Documented the stable public-socket discovery contract for persistent shells - so desktop clients such as `d2b-wlterm` can use `List`/`Status` plus - `ShellOp::List` without scraping human CLI output or leaking terminal state. -- Added typed deserialization support for public daemon response envelopes so - downstream clients can parse `PublicResponse` data without falling back to - untyped JSON. -- Exported `packages..d2b-wayland-proxy` and added a supported - `--host-terminal` launch path for VM-bound host terminals. The launcher creates - randomized single-use Wayland and WezTerm mux sockets under a private - `$XDG_RUNTIME_DIR` directory, waits for proxy readiness before launching the - foreground child, preserves d2b clipboard mediation, and keeps privileged - Wayland globals hidden. -- Documented the optional desktop terminal integration stack (`d2b-toolkit`, - `d2b-wlterm`, and WeezTerm) with exact flake-input follow boilerplate, - Home Manager wiring, Waybar setup, and validation commands. -- Added CTAP/WebAuthn security-key proxy: `d2b.host.usb.securityKey.*` and - `d2b.vms..usb.securityKey.enable`. The host broker (`d2bd`) serializes - CTAP HID traffic from opted-in VMs to a host-attached FIDO2 device (YubiKey - or equivalent) over AF_VSOCK, without USB device ownership transfer. Each - opted-in VM receives a daemon-supervised virtual FIDO2 HID device - (`/dev/hidraw*`) via Linux `/dev/uhid`; browsers and `libfido2` treat it as - a normal local security key. -- Added `d2b usb security-key status|sessions|cancel|test` subcommands for - lease inspection, session history, stuck-request cancellation, and - per-VM smoke checks. -- Added structured notification/event emission for security-key lifecycle - events (ceremony start, user-presence wait, contention, failure, lease - revocation) through the d2b notification subsystem, with durable - `/run/d2b/usb-sk/events.jsonl` and a machine-readable - `/run/d2b/usb-sk/lease.json` state file. -- Notification actions (`Cancel active request`, `Open status`) include - single-use, high-entropy nonces bound to session/action/expiry; `d2bd` - rejects missing, expired, reused, or mismatched action tokens. -- Added eval-time assertions: `usb.securityKey.enable = true` and - `usbip.yubikey = true` are mutually exclusive for the same VM; per-VM - security-key opt-in requires the host `usb.securityKey.enable = true`; - per-VM opt-in requires `guest.control.enable = true`. -- Added Diataxis documentation: - - How-to: [`docs/how-to/use-usb-security-key.md`](docs/how-to/use-usb-security-key.md) - - Migration: [`docs/how-to/migrate-usbip-yubikey-to-security-key.md`](docs/how-to/migrate-usbip-yubikey-to-security-key.md) - - Reference (options, CLI, event/notification JSON): [`docs/reference/components-usb-security-key.md`](docs/reference/components-usb-security-key.md), [`docs/reference/usb-security-key-events.md`](docs/reference/usb-security-key-events.md) - - Explanation (CTAP proxy architecture, why not USB sharing, comparison with USBIP and Qubes): [`docs/explanation/usb-security-key-architecture.md`](docs/explanation/usb-security-key-architecture.md) -- Added the `d2b-notify` crate: a reusable notification/event mechanism for - d2b desktop UX, including: - - Typed `SecurityKeyEvent` enum covering all CTAP/WebAuthn ceremony - lifecycle phases: `Started`, `TouchNeeded`, `Busy`, `Queued`, `Blocked`, - `TimedOut`, `Failed`, `Canceled`, `Completed`. - - `ActionNonceStore`: single-use CSPRNG nonces (32 bytes, 64-char hex) - bound to `session_id`/`action_key`/expiry, with fail-closed validation - that prevents notification-action replay by hostile desktop clients. - - `SkNotifyState`: durable JSON state format (schema version 1) written by - the host runtime to `/run/d2b/notify/sk-state.json`; read by the Waybar - helper and `d2b-wlcontrol`. - - `WaybarBlock` + `waybar_block_from_state`: Waybar JSON-protocol block - derived from the current ceremony state, with per-state CSS classes - (`d2b-sk-idle`, `d2b-sk-touch`, `d2b-sk-busy`, `d2b-sk-active`). - - `WlcontrolSkStatus`: data contract for the `d2b-wlcontrol` status/action - surface, with pluggable per-ceremony action builder for nonce-backed - buttons. - - `d2b-sk-waybar-helper` binary: reads the durable state file and emits - one Waybar JSON line to stdout; suitable as a `custom/d2b-sk` `exec` - target. - - Pluggable `Notifier` trait + `RecordingNotifier` for hermetic tests; - per-event builders for all user-visible ceremony transitions. -- Added `nixos-modules/notifications.nix`: NixOS module with options - `d2b.notifications.enable`, `d2b.notifications.statusHelper.{enable, - package,executablePath}`, `d2b.notifications.integrations.waybar.enable`, - `d2b.notifications.securityKey.{enable,staleEntryTtlSecs}`, and - `d2b.notifications.runtime.stateDir`; creates the - `/run/d2b/notify` tmpfiles directory on activation. -- Added `d2b usb security-key` subcommand family exposing four operator surfaces - for the CTAP/WebAuthn security-key proxy feature: - - `d2b usb security-key status [--json|--human]` — show proxy health, - configured physical keys, per-VM virtual-device health, and current lease. - - `d2b usb security-key sessions [--json|--human]` — list recent and active - security-key request sessions, VM, RP ID, outcome, and timeout. - - `d2b usb security-key cancel {|--current} [--dry-run|--apply] - [--json|--human]` — cancel a stuck security-key request session; `--dry-run` - shows the planned `SecurityKeyProxyCancelSession` broker op. - - `d2b usb security-key test [--dry-run] [--json|--human]` — smoke-check - that the guest virtual HID device and the host broker's physical-key - visibility are healthy; `--dry-run` shows the two planned checks. -- Added USB security-key wire contract types in `d2b-contracts::public_wire`: - `UsbSecurityKeyStatusRequest/Response`, `UsbSecurityKeySessionsRequest/Response`, - `UsbSecurityKeyCancelRequest/Response`, `UsbSecurityKeyTestRequest/Response`, - and supporting DTOs (`UsbSkPhysicalKeyStatus`, `UsbSkVirtualDeviceStatus`, - `UsbSkLeaseStatus`, `UsbSkLeaseState`, `UsbSkSession`, `UsbSkSessionOutcome`, - `UsbSkTestCheck`). -- Added CLI output types in `d2b-contracts::cli_output`: - `UsbSkStatusOutputV1`, `UsbSkSessionsOutputV1`, `UsbSkCancelDryRunOutputV1`, - `UsbSkTestDryRunOutputV1`. -- Added CLI golden tests under `packages/d2b/tests/usb_sk_contract.rs` covering: - `usb security-key --help`, `cancel --current --dry-run`, `test --dry-run`, - and `not-yet-implemented` exit-78 envelope for all live paths. -- Extended `packages/d2b/tests/cli_json_output_contract.rs` with - `usb_security_key_dry_run_outputs_match_goldens`, - `usb_security_key_status_not_yet_implemented`, and - `usb_security_key_sessions_not_yet_implemented` tests. -- The use of "security key" as the user-facing term for CTAP/WebAuthn - authenticators is now established in CLI help, JSON envelopes, and docs. - The FIDO/CTAP terminology is reserved for diagnostic output and technical docs. - - The live paths (`status`, `sessions`, `cancel --apply`, `test ` without - `--dry-run`) emit exit 78 with a `not-yet-implemented` envelope until the - daemon broker handler lands in a later workstream. -- Added `d2b.host.usb.securityKey.enable` and `d2b.host.usb.securityKey.devices` - (stable FIDO device selector submodule with `vendorId`, `productId`, `serial`, - and `label` fields) to declare the host USB security-key proxy. -- Added `d2b.vms..usb.securityKey.enable` per-VM opt-in for CTAP/HID - relay to a host-proxied FIDO security key, guarded behind the new host option. -- Eval-time assertions: VM `usb.securityKey.enable` requires the host proxy to - be enabled; `usb.securityKey.enable` and `usbip.yubikey` are mutually - exclusive for the same VM (phase-1 constraint); device `vendorId` values must - be within the FIDO-class allowlist; device labels must be unique. -- Rust DTO module `d2b_contracts::security_key` with typed wire contracts: - `SecurityKeyStatusResponse`, `SecurityKeySessionsResponse`, - `SecurityKeyCancelRequest/Response`, `SecurityKeyEvent` (7 variants), - `SecurityKeyOpenDeviceRequest`, `SecurityKeyApplyUdevRulesRequest`, and - opaque-ID newtypes `SecurityKeySessionId` / `SecurityKeyDeviceLabel`. -- `PublicRequest` / `PublicResponse` variants for `UsbSecurityKeyStatus`, - `UsbSecurityKeySessions`, and `UsbSecurityKeyCancel`. -- `BrokerRequest` variants `SecurityKeyOpenDevice` and - `SecurityKeyApplyUdevRules` with `op_name()` dispatch arms. -- `W3BrokerOperation::SecurityKeyOpenDevice` and - `W3BrokerOperation::SecurityKeyApplyUdevRules` with wire tags, flags, and - capability advertisement. -- Privilege matrix rows for `usb security-key` (public) and the two new broker - operations; dispositions doc stubs for both broker ops. -- `usb security-key status`, `usb security-key sessions`, and - `usb security-key cancel` CLI contract stubs in - `docs/reference/cli-contract.md` (daemon not yet wired, phase 1). -- Nix-unit eval cases (`tests/unit/nix/cases/usb-security-key.nix`) and - assertion rejection cases for all new eval-time constraints. -- Contract + policy tests in `packages/d2b-contract-tests/tests/usb_sk_contract.rs` - (20 tests). -- Added `d2b.vms..usb.securityKey.enable` option. When `true`, the guest - VM gets a virtual FIDO2 HID device via a CTAPHID UHID frontend relay - (`d2b-sk-frontend`). The guest-side binary opens `/dev/uhid`, creates a - virtual HID device, and relays 64-byte CTAPHID reports over an AF_VSOCK - connection to the host broker (VSOCK port 14320). Firefox and libfido2 - discover the virtual `/dev/hidraw*` device via the `fido` group; no root - or physical USB access is required inside the guest. -- `d2b-sk-frontend` static guest binary: fully static (musl) binary for the - guest CTAPHID UHID relay frontend. Implements exponential backoff VSOCK - reconnect (1 s–60 s), clean UHID device recreation across reconnects, and a - simple 4-byte length-prefix framing protocol. Uses VSOCK port 14320. -- Host DAG node `sk-frontend` (role `security-key-frontend`): a no-runner - tracking node whose readiness predicate fires when the host broker's vsock - socket (`/vsock.sock_14320`) appears. Edge: `cloud-hypervisor → - sk-frontend`. -- Mutual exclusion assertion: `d2b.vms..usbip.yubikey` and - `d2b.vms..usb.securityKey.enable` cannot both be `true` for the same VM - (both claim the FIDO2 device endpoint). -- `qemu-media` runtime incompatibility assertion for `usb.securityKey.enable` - (the CTAPHID proxy requires the Cloud Hypervisor / nixos runtime). -- `securityKeyVsockPort = 14320` constant added to `d2b` lib for use by host - broker and guest component modules. -- Nix eval tests (`security-key-gating.nix`): manifest `securityKey` field - gating, DAG node presence/absence, assertion firing for the yubikey and - qemu-media conflicts. -- Contract tests (`minijail_sk_frontend.rs`): source-grep assertions for the - sk-frontend minijail profile block in `minijail-profiles.nix`, including role, - seccompPolicyRef presence, and empty capability set; compile-time - `ProcessRole::SecurityKeyFrontend` variant and serde round-trip check. -- Added `OpenHidrawSecurityKey` broker op: resolves a configured FIDO security-key - stable selector, opens the physical `hidraw` node, and passes the fd to `d2bd` via - `SCM_RIGHTS`. Includes the privilege-matrix row, audit fields, and dispatch wiring. -- Added `d2bd::security_key` session management module: CTAPHID relay with CID - isolation/translation, a one-active-ceremony-per-key lease state machine (default - 120s ceremony timeout, 15s queue-wait timeout), length-prefixed 64-byte report - framing, and a `SO_PEERCRED`-based per-VM socket peer authentication check. Raw CTAP - payloads, PINs, and credential material are never logged. - -- Added the `d2b.envs..externalNetwork.*` option and normalized-index metadata - surface for net-VM-owned external network attachment, egress, port-forward, and mDNS - policy. -- Net VMs can opt into external network mDNS reflection and an optional `.local` - dnsmasq bridge without running Avahi or opening UDP/5353 on the host. -- Nix-unit coverage and minimal eval wiring for opt-in per-env external network net VM - interfaces, egress carve-outs, port forwards, mDNS reflection, and `.local` - forwarding. - - -- Host-local realm daemons now emit only strict `DaemonConfig` fields - and use realm-scoped daemon state directories, with realm brokers explicitly - loading the shared `realm-controllers.json` contract. -- Allocator metric bounding now aggregates repeated low-cardinality - label sets before applying the event cap, preserving counts instead of - dropping later samples. -- Allocator metadata now emits one namespace-boundary resource - request per networked realm, avoiding duplicate resource ids for realms - spanning multiple enabled environments. -- Realm audit, operation, and typed-error envelopes now carry the - cross-realm correlation id needed to reconstruct rejected routes. -- Operation responses now carry the same required correlation id as - requests so response-frame return paths can emit correlated audit and trace - records. -- `realm-controllers.json` validation now accepts host-local materialized - unit/socket metadata when the artifact declares that systemd units are - materialized, while still rejecting drift when it claims none are emitted. -- Hardened host-local realm materialization by making realm runtime - directories non-group-writable, moving default per-realm audit directories out - of daemon-owned realm state, avoiding global systemd manager environment - mutation for broker uid/gid discovery, and removing host paths from new - startup/config tracing fields. -- Host-local realm allowed users who are also `d2b.site.launcherUsers` now keep - both the canonical `d2b` lifecycle group and their deterministic realm - socket-access groups. -- Realm Unix socket access-binding DTOs now reject paths longer than the Linux - `sockaddr_un.sun_path` limit before bind/connect. -- Realm capability-negotiation JSON now rejects unknown outer envelope fields - while still preserving unknown future capability tokens inside the - capability set. -- Host-local realm access preflight now derives advertised capabilities from - enabled provider refs and denies missing required capabilities instead of - echoing every request as satisfied. -- Aligned realm-core schema generation and identifier validation: - `xtask gen-schemas` now emits `d2b-realm-core.json`, and realm reference - tokens reject leading punctuation consistently with their JSON schemas. -- Aligned allocator reference docs with the generated realm-core - schema roots and documented the future repair-path shape for fail-closed - allocator reconciliation states. -- `d2b-wayland-proxy --host-terminal` now waits until the proxy-owned wrapper - toplevel has acked its initial configure before attaching the VM identity rail, - preventing Wayland compositors from rejecting proxied WeezTerm windows during - startup. -- Guest VMs now cap persistent systemd journals by default so `/var/log/journal` - cannot fill small per-VM `/var` images and corrupt NixOS activation state. -- Security-key host relay now drives the physical hidraw fd through - cancellation-safe `AsyncFd` I/O so guest disconnects cannot leave orphaned - reader threads racing future sessions. -- Security-key guest frontend now strips the kernel-supplied zero report-ID byte - from UHID output reports before forwarding CTAPHID frames to the host broker. -- Security-key guest udev rules now match the virtual UHID FIDO device by its - HID parent identity and grant the standard `plugdev` browser/FIDO access group - so Firefox and libfido2 can open the guest `/dev/hidraw*` node without root. -- Security-key guest frontend now drives `/dev/uhid` through nonblocking - `AsyncFd` I/O instead of `tokio::fs::File`, avoiding `ESPIPE` failures on - character devices during CTAPHID response injection. -- Security-key proxy broker now accepts descriptor-verified FIDO hidraw devices - even when the host udev group is not one of the fallback FIDO groups. The - group allowlist remains required only for the descriptor-unreadable fallback - path. -- Security-key accept loops now run on a daemon-owned runtime thread so the - per-VM VSOCK socket remains listening after the VM-start readiness transaction - returns. -- Security-key accept-loop listener initialization now happens inside the - daemon-owned Tokio runtime context instead of before the runtime is entered. -- Security-key guest frontend now emits the correct UHID_CREATE2 layout - (`bus` is a 16-bit field), allowing the virtual FIDO HID device to register - with the guest kernel. -- Security-key host listener sockets now use mode `0770` so inherited per-VM - ACLs can grant Cloud Hypervisor write/connect access without making the - listener world-accessible. -- Security-key guest frontend now writes the full UHID_CREATE2 payload, - including `dev_flags` and padding fields required by current Linux kernels. -- Security-key guest frontend now uses the correct Linux UHID event type - numbers (`CREATE2 = 11`, `INPUT2 = 12`) so the virtual FIDO HID device is - actually created rather than sending an input report to no device. -- Security-key guest frontend now zero-extends short UHID events from the kernel - instead of treating lifecycle events shorter than the maximum union size as - fatal. -- Security-key host relay now prefixes physical hidraw writes with a zero report - ID byte, matching Linux hidraw/libfido2 behavior for unnumbered FIDO reports. -- Store-sync activation now creates newly declared per-VM `/run/d2b/` - leaves before writing `next-generation`, without recursively creating or - changing the `/run/d2b` parent posture. - - -- `d2b-wayland-proxy` now tears down proxy-owned wrapper toplevels when guests - destroy the XDG role or disconnect abruptly, preventing dead VM identity rails - from outliving the guest window. -- `d2b-wayland-proxy` now translates pointer focus and motion from wrapper - content coordinates into guest-surface coordinates while suppressing only the - trusted rail, preventing clicks in wrapped guest windows from crashing clients. -- `d2b-wayland-proxy` now presents proxy-drawn VM identity rails through a - proxy-owned wrapper toplevel, so host compositor borders and focus rings wrap - the VM rail and guest content together without copying guest buffers. -- `d2b-wayland-proxy --host-terminal` now launches child terminals with a - private runtime directory and relative `WAYLAND_DISPLAY`, while accepting - ACL-protected user runtime directories, so host terminals connect to the - intended single-use proxy socket. -- `d2b-wayland-proxy` treats `ENOTCONN` during nonblocking clipboard bridge - handoff as retryable backpressure, preserving pending FDs until the bridge - socket finishes connecting. -- Added host-integration coverage for the live `d2b-wayland-proxy` - AF_UNIX client-to-upstream relay path. -- Updated the Windows notification transitive dependency to remove the runtime - `quick-xml` advisory path, and documented a temporary build-time - `wayland-scanner` advisory exception until that code generator publishes a - fixed `quick-xml` release. -- Default proxy-drawn VM-name labels render in the wrapper rail so the VM - identity remains visible without overlaying guest buffers. -- `d2b-wayland-proxy` now preserves the last committed surface size after a - guest destroys the current buffer object, while still clearing decorations on - committed `attach(NULL)`. -- USBIP driver helper retries now treat transient `ETXTBSY` / "text file busy" - spawn failures as retryable instead of reporting the helper as missing. -- `d2b-wayland-proxy` now advertises its virtual clipboard manager even when - the host compositor omits `wl_data_device_manager`, and denies unstable - text-input v3 forwarding by default to avoid guest app crashes on invalid - seat-bound requests. Clipboard-boundary allow overrides now remain denied - and emit stable `W-*` diagnostics. -- Clipboard paste selection offers no longer send drag-and-drop action events - for normal selections, fixing GTK/Firefox startup through the proxy; `d2b-clipd` - now requests the exact pending paste MIME from the picker and installs bridge - sockets with deterministic peer-connect permissions. -- `d2b-clipd` now clears stale host-selection state when it observes its own - bridge-published VM selection, matches bridge send requests by source id - instead of MIME alone, and fulfills all queued Wayland paste FDs after one - picker selection so VM-to-host and VM-to-VM pastes cannot resolve to EOF after - the picker selects an item. -- Clipboard picker selection now publishes the selected entry as a fresh - d2b-owned host selection and triggers paste replay, while VM destination paste - requests open the picker first and serve the replayed transfer immediately - from the published selection instead of holding a Wayland transfer FD across - picker interaction. -- Clipboard bridge hardening now binds per-VM bridge sockets under a temporary - umask before starting background helper threads, uses anonymous memfds for - virtual-keyboard keymaps, closes bridge streams after partial refresh writes, - backs off failed nonblocking bridge connects, queues bridge handoffs across - transient send backpressure, bounds guest-controlled proxy diagnostic label - cardinality, and prunes stale host-backed virtual offers. -- VM clipboard history now aggregates all MIME variants for the same exact VM - source under an injective bridge entry id, and the bridge-published selection - echo guard no longer depends on a time window. -- `d2b-clip-debug` and picker Wayland polling now process readable events - before hangup/error handling so final Wayland events are not dropped on - disconnect. -- The flake's `d2b-clipd` package now installs only the daemon binary, keeping - diagnostic probe binaries out of the daemon package closure. -- `d2b-clipd` now emits an accurate user-visible reason when virtual-keyboard - paste replay fails, avoids high-cardinality `key=value` fields in routine - bridge logs, and sends bridge refreshes with fail-closed backpressure handling - on newly accepted proxy streams. -- Clipboard audit and metric queues now flush metadata-only events instead of - silently discarding them, and the clipboard user service escapes systemd - specifiers while rejecting dot-segment bridge roots. -- Clipboard replay now records d2b-owned data-control source ids before - flushing Wayland events, so VM-origin copies are not misclassified as unknown - host paste requests, and suppresses source-VM selection echoes so copying - inside a VM does not open a spurious picker or create empty host entries. -- VM-origin copies now stay inside d2b's bridge/history state until an explicit - host or VM paste request opens the picker, so copy operations no longer publish - a host data-control discovery source that applications can probe. -- Clipboard discovery sources now suppress their own compositor selection echoes - for every focused destination, preventing copy-time host entries and ensuring - the picker opens only from the later paste request. -- Clipboard paste requests into a VM now direct-serve only the one-shot - user-selected replay publication, so later VM-destination pastes open the - picker instead of silently reusing stale published selection state. - - -- The console drain path is now treated as a long-lived daemon-internal tokio - task rather than a broker-spawned runner or one-shot readiness probe. -- `d2b console` dispatch: launcher peers can no longer access another user's VM - console session. The per-session owner UID is now tracked at `Attach` time; - `ReadOutput`, `WriteStdin`, `Resize`, `Wait`, and `Close` reject non-admin - peers whose UID does not match the session owner (`AuthzNotAdmin`). -- QEMU console chardev now uses `-chardev socket,id=con0,fd=N` instead of the - generic `-chardev fd` backend for correct socketpair semantics. -- `d2b console` FSM: bytes preceding the Ctrl-] detach character in a stdin - chunk are now forwarded to the VM before closing. Stdin buffer increased from - 256 to 4096 bytes. -- `d2b console` prints an operator hint when connected to a qemu-media VM - noting that the serial console may appear blank until the guest writes to - `/dev/ttyS0`. -- `d2bd` audio dispatch now calls guestd `AudioSet` RPCs for Cloud Hypervisor - NixOS VMs instead of statically defaulting to `HostOnly`. `combined_audio_applied` - returns `HostAndGuest` when both host and guest succeed, `HostOnly` when only - host applies, `GuestOnly` for ACA sandboxes, and `Unsupported` on full failure. - qemu-media VMs never call guestd; ACA VMs fail closed when guestd is - unreachable. -- `wpctl` subprocesses in guestd now drop to `workload_uid` before exec and set - both `PIPEWIRE_RUNTIME_DIR` and `XDG_RUNTIME_DIR` to `/run/user/`, so - WirePlumber locates the correct per-user socket. In d2bd the host PipeWire uid - is derived from `metadata(pipewire_runtime_dir).uid()` and passed via - `CommandExt::uid()` without shell string construction. -- `wpctl` subprocess failures in guestd now capture bounded sanitized stderr for - operator diagnostics; d2bd host-side failures log only static messages so - PipeWire node identifiers, paths, and volume values do not leak. -- OFD lock unlock now uses `F_OFD_SETLK` (non-blocking release) instead of the - incorrect `F_OFD_SETLKW` (blocking wait), which is semantically wrong for a lock - release path. -- Audio lock file opens now use `OpenOptions::create(true).write(true)` instead of - `custom_flags(libc::O_CREAT)`, which previously required undocumented write - permission to function correctly. -- `d2b audio` CLI is fully implemented: `status`, `mic on|off`, `speaker on|off`, - and `off` subcommands send typed `AudioOp` requests to the daemon public socket - and render results as human text or `--json`. `d2b audio status --json` emits - `AudioStatusResult` JSON for d2b-wlcontrol consumers. -- Static USBIP declarations now reconcile on strict VM start after a host reboot - even when volatile `/run/d2b/locks/usbip/*` claim files are gone. The - daemon replays declared per-VM bind intents through the existing broker policy - and OFD-lock path, while VM stop cleanup still touches only same-owner - persisted claims. No-wait/autostarted VMs schedule a bounded background - reconciliation worker that aborts if the VM runner is no longer supervised. -- `d2b host shutdown-hook --apply` no longer fails with `authz-not-admin` - on every VM: the new `HostShutdown` role permits `vmStop` from the guarded - `ExecStop` path (uid=0) while preserving the daemon-restart continuation - contract (`KillMode=process` + `Restart=on-failure`). -- Wayland-proxy VM start no longer fails with `runner-exited:wayland-proxy` - after a fresh login when `/run/user/` is `0700`: the broker now grants - a traverse ACL on the runtime directory immediately before proxy spawn. -- Daemon startup no longer emits spurious `kernel-module-check: fatal misses` - for built-in virtio modules on hosts with `=y` kernel config; the - `D2B_SKIP_KERNEL_MODULE_CHECK` workaround is no longer needed for these - hosts. -- qemu-media host activation now repairs the `/run` ACL mask for the - qemu-media runner UID, so a switched host cannot leave - `/run/d2b/vms/` with `mask::r-x` and prevent QEMU from creating its QMP - socket before boot-media auto-enrollment runs. - -- Live guest activation timeouts are now configurable globally via - `d2b.daemon.lifecycle.liveActivation.timeoutSeconds` and per VM via - `d2b.vms..lifecycle.liveActivation.timeoutSeconds`, allowing - identity-bound guests to wait longer for operator-mediated user-session flows - such as Entra/Himmelblau hello/PIN. -- `d2bd` now publishes a daemon-side public status read model for unfiltered - list/status requests, including read-model generation and source-fingerprint - metadata for wlcontrol and other fast-refresh clients. -- **USB explicit attach** (`d2b usb attach --apply`): - `d2b usb attach` now supports attaching any physically-present USB device - to a USB-capable VM without requiring static busid/vendor allowlists in the - NixOS bundle configuration. The new explicit path performs three fail-closed - pre-flight checks before any broker or firewall mutation: sysfs presence - (`/sys/bus/usb/devices//idVendor`), USB-capable gate - (`RuntimeCapabilityGate::UsbHotplug`), and active claim exclusivity (OFD lock - under `/run/d2b/locks/usbip/`). When a static bundle intent exists - for the busid, the declared path is used (existing behavior preserved). When no - declared intent is found, the explicit path dispatches two new broker ops — - `UsbipExplicitBind` and `UsbipExplicitFirewallRule` — which perform per-device - backend ACL grant (without allowlist validation), scoped nftables carveout - install preserving all active declared and explicit carveouts, and compensating - rollback on any failure. New typed errors `UsbipBusidNotPresent` (exit 67) and - `UsbipExplicitClaimConflict` (exit 67) surface actionable operator guidance for - pre-flight rejections. -- `UsbipClaimSource` enum in `d2b-contracts` models whether an active daemon - USB claim originates from a static bundle declaration (`Declared { firewall_ref, - bind_ref }`) or an explicit present-busid attach (`Explicit`). -- `UsbipDaemonClaimRecord` DTO in `d2b-contracts` captures the in-process - daemon representation of an active busid claim including VM, env, proxy port, - source, and the OFD lock path. -- `build_usbip_explicit_plan` in the daemon USB state machine builds a per-busid - bring-up plan without requiring a bundle resolver or pre-declared intents. -- `UsbipExplicitBind` and `UsbipExplicitFirewallRule` broker wire ops carry raw - busid, vm, env, and per-env uplink IPs for the per-device backend model; - validated by the same busid shape validator as the declared path. -- 38 new focused Rust tests: 8 focused unit tests in `usbip_state_machine`, 15 - contract tests in `usbip_explicit_attach_contract` covering explicit plan - shape, claim source enum, lock path derivation, broker op round-trips, - deny-unknown-fields, per-device backend model policy, firewall env scope, sysfs - presence pre-flight, and codebase policy gates; 2 audit roundtrip tests in the - broker for the new `UsbipExplicitBind` and `UsbipExplicitFirewallRule` - `OperationFields` variants; 2 JSON-schema contract tests in - `usb_json_contract`; and 3 network-scoping contract tests in - `usb_network_scoping`. - -- `d2b switch` now threads the configured live activation timeout into - guest-control and includes identity-flow recovery guidance when guest - activation times out. -- Successful activation commits now publish split store-view `state/current` and - `meta/current` pointers in addition to the legacy activation marker, keeping - daemon StoreSync metadata aligned after live switches. -- Public status/list read-model snapshots now invalidate when runner pidfd state - changes, preventing cached lifecycle state from surviving VM start/stop - transitions. -- USBIP bind now uses the same bounded isolated driver helper path as unbind, so - a slow or stuck kernel driver bind cannot pin the broker control path - indefinitely. -- `d2b usb detach --apply` now reaches the broker - `UsbipUnbind` cleanup path instead of stopping at a hardcoded ambiguous-flow - refusal, so stale USBIP host claims can be released and subsequent attaches can - recover without raw `usbip` commands. -- `d2bd.service` now relies on declarative tmpfiles ACLs for `/run/d2b` - instead of an imperative root `ExecStartPre`; the tmpfiles rules keep the ACL - mask writable for the daemon while the `d2b` operator group remains - narrowed to traversal by the explicit group ACL. -- Host activation now preserves the `/run/d2b` ACL mask as `rwx` when - reasserting runtime directory posture, preventing switch-time activation from - clipping the `d2bd` daemon's write access to `r-x`. -- USB probe/status no longer marks a declared USBIP device `degraded` with - `probe-incomplete` after guest-control confirms that the busid is already - imported in the guest. +- Fixed unsafe-local graphical launches by supervising the proxy and configured + app in one verified user scope with private runtime paths, typed readiness, + first-client gating, bounded socket names, exact child reaping, canonical + realm colors, and no direct-compositor fallback. +- Fixed picker/clipboard protocol compatibility, focus restoration, proxied + virtual-keyboard replay, endpoint payload handling, cancellation, and + backpressure while preserving picker-only transfer authority. +- Fixed USBIP claim, bind/unbind, firewall, ACL rollback, restart reconciliation, + and revocation races; hardened security-key UHID framing, socket lifetime, and + udev behavior. +- Fixed console and audio session ownership, QMP chardev handling, PipeWire + targeting, and provider dispatch. +- Fixed daemon restart and host-switch continuity: `d2bd.service` reports ready + only after socket bind and runner adoption, while running VMs remain alive. +- Fixed guest exec and GUI launch establishment timeouts under heavy virtiofs + load. +- Fixed runtime, state, guest-control, observability, and per-role ACL ordering + so daemon and runner access survives reboot and host switches without local + overrides. +- Fixed net-VM cold-boot host preparation, qemu-media synchronization contract + rendering, broker child reaping, and existing disk-image validation. +- Fixed realm controller and workload identity JSON field names and nesting to + match their Rust DTOs. +- Fixed realm workload CLI routing, bare-VM migration hints, persistent-shell + owner framing, and guest journal sizing. ### Removed -- Removed obsolete references to the legacy Wayland proxy from - documentation and comments. - - -- Removed the public `d2b usb enroll` CLI and daemon/public-wire verb. - QEMU-media USB boot-drive remediation now points operators to - `qemuMedia.source.usbSelector.byIdName` and `d2b usb probe`, while - running qemu-media VMs still use QMP-backed `d2b usb attach` / - `detach` hotplug. - -- CLI/docs: added the missing `vm display` authorization-matrix row so the - declared display-session management command is covered by the generated - privileges contract. -- Tests: narrowed the host realm-relay dependency policy to actual relay/runtime - crates so the neutral constellation provider trait crate can remain in - host-side runtime-provider code. -- Documentation: updated the public manifest schema to include the runtime - operation capability, autostart policy, and service summary fields already - emitted by the manifest. -- Tests: the per-example flake gate now evaluates scratch copies with the - `d2b` lock target rewritten to the current `git+file` checkout, - preserving each example's lock graph and external pins while avoiding mutable - `path:../..` lock failures. -- Tests: the broker reap-health zombie canary now accepts the transient - uninterruptible-sleep proc state seen on busy CI runners before child teardown. -- CLI/daemon: qemu-media USB attach/detach `--apply --json` now emits a - JSON success envelope, and qemu-media list/status service capabilities no - longer advertise `virtiofsd`. -- Proof tests: the Cloud Hypervisor connect proof now binds fixture - sockets under a short relative target path so long worktree paths do - not exceed Unix domain socket pathname limits. -- NixOS: all host-tool selectors, including the activation helper and - Wayland filter process descriptor, now honor - `d2b.site.usePrebuiltHostTools = false`. Cross-system flake eval - and host-integration fixtures use source-built mode so pre-release - daemon/config schema changes are validated together where release - prebuilts are unavailable or stale. The qemu-media nix-unit cases now - keep artifact coverage on aarch64 while treating qemu-media's - x86-only platform assertion as expected. -- Tests: d2b CLI unit-test sockets now use short paths under the - system temporary directory and exec mock-daemon tests use real - manifests, avoiding host/worktree-dependent `ENAMETOOLONG` failures - and pre-connect mock-server hangs. -- Tests: d2bd public status tests now load temp bundle artifacts - through the current-user test verification policy with production-like - `0640` modes, preserving bundle tamper checks while keeping local - unit tests runnable as an unprivileged user. -- Tests: the privileges-matrix policy test now ignores CLI grouping - forms such as `realm` instead of treating them as broker/public - operation ids, and the exec-runner natural-exit test waits for the - bounded drain thread to publish stream EOF. -- Tests: the source-filter policy now treats `gateway-vm.nix` as a - consumer of centralized host-tool packages rather than a Rust package - source builder, while still rejecting ad-hoc source filters there. -- Tests: updated the Cloud Hypervisor runner-shape contract snapshot to - include the guest-control `d2b-gctl` virtiofs share emitted by the - current bundle. -- Tests: refreshed the USB attach/detach dry-run goldens to describe the - guestd import/detach path instead of the retired SSH fallback. -- Tests: the Rust gate now runs the broker default/layer1/fake-backends - feature passes serially by default so SIGCHLD reaper tests do not - contend over process-global signal state; `D2B_PARALLEL_BROKER=1` - remains available for local timing experiments. -- Tests: the stub-no-socket gate now checks for unexpected runtime - entries instead of live `/run/d2b` directory mtime changes, so - unrelated daemon activity on a shared host does not fail the gate. -- CI: flake-check shards now retry through `nix-instantiate` when the - hosted-runner `nix eval` process segfaults while instantiating a - single check derivation. -- CI: the `eval-with-observability` flake check now validates the - observability example's assertions, manifest toggle, stack VM, and - workload opt-in without forcing the full system toplevel derivation, - avoiding a hosted-runner Nix evaluator crash. -- ADR 0032 ACA display: the daemon-owned verified Relay listener now - survives the synchronous `gatewayDisplay` request runtime, so Waypipe - sessions remain connected after `d2b vm exec ` returns - and the forwarded Wayland app can stay visible on the host compositor. -- USBIP attach/detach now routes guest-side import cleanup through guestd - over authenticated guest-control, removing the CLI's SSH fallback and - preventing stale guest imports from blocking reattach after daemon restarts. -- `qemu-media` VM start no longer runs NixOS-only state ownership and - SSH host-key preflights, allowing externally booted media VMs to use - the qemu-media runner-owned state directory prepared by host - reconciliation. Physical USB boot media now attaches through QMP's - `host_device` block driver instead of the regular-file-only `file` - driver. qemu-media runners now pass explicit QEMU memory and vCPU - sizing, defaulting to 4 GiB and 2 vCPUs instead of QEMU's tiny - built-in RAM default. qemu-media guest RAM now uses a non-dumpable, - non-KSM memory backend by default, and `qemuMedia.security.lockMemory` - can fail closed with QEMU `mem-lock` when the host cannot keep guest RAM - out of swap. -- `qemu-media` restart now cleans up leftover dependency sidecars when - the primary qemu-media runner has exited, so a stale Wayland proxy - pidfd from a failed boot no longer blocks the next start. -- `qemu-media` lockMemory now gives QEMU a bounded proportional memlock - allowance (guest RAM plus the larger of 2 GiB or 25% headroom), avoiding - runtime `mlockall` failures for larger media VMs without broadening the - broker service's memlock limit, and fails before spawn when host - `MemAvailable` cannot satisfy guest RAM plus fixed QEMU overhead. -- `qemu-media` USB detach now reconciles QMP state idempotently when the - delete event is missed or the media nodes are already absent, and can - detach a uniquely attached same-vendor/product ref after a runtime USB - selector moves without requiring manual re-enrollment first. -- Documentation: sanitized ADR 0032's ACA/Relay live-proof record so the - architectural validation summary remains without publishing live sandbox, - disk-image, command-output, or compositor-window identifiers. - -- Extended generated `sync.json` coverage for daemon lock roots, per-VM - lifecycle locks, store-view sync locks, and USBIP lock claims without - changing live lock implementations. -- Added contract-test policy coverage for host-mutable path/lock surfaces, - requiring storage/sync contract rows, opaque broker IPC inputs, and a single - repair owner for new mutable host state. -- ADR 0035 Wave 5 added a contract-test policy that classifies every - `ProcessRole` and requires runner roles to carry Rust argv-builder plus - runner matrix/contract coverage before new roles land. -- ADR 0035 Wave 4 decomposed CLI read-model/rendering helpers and daemon - admission helpers into focused Rust modules while preserving output and - authorization contracts. -- ADR 0035 Wave 3 moved stable CLI JSON output DTOs into the shared IPC contract - crate, keeping CLI presentation and schema generation on the same strict - deserialization contract. -- ADR 0035 Wave 2 normalized internal NixOS VM/env indexing for network and host - consumers, preserving network isolation semantics while making per-env USBIP - backend ports an explicit generated host contract. -- ADR 0035 Wave 1 consolidated internal NixOS bundle artifact definitions behind - a typed central model while preserving generated artifact bytes and private - install metadata. -- ADR 0035 Wave 0 internal cleanup added deterministic inventory tooling and - `compat-ADR` bridge-key policy coverage, removed caller-free test/Make - compatibility aliases, and dropped stale retired bash-CLI option comments. -- USBIP architecture notes/tests now pin the per-env proxy as a generic L4 - forwarder, preserving backend/proxy sidecars during single-busid teardown and - encoding optimistic backend/export refresh, firewall-before-flow-kill - revocation ordering, TCP-vs-UDP targeted cleanup rules, fail-closed - revocation when a selected busid stream cannot be isolated, and explicit - bounded-drain/exclusive rebind requirements before any same-env stream bounce. -- USBIP restart reconciliation now has a daemon-internal physical topology - identity model that compares allowed VID/PID with sysfs bus/port topology - instead of trusting serial-like descriptors, while keeping raw topology out of - public/status projections. -- USB reconciliation now has closed degraded-reason/status primitives with - redacted public/event projections, bounded telemetry labels, remediation - mapping, bounded reconcile correlation IDs, dedupe/rate-limit buckets - partitioned only by closed event type and bounded source projection, strict - `other` fallback for capped buckets/static metric labels, and suppressed-event - summaries with exact dropped counts and windows for later observability - wiring. -- VM start/stop USB reconciliation now threads the same bounded reconcile - correlation ID through USB broker requests as broker audit trace context - without adding it to metric labels. -- USB broker audit records now keep a privileged forensics projection for - USBIP binds with normalized vendor/product IDs, serial-presence only by - default, HMAC-SHA256 serial correlation backed by broker-owned root-only key - material, current/previous-key correlation during rotation windows, and a - scrubbed rotation-window log/audit shape for observability. -- Guest-control now exposes authenticated, side-effect-free USBIP status/list - observation backed by the configured guest `usbip` path with closed timeout and - parser error mapping. -- Persistent shell CLI routing now sends gateway-backed `list`, `detach`, and - `kill` management forms through the configured realm gateway over the typed - guest-control exec path, while interactive gateway attach fails closed until - semantic ADR 0039 attach support lands. -- Constellation persistent shell routing: extended remote full-host routing and - provider trait seams so ADR 0039 `Shell*` operations require - `persistent-shell`, target workloads explicitly, preserve mutating - idempotency semantics, round-trip through the protobuf codec, and stay - separate from provider exec/durable execution. -- Constellation persistent shell runtime alignment: guestd now gates shell - capability advertisement on the usable exec/workload-user/helper/shpool - runtime, reports configured shell limits, uses opaque shell ids, exposes - core DTO adapters for shell summaries/events, and documents the fail-closed - provider guestd bootstrap contract. -- Constellation persistent shell contracts: promoted ADR 0039's reserved - `persistent-shell` capability, `Shell*` operation kinds, shell-authorized PTY - stream kind, and bounded shell DTOs into the generated core schema contract. -- Constellation persistent shell routing: added ADR 0039 and reference stubs - reserving the provider/remote contract for ADR 0038 shells, including the - guestd-compatible provider-agent requirement and the rule that - `executeShellCommand` is not a persistent-shell channel. -- Persistent shell daemon: started d2bd-side shell control-plane routing with - admin-gated management operations, guest-control capability checks, shell - response framing, and attached-owner terminal proxying scaffolding. -- Persistent shell runtime: started guestd-side shell session runtime scaffolding - with staged Nix/PAM/service wiring, in-memory admission/idempotency tests, and - fail-closed guest-control shell capability handling. -- Persistent shell contracts: added staged default-off shell option, manifest, - public/guest-control wire, and authz contract scaffolding for later runtime - wiring. -- Test orchestration: added a central Layer-1 job manifest that drives local - `make check`, renders the PR workflow, and adds a stable `check` CI rollup so - branch protection can require one context while generated job names remain - implementation details. -- Guest-control internals: started extracting the shared terminal substrate used - by interactive exec, with compatibility DTO conversions and redaction tests for - future interactive-terminal reuse. -- Test and contract hygiene: synced the existing operation-inspection - authorization/golden contracts and shortened Unix-socket paths in CLI, - daemon-access, broker QMP, and broker integration tests so long worktree paths - do not exceed platform socket limits. -- Developer tooling: added a standalone static guest shell helper workspace, - libshpool pin, initial validation/management-output scaffolding, and explicit - Rust/static supply-chain gate wiring for upcoming guest-control terminal work. +- Removed `d2b usb enroll`; qemu-media USB boot selection now uses + `qemuMedia.source.usbSelector.byIdName` and `d2b usb probe`. + +### Security + +- Kept realm relay/provider credentials, remote registries, and realm audit out + of the host daemon, broker, and bundle; relay identity is never mapped to + local lifecycle authorization. +- Enforced same-UID unsafe-local helper registration, private proxy/readiness + sockets, immutable proxy binaries, operation fingerprint parity, fail-closed + group eligibility, and explicit logout/login after new group assignment. +- Enforced picker/clipd-only cross-realm clipboard transfer, strict bounded and + redacted protocol metadata, destination-focus verification, and proxy-safe + synthetic paste ordering. +- Tightened broker, runtime, qemu-media, observability, and per-role path + ownership so diagnostics remain redacted and mutable host state stays within + its declared authority. ## [1.3.1] - 2026-06-18 diff --git a/README.md b/README.md index 8d6cde518..75c06b35c 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Other entry points: see [Where to start](#where-to-start) below for a table of the doc-friendly example aliases (`personal-dev`, `graphics-workstation`, `multi-env`) plus the manual integration path. For reconnectable interactive shells, see -[Use persistent guest shells](./docs/how-to/use-persistent-shells.md). +[Use persistent shells](./docs/how-to/use-persistent-shells.md). For Waybar/terminal launcher integration around those shells, see [Configure desktop terminal integration](./docs/how-to/configure-desktop-terminal-integration.md). For the staged generic host-user provider contract, see diff --git a/docs/adr/0045-provider-and-transport-framework.md b/docs/adr/0045-provider-and-transport-framework.md new file mode 100644 index 000000000..99163ff4d --- /dev/null +++ b/docs/adr/0045-provider-and-transport-framework.md @@ -0,0 +1,1729 @@ +# ADR 0045: Provider and transport framework + +- Status: Proposed +- Date: 2026-07-10 +- Refines: [ADR 0035](0035-efficiency-and-simplification-roadmap.md) + (provider naming and workspace simplification), [ADR 0043](0043-realm-native-control-plane.md) + (realm-native control plane), [ADR 0044](0044-unsafe-local-runtime-provider.md) + (unsafe-local runtime provider) +- Related: [ADR 0010](0010-wire-protocol-and-typed-errors.md) + (wire protocol and typed errors), [ADR 0028](0028-guest-control-plane-over-vsock.md) + (guest control plane over virtio-vsock), + [ADR 0034](0034-storage-lifecycle-restart-and-synchronization.md) + (storage lifecycle, restart adoption, and synchronization), + [ADR 0037](0037-local-hypervisor-runtime-seam.md) + (local hypervisor runtime seam) + +## Context + +ADR 0043 gives every realm one controller generation and allows that controller +to run on the host, in a gateway VM, on a cloud full host, or in a constrained +provider environment. Its current placement vocabulary describes where the +controller runs, but it does not fully model the controller as a workload +managed by another realm. + +That leaves several ambiguities: + +- a gateway VM looks like a special realm object even though it is a VM with a + controller role; +- the controller VM can appear to be owned by the realm it must bring into + existence, creating a lifecycle cycle; +- `provider` can mean a workload runtime, infrastructure provisioner, realm + controller host, transport, protocol codec, node client, or daemon-access + adapter; +- the existing `d2b.realms..providers` records do not distinguish those + responsibilities; +- `d2b-realm-provider` exports provider authorities beside + `TransportProvider`, `ProtocolCodec`, `StreamMux`, daemon-access, and node + client traits even though those seams have different ownership; +- Azure Relay already implements the generic `TransportProvider`, while the + narrower `RelayProvider` has no production implementation; +- realm relay configuration says how a realm is reachable, but not how Unix + streams, vsock, direct TLS/QUIC, and Relay share one transport contract; +- public daemon, realm-peer, and guest-control protocols independently + implement framing, version negotiation, authentication, capability exchange, + typed errors, deadlines, and request correlation; +- ADR 0043 authorizes direct transport shortcuts only over native underlay + reachability, even when every participant already uses one shared relay + fabric. + +The concrete motivating case is a work realm backed by Azure: + +1. Azure Resource Manager and Azure Relay authentication require Microsoft + Entra credentials acquired inside an Entra-joined, Intune-managed local + Cloud Hypervisor VM. +2. Interactive authentication requires a physical YubiKey. +3. The same YubiKey is also used for browser authentication in a work desktop + VM and for delegated developer authentication in another work VM. +4. A remote Azure VM may run the full work realm controller. +5. Code running locally or remotely needs access to Azure resources without + receiving the controller's ARM or Relay token cache. +6. Nested realms may all use the same Azure Relay fabric. Relaying every byte + through each intermediate controller would add latency and failure domains + without adding policy value. + +The existing security invariants remain binding: + +- host `d2bd` and `d2b-priv-broker` hold no realm provider, Relay, or Entra + credentials; +- relay authentication establishes reachability only and never maps to local + `Admin`, broker authority, or realm identity; +- one remote, work, or provider realm has one credential boundary by default; +- a realm controller cannot own or repair the infrastructure on which that + controller itself runs; +- parent and ancestor policy must constrain every cross-realm operation even + when bytes do not traverse those controllers; +- the same physical authenticator may serve multiple isolated VMs, but token + caches, refresh tokens, and managed identities are never shared between + them. + +## Decision + +### Providers and workload roles are separate concepts + +D2b will use the following vocabulary: + +| Concept | Responsibility | +| --- | --- | +| Workload runtime provider | Starts, stops, inspects, and executes one workload. | +| Infrastructure provider | Provisions or adopts infrastructure on which workloads, including controller workloads, run. | +| Transport provider | Produces connected bidirectional byte sessions over Unix streams, vsock, direct network transports, or relay fabrics. | +| Component session | Adds fixed framing, protocol negotiation, authentication, encryption, peer identity, limits, and liveness above a byte transport. | +| Service protocol | Defines typed daemon, realm, guest, or provider APIs above an authenticated component session. | +| Workload role | Declares an authority-bearing function performed by a workload, such as running a realm controller. | + +The unqualified term `realm provider` is too ambiguous for a public schema and +must not name a new catch-all interface. An adapter may implement more than one +provider trait, but each binding names the trait being used. For example: + +- `azure-vm` can provision a remote VM as an infrastructure provider and can + supervise that VM as a workload runtime provider; +- `azure-relay` is a transport-provider implementation; +- a VM created by `azure-vm` may carry the `realmController` workload role; +- Cloud Hypervisor, QEMU, Bubblewrap, and Minijail are workload runtime + providers, not realm controllers by themselves. + +Provider identifiers describe adapters, not security claims. Isolation, +execution identity, environment source, display routing, networking, device +access, and persistence remain typed execution-posture fields. + +### Provider crates use a type-first sortable namespace + +Every provider implementation crate uses this grammar: + +```text +d2b-provider-- +``` + +The provider type immediately follows `d2b-provider-` so workspace listings, +dependency graphs, generated inventories, and code search group all +implementations of the same authority together. + +The selected provider crate namespaces are: + +| Crate prefix | Standard interface | Responsibility | +| --- | --- | --- | +| `d2b-contracts` | Contract crate | Canonical serialized provider descriptors, operation contexts, capability DTOs, plans, observed-state envelopes, stable errors, and generated schemas. | +| `d2b-provider` | Interface crate | In-process async Rust provider traits, typed registries, and runtime error wrappers over `d2b-contracts` types. Contains no duplicate contract DTO, provider SDK, or implementation. | +| `d2b-provider-runtime-` | `RuntimeProvider` | Plans, starts, stops, adopts, and inspects workloads. | +| `d2b-provider-infrastructure-` | `InfrastructureProvider` | Provisions, adopts, inspects, and deletes infrastructure that hosts workloads or realm controllers. | +| `d2b-provider-transport-` | `TransportProvider` | Connects or accepts bounded bidirectional byte sessions and advertises purpose, rendezvous, reconnect, and revocation capabilities. | +| `d2b-provider-substrate-` | `SubstrateProvider` | Checks and prepares a full-host OS substrate such as NixOS or generic Linux. | +| `d2b-provider-credential-` | `CredentialProvider` | Acquires or reports credentials inside the configured credential-owning workload without exporting them to the host. | +| `d2b-provider-display-` | `DisplayProvider` | Implements a reusable display/session adapter independent of one runtime backend. | +| `d2b-provider-testkit` | Conformance harness | Provider mocks, deterministic fixtures, and reusable conformance suites. It is not linked into production binaries. | + +The words after the provider type name the canonical implementation, not a +configured instance. The provider type is not repeated in that segment: + +- `d2b-provider-runtime-cloud-hypervisor`; +- `d2b-provider-runtime-qemu-media`; +- `d2b-provider-runtime-systemd-user`; +- `d2b-provider-runtime-bubblewrap`; +- `d2b-provider-runtime-azure-container-apps`; +- `d2b-provider-infrastructure-azure-vm`; +- `d2b-provider-transport-azure-relay`; +- `d2b-provider-transport-unix-stream`; +- `d2b-provider-transport-cloud-hypervisor-vsock`; +- `d2b-provider-credential-entra`; +- `d2b-provider-substrate-nixos`; +- `d2b-provider-display-wayland`. + +For example, `d2b-provider-transport-azure-relay` has +`ProviderType::Transport` and implementation id `azure-relay`; its public +provider kind remains `azure-relay`. A configured deployment may then assign +instance ids such as `work-transport` or `payments-transport` without +changing the crate or implementation id. + +Abbreviated or axis-free crate names such as `d2b-provider-aca`, +`d2b-provider-relay`, `d2b-provider-azure`, and +`d2b-host-providers` are forbidden after the cutover. A vendor with multiple +provider types gets one crate per authority boundary. For example, ordinary +Azure VM workload lifecycle and Azure VM infrastructure provisioning sort +separately: + +```text +d2b-provider-runtime-azure-vm +d2b-provider-infrastructure-azure-vm +``` + +Vendor SDK plumbing shared by those implementations stays in a private module +of one implementation until two real consumers justify a narrow shared crate. +If a shared crate is required, its name must describe the specific SDK +capability; `common`, `util`, `manager`, and an axis-free +`d2b-provider-azure` remain disallowed. + +This type-first grammar supersedes ADR 0035's examples +`d2b-provider-hypervisor-`, `d2b-provider-`, and +`d2b-constellation-transport-`. Hypervisors are runtime providers and +relay, Unix-stream, and vsock transports sort under the common transport axis. + +### Every provider implements a standard base interface + +Existing `d2b-contracts` remains the canonical owner of provider data that is +serialized, persisted, generated from Nix, sent over a wire, or shared by +independently compiled components. Provider contracts live under a focused +module such as `d2b_contracts::provider`. + +That module owns at least: + +- `ProviderDescriptor`, `ProviderType`, and `ProviderHealth`; +- the serializable `ProviderOperationContext`; +- primary and optional capability descriptors; +- provider plans, opaque handles, and observed-state envelopes that cross a + crate, process, persistence, or wire boundary; +- stable provider error kinds, retry hints, and redacted error envelopes; +- schema versions and generated JSON/protobuf schemas where applicable. + +Provider implementations must import these canonical types. They must not +declare provider-local copies with the same semantic fields. + +The interface crate is named `d2b-provider`, not `d2b-provider-api`. This +matches the repository convention: `d2b-contracts` owns serialized contracts, +`*-core` crates own pure models, and a bare domain crate names the trait and +composition boundary. The project does not otherwise use an `-api` crate suffix +for this kind of in-process interface. + +`d2b-provider` replaces only the trait, registry, and in-process runtime +portion of `d2b-realm-provider`. It depends inward on `d2b-contracts` plus the +minimum async/I/O traits needed by the interfaces. It may define non-serialized +trait-object adapters, `ProviderResult`, and a runtime `ProviderError` wrapper, +but that wrapper exposes the stable error envelope from `d2b-contracts`. + +`d2b-provider` must not depend on: + +- `d2bd`, the privileged broker, or host mutation implementations; +- a cloud SDK, HTTP client, TLS implementation, or concrete transport; +- a protocol codec; +- a provider implementation crate; +- test mocks or live-provider fixtures. + +`d2b-contracts` must not depend on `d2b-provider` or any provider +implementation. If the current monolithic contract crate would otherwise force +unrelated guest protobuf or codec dependencies into every provider build, those +dependencies must be feature-gated or split behind contract-only modules rather +than copying provider DTOs into the interface crate. + +Every registered provider implements the common base interface: + +```rust +#[async_trait] +pub trait Provider: Send + Sync { + fn descriptor(&self) -> ProviderDescriptor; + async fn health(&self) -> ProviderResult; +} +``` + +The base and specialized provider traits remain `#[async_trait]`, `Send`, and +`Sync` and are deliberately object-safe for `Arc` registries. +They do not expose generic methods, return `Self`, or require implementation +associated types at the registry boundary. Native async traits/RPITIT are not +used for these dynamic registries until Rust supports the required dyn +semantics without provider-specific wrappers. + +The canonical `d2b_contracts::provider::ProviderDescriptor` is bounded, +non-secret data containing: + +- the configured `ProviderId`; +- one closed `ProviderType`; +- the canonical implementation id; +- the provider API version; +- positive capability assertions; +- the implementation's configuration-schema fingerprint. + +It never contains credentials, token subjects, endpoints, resource ids, command +arguments, host paths, or provider response bodies. + +The closed primary provider types are: + +```rust +pub enum ProviderType { + Runtime, + Infrastructure, + Transport, + Substrate, + Credential, + Display, +} +``` + +Each configured provider instance has exactly one primary provider type and is +registered in the matching typed registry. Implementations may additionally +implement optional capability interfaces such as persistent shell, durable +execution, console, audio, guest-control endpoint, or observability export. +Those capabilities do not change the provider's primary authority type. + +Optional capability dispatch uses parallel capability-specific registries keyed +by the same `ProviderId`. Rust trait-object downcasting or peer-trait casting is +not part of the design. Registry construction verifies both directions: + +- every advertised optional capability has an implementation registered in the + matching capability registry; +- every registered capability implementation is advertised by the provider + descriptor. + +A mismatch fails provider registration before the provider can receive an +operation. + +The specialized interfaces extend `Provider`: + +| Interface | Required semantic surface | +| --- | --- | +| `RuntimeProvider` | Capability description; plan; idempotent ensure/start; stop; inspect/adopt; destroy when the runtime owns durable workload state. | +| `InfrastructureProvider` | Capability description; plan; apply; adopt; inspect; bootstrap binding; destroy. | +| `TransportProvider` | Capability description; connect/listen; return a bounded byte session; issue and revoke a binding when supported; inspect transport health. | +| `SubstrateProvider` | Capability description; check; plan remediation; apply only through the authorized substrate owner. | +| `CredentialProvider` | Non-secret status; interaction requirement; acquire or refresh only for a co-located typed consumer; revoke. | +| `DisplayProvider` | Capability description; open and close an already-authorized display session. | + +`TransportProvider` is the common byte-carriage authority. Its capability +descriptor states: + +- accepted purposes (`realm-peer`, `workload-peer`, `daemon-access`, + `guest-control`, or `bootstrap`); +- whether it can connect, listen, or provide rendezvous; +- reconnect and liveness behavior; +- whether established sessions support active revocation; +- maximum session lifetime; +- whether kernel peer evidence is available. + +Unix-stream, native-vsock, Cloud-Hypervisor-vsock, direct-TLS/QUIC, loopback, +and Azure Relay implementations all return the same `TransportSession`. Relay +authentication and rendezvous remain Azure-Relay implementation details; they +never become d2b principal authentication. + +`d2b-provider` owns the live, non-serializable `ByteStream`, +`TransportSession`, and `TransportListener` runtime types because +`TransportProvider` returns them. `d2b-session` depends inward on +`d2b-provider` and consumes `TransportSession`; `d2b-provider` never depends on +`d2b-session`. Only serializable target, binding, descriptor, capability, and +status DTOs live in `d2b-contracts`. + +The existing local-only `RuntimeProvider` and provider-managed +`WorkloadProvider` split is retired. Local VMMs, host-user runtimes, container +sandboxes, provider-managed sandboxes, and remote VM runtimes implement one +`RuntimeProvider` lifecycle contract. Exec, persistent shell, display, console, +audio, and guest-control remain optional capability interfaces rather than +being folded into runtime lifecycle. + +### Provider and non-provider seams are explicit + +The current `d2b-realm-provider` surface mixes provider authorities with +protocol machinery. The cutover classifies them as follows: + +| Current interface | Selected treatment | +| --- | --- | +| `HostSubstrateProvider` | Rename to primary `SubstrateProvider`. | +| `RuntimeProvider` | Primary runtime provider. | +| `WorkloadProvider` | Fold into `RuntimeProvider`; exec becomes an optional capability. | +| `InfrastructureProvider` | Primary infrastructure provider. | +| `CredentialProvider` | Primary credential provider. | +| `DisplayProvider` | Primary display provider. | +| `TransportProvider` / `TransportListener` | Primary transport provider plus supporting listener type. | +| `RelayProvider` | Remove; relay rendezvous is a transport capability. | +| `DurableExecutionProvider` | Optional runtime capability. | +| `PersistentShellProvider` | Optional runtime capability. | +| `GuestControlEndpointProvider` | Rename to optional `GuestControlEndpointResolver`; it discovers an endpoint and is not a primary provider. | +| `ObservabilitySinkProvider` | Rename to `ObservabilitySink` until an independently configured external sink justifies a primary provider type. | +| `NodeProvider` | Rename to `NodeClient` or `NodeInventory`; node registration and workload listing are realm services, not provider authority. | +| `ProtocolCodec` | Move to the component-session/codec layer. | +| `StreamMux` | Move to the component-session/realm-router layer. | +| `DaemonAccessTransport` | Replace with daemon-access composition over `TransportProvider`. | +| `DaemonAccessApi` | Keep as a semantic daemon service contract. | + +Internal dependency-injection seams such as `NodeRunner`, `ShellBackend`, +`TerminalBackend`, `HostAudioController`, `ExecRuntime`, `LogStore`, +`TokenSource`, `CapabilitiesProvider`, `ShellEventSink`, `CgroupBackend`, +`NetlinkBackend`, and `ModprobeBackend` do not implement `Provider` and do not +appear in provider registries. They are local strategies owned by a daemon, +guest, broker, or test harness. + +Serialized runtime locality and driver values are descriptor dimensions, not +provider types. `local`, `cloud-hypervisor`, `crosvm`, and `qemu` become +`RuntimeLocality` and `RuntimeImplementationKind` fields rather than a second +Rust type also named `RuntimeProvider`. + +`d2b-contracts` defines a bounded, serializable `ProviderOperationContext` +containing: + +- the stable operation id and idempotency key; +- the already-authorized realm and workload/controller identity; +- the required capability; +- the wall-clock expiry used across process or wire boundaries; +- an opaque, non-secret trace id suitable for W3C-compatible correlation. + +`d2b-provider` defines a non-serializable `ProviderCallContext` that wraps the +contract DTO with the local monotonic deadline and cancellation signal. Tokio +tokens, channels, `Instant`, file descriptors, and other runtime state never +enter `d2b-contracts`. + +The provider does not reinterpret local users, authorize a principal, or choose +another realm. The realm controller performs authorization before dispatch. +The provider verifies that the call context matches its configured scope, +then performs only its typed action. + +All provider interfaces share these semantics: + +1. Unsupported behavior returns a typed capability denial. It never falls back + to SSH, a shell command, generic TCP, ambient developer credentials, or + another provider. +2. Mutations are idempotent by operation id and return typed observed state. +3. Plans and handles contain opaque provider refs, not raw credentials or + unbounded provider responses. +4. `Debug`, tracing, audit, and metrics redact credentials, endpoints, resource + ids, user identities, and provider payloads. +5. Timeouts, cancellation, retry classification, and degraded state are part of + the interface contract. +6. Restart adoption verifies provider identity and operation binding before + accepting observed state. +7. A provider implementation cannot call the broker directly. Host mutations + are re-originated by the owning daemon through typed broker operations. + +### Conformance is mandatory + +`d2b-provider-testkit` owns reusable conformance suites for every primary +provider interface. An implementation crate is not registered or advertised as +supported until it passes the suite for its primary type and every optional +capability it advertises. + +The suites prove at minimum: + +- descriptor type and implementation id match the crate axis; +- capability advertisement is positive and unsupported operations fail closed; +- operation ids make retries idempotent; +- cancellation and deadlines are bounded; +- inspect/adopt rejects identity or generation mismatch; +- no secret-shaped value appears in debug, error, audit, or metric output; +- handles and plans survive their documented serialization boundary; +- provider-specific errors map to stable `ProviderError` kinds and retry hints; +- a provider cannot widen the authorized realm, workload, operation, or + capability from `ProviderCallContext`; +- optional capability registry entries and descriptor claims match exactly. + +Cloud implementation crates keep live tests explicitly opt-in. Hermetic +conformance uses fake SDK clients and transports supplied by the implementation +crate, while shared mocks and assertions remain in `d2b-provider-testkit`. + +### Transport is a byte-stream boundary only + +`TransportProvider` returns a connected, reliable, ordered, bidirectional byte +session. It does not: + +- authenticate a d2b principal; +- negotiate a d2b service or schema; +- authorize an operation or stream; +- interpret API payloads; +- map Relay, TLS, vsock, or Unix identity to a daemon role; +- bridge one transport to another. + +`TransportTarget` becomes a typed provider reference, opaque endpoint reference, +and closed purpose rather than an unbounded endpoint string. Accepted sessions +carry: + +- transport provider id and implementation kind; +- opaque binding/session id; +- closed transport purpose; +- bounded local transport evidence when available; +- active-revocation handle when advertised; +- the connected byte stream. + +Transport evidence is not authorization. Unix `SO_PEERCRED`, a VMM process uid, +vsock CID, TLS certificate, managed identity, Relay SAS, and rendezvous id have +different meanings and are consumed only by the authenticator selected for the +session purpose. + +The generic transport boundary includes: + +- Unix streams used for direct daemon or realm-peer connections; +- native AF_VSOCK; +- Cloud Hypervisor's host-to-guest vsock adapter after its `CONNECT`/`OK` + handshake; +- loopback/local TCP conformance transports; +- direct TLS/QUIC/WebSocket transports; +- Azure Relay WebSocket rendezvous. + +The public daemon Unix listener, privileged broker socket, and unsafe-local +helper remain specialized IPC endpoints where seqpacket boundaries, +`SO_PEERCRED`, socket activation, `SCM_RIGHTS`, exact FD validation, or helper +generation semantics are load-bearing. Their local framing helpers may be +shared, but they do not implement `TransportProvider`. + +### One component-session protocol sits above transport + +D2b will add a standard component-session layer: + +```text +TransportSession + -> ComponentSession + fixed preface and framing + protocol/service negotiation + Noise or local mechanism authentication + encryption and replay protection + peer identity and channel binding + capabilities and effective limits + keepalive, deadline, and close semantics + -> typed service protocol +``` + +Serialized session contracts live in `d2b-contracts::session`. The in-process +state machine, authenticator traits, framing, and runtime contexts live in a +bare `d2b-session` crate. `d2b-session` depends inward on contracts and crypto +primitives; semantic daemon, realm, guest, and provider services depend on it. + +The bootstrap preface and handshake use one fixed canonical encoding. A peer +does not encode the handshake through the codec it is attempting to negotiate. +After authentication, the selected service may use protobuf or another +explicitly negotiated codec. + +Noise handshake and transport records use fixed 16-bit length framing and stay +within Noise's 65,535-byte message limit. ComponentSession fragments larger +ttrpc or named-stream frames into bounded encrypted records and reassembles +them only up to the negotiated d2b hard frame limit. Record sequence, fragment +count, and total plaintext length are authenticated; truncation, duplication, +reordering, or over-limit reassembly closes the session. + +The handshake exchanges and binds: + +- component-session protocol version; +- session purpose and endpoint roles; +- supported and selected service protocols; +- supported and selected authentication mechanism; +- codec id and schema fingerprint; +- hard frame and stream limits; +- positive capabilities; +- both nonces; +- transport channel-binding evidence; +- expected realm, node, workload, or daemon identity where applicable. + +All offered and selected values are covered by the authentication transcript so +an intermediary cannot downgrade authentication, codec, schema, limits, or +capabilities. No semantic API request, event, or stream is exposed before the +session reaches `Accepted`. + +### Noise is the standard non-local peer authenticator + +Non-local realm, controller, node, workload-agent, and provider-agent sessions +use the [Noise Protocol Framework](https://noiseprotocol.org/) above the +transport byte stream. + +The initial mechanism set is: + +| Mechanism | Purpose | +| --- | --- | +| `local-peercred` | Direct local Unix daemon access; kernel credentials are mapped by local daemon policy. | +| `noise-realm` | Enrolled realm/controller/node/workload peers using realm-bound static keys and ephemeral session keys. | +| `noise-guest-psk` | Guest-control sessions using the existing per-VM secret plus boot/CID/direction/purpose transcript claims. | +| `noise-bootstrap` | One-time controller or child-realm enrollment bound to parent trust, operation id, expected resource, expiry, and replay nonce. | +| `mutual-tls` | Optional direct daemon-access interoperability when a configured certificate authority owns the identity mapping. | + +The initial Noise profiles are: + +```text +noise-realm = Noise_KK_25519_ChaChaPoly_SHA256 +noise-bootstrap = Noise_IK_25519_ChaChaPoly_SHA256 +noise-guest-psk = Noise_NNpsk0_25519_ChaChaPoly_SHA256 +``` + +Changing a pattern or primitive suite is a component-session protocol-version +change, not an implementation-local preference. + +Enrolled realm peers know each other's bound static transport keys and use +`KK`. A bootstrapping child knows the parent's seed-provided static key and uses +`IK`; the parent binds the newly presented child static key only after the +operation/resource/enrollment checks succeed. + +Production endpoints never accept `none` or silently retry a weaker mechanism. +The endpoint purpose fixes the minimum acceptable mechanism. Relay, managed +identity, or TLS transport authentication may be included as channel-binding +evidence, but never substitutes for the selected d2b peer authenticator. + +Noise static keys are not themselves realm identities. Enrollment binds each +Noise public key to the canonical realm/node/workload identity and controller +generation. The Noise handshake hash becomes the component-session channel +binding used by service authorization and audit. + +### ttrpc/protobuf is the common control-RPC protocol + +Authenticated component sessions use +[ttrpc](https://github.com/containerd/ttrpc/blob/main/PROTOCOL.md) framing and +protobuf service definitions for bounded control RPCs. This reuses the existing +guest-control dependency and supplies: + +- service and method dispatch; +- request/response correlation; +- unary and streaming frame forms; +- protobuf schemas and generated Rust bindings; +- explicit stream closure. + +The d2b hard frame cap remains 1 MiB even though ttrpc permits larger frames. +Declared lengths above the negotiated d2b cap are rejected before allocation. + +Ttrpc is not the transport or security layer. Its specification intentionally +omits authentication, unreliable-network recovery, ping/reset behavior, and +flow control. ComponentSession therefore owns authentication, keepalive, +connection generation, reconnect, hard limits, and session teardown. + +The initial services are: + +```text +d2b.daemon.v1 +d2b.realm.v1 +d2b.guest.v1 +d2b.provider.v1 +``` + +Every request envelope carries a bounded request id, correlation/trace context, +service and method id, absolute deadline, and idempotency key when mutating. +The authenticated principal is session state, not a caller-controlled request +field. Required capability is derived from trusted service/method metadata. +Responses use the shared typed error envelope. + +High-volume or reconnectable PTY, display, clipboard, file-copy, logs, audio, +and port-forward data continues to use d2b's named stream mux. The mux binds +each stream to an already-authorized operation, enforces credit/backpressure, +and supplies resume/close semantics that ttrpc intentionally does not provide. +Ttrpc is the control plane for opening and managing those streams, not a second +unbounded data tunnel. + +Exactly one ComponentSession driver reads and writes the encrypted transport. +Its post-auth record header distinguishes: + +```text +session-control +ttrpc-control +named-stream +``` + +Ttrpc runs over one bounded virtual control channel and multiplexes RPC calls +inside that channel. The d2b mux owns named data channels. Ttrpc and a named +stream handler never read the underlying transport concurrently, and a blocked +data stream cannot consume the reserved control credit required for +cancellation, revocation, keepalive, or close. + +The session driver exposes cooperative virtual `AsyncRead + AsyncWrite` +endpoints: + +- one ttrpc control endpoint with reserved ingress/egress capacity; +- one endpoint per admitted named stream with independent bounded queues and + credit; +- an internal session-control queue that preempts ordinary data for close, + revocation, keepalive, and fatal errors. + +One read task authenticates/decrypts records and wakes only the destination +channel's reader. One write task schedules session-control first, then ttrpc +control, then named-stream data with bounded round-robin fairness. No queue lock +is held across an await. Per-channel and aggregate queue caps fail closed with +typed backpressure; a stalled named stream cannot starve control traffic or +another stream. + +### Existing component protocols migrate behind the session layer + +- `PeerSession` version/codec/capability negotiation and + `SecurePeerSession` authentication/encryption merge into + `ComponentSession`. +- Guest-control keeps its protobuf service semantics, while its nonce/HMAC + transcript becomes the `noise-guest-psk` authenticator and its vsock path + becomes a transport implementation. +- `DaemonAccessTransport` implementations become daemon-access clients composed + over a selected `TransportProvider` and `ComponentSession`. +- The local public Unix socket keeps direct `SO_PEERCRED` admission and its + compatibility wire until an explicit migration moves it to + `d2b.daemon.v1`. +- `ProtocolCodec` moves to the session/codec boundary; semantic services do not + depend on concrete codec implementations. +- `StreamMux` moves to `d2b-session` or `d2b-realm-router` and remains above + authenticated sessions. +- Broker and unsafe-local helper protocols retain specialized seqpacket and FD + semantics. They may reuse shared IDs/errors but are not component-session + byte transports. + +### Existing provider crates migrate explicitly + +The implementation cutover uses this map: + +| Current crate | Selected replacement | +| --- | --- | +| `d2b-realm-provider` | Split serialized DTOs/capabilities/stable errors into `d2b-contracts::provider`, provider traits/registries into `d2b-provider`, session/codec/mux traits into `d2b-session` or realm-router, and mocks/conformance into `d2b-provider-testkit`. | +| `d2b-host-providers` | Split into `d2b-provider-runtime-cloud-hypervisor`, `d2b-provider-runtime-qemu-media`, `d2b-provider-substrate-nixos`, `d2b-provider-substrate-linux`, and `d2b-provider-display-wayland`. | +| `d2b-provider-aca` | `d2b-provider-runtime-azure-container-apps` | +| `d2b-provider-relay` | `d2b-provider-transport-azure-relay` | +| Provider conformance code in production crates | `d2b-provider-testkit` | +| Loopback transport implementation used only by tests | `d2b-provider-testkit` | +| Implementations in `d2b-realm-transport` | Move to matching `d2b-provider-transport-` crates; move common session runtime to `d2b-session`; keep semantic route DTOs in realm-core. | +| `d2b-realm-router::{PeerSession,SecurePeerSession}` | Merge into `d2b-session::ComponentSession`; keep realm route policy and operation-bound mux orchestration in realm-router. | +| `d2b-daemon-access` transport implementations | Compose daemon-access service clients over typed transport providers and component sessions; keep local Unix compatibility admission until migrated. | +| `d2b-gateway` | Move generic authorization, ledger, and session state into the realm controller/router crates; move provider-specific behavior into typed provider implementations; delete the gateway-named crate. | +| `d2b-gateway-runtime` | Delete after the realm controller composes typed provider registries directly. | + +Protocol-neutral realm routing and stream DTOs stay in realm-core crates. +Serialized provider DTOs and schemas move to `d2b-contracts`; in-process +interfaces move to `d2b-provider`. Concrete provider dependencies stay in +their type-first implementation crates. A provider implementation depends +inward on the interface and contract crates; contract, interface, and realm +crates never depend outward on implementations. + +The rename is one coordinated workspace cutover. No compatibility wrapper crates +or re-export-only packages preserve the old names. Cargo manifests, lockfiles, +Nix package construction, source policy, docs, tests, and dependency-direction +gates move together. + +The generic error code `relay-unavailable` becomes `transport-unavailable`. +Azure Relay authentication, rendezvous, or provider-specific failures retain +typed Azure-Relay diagnostic classes beneath that transport-level result. + +ADR 0044's no-isolation warning remains mandatory, but this ADR separates that +warning from the runtime-provider identifier: + +- `systemd-user` identifies direct host-user execution in verified transient + user scopes; +- `unsafe-local` remains the closed isolation posture and required user-facing + warning; +- `systemd-user-service`, `bubblewrap`, and `minijail` are distinct provider + identifiers whose actual posture is derived from the selected profile; +- no provider name alone is sufficient evidence for an `isolated` posture. + +The current `providerKind = "unsafe-local"` wire and bundle value remains code +canon until a coordinated schema and bundle-version cutover implements this +split. That cutover must not add an implicit alias or fallback. + +### Canonical provider identifier catalogue + +The provider registry remains extensible. The following identifiers are +reserved as canonical spellings when those adapters are implemented. Listing an +identifier here does not claim current support. + +| Family | Canonical provider identifiers | +| --- | --- | +| Host process | `systemd-user`, `systemd-user-service`, `bubblewrap`, `minijail` | +| Local container | `podman`, `docker`, `systemd-nspawn`, `lxc`, `kata-containers`, `gvisor` | +| Hypervisor or VMM | `cloud-hypervisor`, `qemu-kvm`, `qemu-media`, `firecracker`, `crosvm`, `libkrun`, `xen`, `bhyve`, `hyper-v`, `vmware-vsphere`, `virtualbox`, `apple-virtualization`, `nutanix-ahv` | +| Virtualization control plane | `libvirt`, `proxmox`, `kubevirt` | +| Cloud VM | `aws-ec2`, `azure-vm`, `gcp-compute-engine`, `openstack-nova`, `oracle-compute`, `alibaba-ecs`, `ibm-vpc`, `digitalocean-droplet`, `hetzner-cloud`, `akamai-linode`, `vultr`, `scaleway-instance` | +| Managed cloud sandbox | `aws-fargate`, `azure-container-apps`, `azure-container-apps-sessions`, `gcp-cloud-run`, `fly-machines`, `e2b`, `modal`, `daytona`, `codesandbox`, `github-codespaces` | +| Generic scheduler | `kubernetes-pod`, `nomad-allocation` | +| Transport | `unix-stream`, `native-vsock`, `cloud-hypervisor-vsock`, `direct-tls`, `quic`, `azure-relay` | + +Adapters that do not support d2b's semantic operation or stream contracts +advertise only the capabilities they actually implement. Brand or product +recognition never implies persistent shell, display, device, networking, or +full-controller support. + +### Runtime providers declare kernel and adoption posture + +Every `RuntimeProvider` descriptor includes closed posture fields for: + +- process and restart-adoption authority; +- network namespace ownership; +- user namespace construction; +- persistent identity protection; +- cgroup ownership; +- device mediation. + +`systemd-user` and `systemd-user-service` workloads are owned by the +authenticated user's systemd manager. Their processes live under user-manager +scopes, not `/sys/fs/cgroup/d2b.slice`. Restart adoption therefore uses the +verified systemd `InvocationID`, exact scope control-group identity, and the +same-UID helper generation selected by ADR 0044. They are not swept or adopted +through the VM runner cgroup algorithm from ADR 0034. The provider ledger is +diagnostic; a live verified systemd scope remains the adoption authority. + +This provider-specific adoption rule is allowed only because the runtime +descriptor names it and conformance verifies it. It does not relax the +`d2b.slice` rule for broker-spawned VM and sidecar runners. + +Host-process runtimes also declare one of these network postures: + +| Posture | Meaning | +| --- | --- | +| `host-shared` | Shares the host network namespace and is explicitly reported as non-isolated. It cannot satisfy realm network isolation. | +| `none` | Has no network namespace interfaces beyond loopback. | +| `isolated-namespace` | Uses a dedicated network namespace with broker-owned veth/TAP attachment and realm firewall policy. | + +Bubblewrap or Minijail naming does not imply `isolated-namespace`; the selected +profile must request and prove it. A runtime that uses `host-shared` networking +cannot host a realm controller or satisfy an isolated workload policy. + +User namespace construction is also explicit: + +- `broker-preestablished` uses a typed broker operation to create mappings and + any privileged mount setup before the provider process executes; +- `unprivileged-self-managed` is permitted only when the host allows + unprivileged `CLONE_NEWUSER`, the mapping uses no privileged ids or + capabilities, and conformance proves no broker-owned surface is bypassed; +- `none` creates no user namespace. + +The virtiofsd-specific namespace path from ADR 0021 is not silently reused for +Bubblewrap or Minijail. A new privileged mapping or mount requirement needs a +typed broker contract. + +Finally, a runtime may advertise `realm-controller-host-v1` only when it +provides persistent identity storage with an explicit tamper-resistance +posture. Initially this requires a hypervisor/VMM workload with a persistent +TPM-backed identity. `systemd-user`, `systemd-user-service`, Bubblewrap, and +Minijail do not qualify merely because they can start a process. + +### A gateway VM is a workload with a realm-controller role + +A local gateway is not a separate workload kind. It is a generic workload whose +parent-owned declaration carries a typed controller role: + +```nix +d2b.realms.local-root.workloads.work-controller = { + kind = "local-vm"; + + localVm = { + autostart = true; + tpm.enable = true; + graphics.enable = true; + usb.securityKey.enable = true; + }; +}; + +d2b.realms.work = { + parent = "local-root"; + controller.workload = "work-controller.local-root.d2b"; +}; +``` + +The child realm's forward pointer is the selected public shape. The normalized +workload index derives the controller role for the referenced generic workload. +No realm scans parent workloads to discover a `forRealm` back-reference. +Implementation may add typed controller sub-options, but it must not replace +the pointer with an arbitrary command, free-form service definition, or +provider-specific controller option. + +The declaration has these invariants: + +1. `controller.workload` names a workload owned by the direct parent realm. +2. A workload cannot control its owning realm, an ancestor, a sibling, or an + unrelated realm. +3. `controller.workload` is one scalar canonical workload target. Lists are + rejected, so one realm cannot select multiple controller credential domains. +4. At runtime, at most one authenticated controller generation is authoritative. + If competing, partitioned, or otherwise ambiguous generations are observed, + no new route is published and the realm is reported degraded until + parent-authorized reconciliation selects a generation. +5. The workload runtime provider must advertise full realm-controller support. + The provider must also advertise `realm-controller-host-v1` and its + persistent identity protection. Host-user and host-process sandbox providers + cannot carry credential-bearing realm-controller authority without a future + accepted identity-protection design. +6. The target realm's controller placement is derived from the workload's + provider and location. `gateway-vm` remains a placement class for status and + telemetry; it is not a separate lifecycle object. +7. The controller never manages the substrate on which it runs. Its parent + realm and the parent's infrastructure provider retain create, adopt, + power, replace, and delete authority for that workload. + +The parent materializes the role by: + +- installing the realm-scoped controller implementation in the guest; +- injecting the parent realm public trust anchor and one-time enrollment + material; +- providing non-secret provider and transport configuration references; +- preparing the child realm's bootstrap network attachment; +- starting the controller workload before publishing the child route; +- authenticating the controller generation over the standard realm protocol; +- draining the child realm and withdrawing its route before stopping or + replacing the controller workload. + +Parent ownership does not make the controller dual-homed. A local controller VM +has one data-plane NIC on the child realm network; parent control uses the +authenticated vsock bootstrap/control channel. It is not attached to the +parent's L2 bridge. If a future provider requires more than one interface, the +runtime must set `net.ipv4.ip_forward = 0`, +`net.ipv6.conf.all.forwarding = 0`, and per-interface +`net.ipv6.conf..accept_ra = 0`; disable proxy ARP and proxy NDP; install +default-deny cross-interface firewall policy; and prove that no bridge, route, +or network namespace joins the parent and child realm networks. + +Controller enrollment material must be available before authenticated guestd +or realm protocol traffic can begin. For local Cloud Hypervisor controllers, +the parent creates a dedicated one-shot controller seed in parent-owned runtime +state, exposes it through a read-only boot-time seed share, and withdraws that +share after the controller acknowledges consumption. The seed contains the +parent public trust anchor, expected controller identity coordinates, operation +binding, expiry, and replay nonce. It is not a Nix-store artifact, persistent +virtiofs share, command-line secret, or general guest-control channel. +After consuming the seed, parent and controller establish a +Cloud-Hypervisor-vsock transport and complete `noise-bootstrap`; ordinary realm +or guest service traffic is unavailable until that ComponentSession succeeds. + +The child realm identity private key is generated inside the controller +workload. It is never rendered into Nix, the host bundle, cloud-init plaintext, +or a parent-readable state ledger. Persistent controller identity, provider +state, token caches, and realm audit remain inside the controller workload's +storage and TPM boundary. + +### Remote controller workloads use the same role + +A controller workload may be remote from its parent. Its runtime and +infrastructure provider change; its role and realm protocol do not: + +```nix +d2b.realms.local-root.workloads.work-connector = { + kind = "local-vm"; + + localVm = { + autostart = true; + tpm.enable = true; + graphics.enable = true; + usb.securityKey.enable = true; + }; +}; + +d2b.realms.local-root.infrastructureProviders.azure = { + kind = "azure-vm"; + executor.workload = "work-connector.local-root.d2b"; + credentialRef = "entra-azure-control"; +}; + +d2b.realms.local-root.runtimeProviders.azure-controller = { + kind = "azure-vm"; + infrastructureProvider = "azure"; +}; + +d2b.realms.local-root.workloads.work-controller = { + kind = "provider-managed"; + provider = "azure-controller"; +}; + +d2b.realms.work.controller.workload = "work-controller.local-root.d2b"; +``` + +`provider-managed` is the selected generic workload configuration variant for a +workload whose runtime comes from `runtimeProviders`; `azure-vm` is the runtime +or infrastructure provider implementation id, not another workload `kind`. +This replaces the current schema-only `provider-placeholder` variant when live +provider dispatch lands. + +The exact typed runtime and infrastructure provider options are new schema +introduced by this decision. Existing inert +`d2b.realms..providers` records must be split or migrated into those +bindings when this decision is implemented. + +The parent-owned infrastructure provider: + +1. authorizes the create operation under parent policy; +2. acquires provider credentials only in its configured executor workload; +3. creates or adopts the remote controller VM; +4. injects the parent public key and one-time enrollment material; +5. records only opaque provider resource references and bounded lifecycle + state; +6. verifies that the enrolling controller matches the expected operation and + resource binding; +7. retains lifecycle authority after enrollment. + +The executor workload must itself be startable by the parent without the child +controller. An ordinary workload already owned by `work` cannot provision the +`work` controller because that would recreate the bootstrap cycle. If an +existing interactive VM such as `work-aad` is selected as the executor, its +controller-facing role and lifecycle must be parent-owned; otherwise a +dedicated `work-connector` workload is required. + +The remote VM generates its realm identity, starts the full controller, and +enrolls with the parent. A cloud managed identity may authenticate that VM to +provider APIs or Relay, but managed identity evidence is bootstrap or transport +evidence only. The d2b realm key remains the peer identity used for operations +and policy. + +Remote enrollment uses a temporary, operation-bound rendezvous on the +configured relay fabric. The parent connector opens the enrollment listener +before infrastructure creation. The infrastructure provider injects only the +parent public key, rendezvous reference, expiry, replay nonce, and expected +resource binding through the provider's approved bootstrap mechanism. The new +VM authenticates to Relay with its managed identity, proves the expected cloud +resource binding, generates its realm key, and completes the d2b enrollment +through the `noise-bootstrap` ComponentSession mechanism. The rendezvous is +revoked before the normal realm route is published. No inbound route to the +local parent and no pre-existing child route is required. + +### Provider-agent and transport-connector placement is derived + +Three responsibilities may be co-located but are not the same role: + +| Responsibility | Authority | +| --- | --- | +| Realm controller | Realm policy, registry, audit, routing, and semantic operations. | +| Infrastructure provider executor | Provider API calls and lifecycle of provider-hosted workloads. | +| Transport connector | Provider-specific transport authentication and outbound session establishment. | + +Controller, infrastructure-executor, and transport-connector roles are derived +from forward references into one recursion-safe normalized index: + +```text +d2b._index.workloadRoles. + controllerFor + infrastructureExecutorFor + transportConnectorFor +``` + +`index.nix` computes this table once from raw realm/provider/transport option +values. Guest composition consumes only its own precomputed row; it never scans +`config.d2b.realms.*`, parent workloads, or provider attrsets. Provider and +transport declarations cannot depend on guest config derived from the role +index. This one-way dependency prevents Nix evaluation cycles and avoids two +independently configurable declarations claiming the same authority. + +For a local controller, all three responsibilities may live in one dedicated, +Entra-managed controller VM. For a remote controller, a local Entra-managed VM +may remain the provider executor and Azure Relay transport connector while the +remote VM owns the realm controller: + +```text +local parent realm + -> dedicated work connector (local Cloud Hypervisor) + -> Entra token acquisition + -> Azure infrastructure-provider executor + -> Azure Relay connector + -> remote Azure VM + -> work realm controller +``` + +Combining these functions with an interactive desktop VM is permitted only +when that VM is parent-owned and policy explicitly accepts the availability +and blast-radius tradeoff. A dedicated Entra/Intune-managed connector or +controller workload is preferred. + +### Realm transport configuration is the connectivity source of truth + +Connectivity is configured from `d2b.realms..transport`; a separate +gateway or realm-entrypoint object is not introduced. + +`transport.provider` is a reference to a typed `transportProviders` instance, +not an inline provider kind: + +```nix +d2b.realms.work.transportProviders.azure-work = { + kind = "azure-relay"; + + connector = { + workload = "work-connector.local-root.d2b"; + credentialRef = "entra-work-relay"; + underlayAuthentication = "entra-user"; + }; + + controllerUnderlayAuthentication = "managed-identity"; +}; + +d2b.realms.work.transport = { + enable = true; + provider = "azure-work"; + fabricRef = "work-relay"; + descendantAccess = "delegated"; + peerShortcuts.enable = true; +}; + +d2b.realms.payments.transport.inheritFrom = "work"; +``` + +The two `*UnderlayAuthentication` fields authenticate endpoints to Azure +Relay only. The d2b peers still complete `noise-realm` ComponentSession +authentication before semantic traffic. + +`fabricRef`, endpoint references, and credential references are opaque, +non-secret identifiers. The connector resolves its credential reference inside +the selected workload. The host and parent bundle never resolve or copy the +credential. + +Nested realms may inherit the same transport provider and fabric from an +ancestor, but they receive distinct peer identities and scoped transport +credentials. Inheritance does not share controller token caches, realm private +keys, or a single authorization identity. + +If both peers have direct authenticated connectivity, they may use a direct +transport without a connector workload. If Relay authentication or work +credentials are required, the configured connector owns that side of the +transport. There is no root, `sudo`, host-token, or direct-network fallback. + +### Shared-transport peer shortcuts separate control and data paths + +Strict tree routing remains the authorization model. Shared-transport shortcuts +optimize only the data path: + +```text +control: + source -> source controller -> applicable ancestors -> target controller + +data after authorization: + source peer -> shared transport fabric -> target peer +``` + +Every applicable source, ancestor, and target policy is evaluated before a +shortcut is issued. The nearest common ancestor issues a short-lived signed +`PeerShortcutGrant` only after the normal tree route is authorized. + +The grant is scoped to: + +- source realm and authenticated source principal; +- target realm and authenticated target principal; +- operation or stream kind; +- required capability; +- digest of the authorized tree path; +- controller generations and policy epochs used for the decision; +- bounded correlation and shortcut identifiers; +- issue and expiry times; +- a replay nonce. + +The grant contains no token cache, transport credential, raw endpoint, provider +resource id, command payload, stream data, or user-supplied label. + +Source and target bind the grant digest into their end-to-end peer-session +handshake. The transport provider supplies an opaque, one-time binding below +the policy DTO. ComponentSession authenticates and encrypts the stream end to +end; provider-specific transport authentication establishes reachability only. + +An established shortcut: + +- does not create a new realm edge, alternate parent, or DAG route; +- is valid only for the authorized operation or stream; +- cannot be reused for generic IP forwarding, port forwarding, VPN traffic, or + an unrelated d2b operation; +- expires with the grant, route advertisement, controller generation, or + policy epoch, whichever expires first; +- is torn down on policy revocation, route revocation, peer disconnect, + transport failure, or operation completion; +- produces bounded establishment and teardown audit records at the authorizing + ancestor and participating peers even though stream bytes bypass + intermediate controllers. + +Each endpoint sends a signed `PeerShortcutClosed` control message to the +authorizing ancestor when it observes normal completion, peer disconnect, local +cancellation, or transport failure. The message binds the shortcut id, endpoint +role, controller/workload generation, terminal reason, byte-count class, and +local close time; it contains no payload or provider endpoint. The authorizing +ancestor records endpoint reports independently and emits the final teardown +record when both arrive. + +If one or both reports never arrive, the ancestor closes its authorization +lease at grant expiry or route/policy revocation and records an +`endpoint-unconfirmed` terminal class. Absence of a peer report is never +converted into a successful completion. Participating peers always retain their +own local establishment and teardown records. + +The endpoint-reported byte-count class is diagnostic and untrusted. It cannot +prove how much data crossed the transport and must not drive security policy, +billing, exfiltration detection, or compliance conclusions. A transport provider +may expose separate provider-attested counters when available, but those are a +distinct typed observation with an explicit trust posture. + +### Existing direct-shortcut contracts are generalized + +The route engine already contains `DirectShortcutAuthorizationMetadata`, +`DirectShortcutState`, and typed teardown reasons. Implementation of this ADR +generalizes that foundation to peer transport shortcuts: + +```text +PeerShortcutTransport + = native-direct + | shared-fabric +``` + +Authorization metadata remains transport-address-free. Provider-specific +rendezvous data belongs in a separate bounded transport binding keyed by the +shortcut id. + +This decision refines ADR 0043's restriction that direct shortcuts use only +native underlay reachability. Native direct transport still must not add +STUN/ICE, NAT traversal, a VPN, or an overlay. A `shared-fabric` shortcut is +allowed only when both peers already participate in the same configured +transport fabric and the transport provider advertises shortcut support. It +does not discover or construct a new network path. + +Policy or route revocation applies to established streams, not only future +rendezvous. A transport provider advertising `active-shortcut-revoke-v1` must +close the established binding when the authorizing ancestor revokes it, while +both peer muxes close the named stream. Providers without active revocation may +support shared-fabric shortcuts only with a maximum 60-second session grant and +policy-authorized renewal; expiration closes the stream before renewal. +Provider credential or listener revocation that affects only future +connections is insufficient by itself. + +If a shortcut cannot be established, the operation follows an explicitly +authorized parent transport path or fails with a typed transport error. There is no +silent direct-network, provider-native, SSH, or generic tunnel fallback. + +### Workloads may participate directly in a shared transport + +Eliminating controller hops requires the source and target workload agents, not +only their controllers, to participate in the transport fabric. + +A workload may advertise `transport-client-v1` only when its runtime supplies a +guestd-compatible or d2b peer agent. The controller delegates a short-lived, +workload-scoped transport access grant or the workload authenticates the +transport independently through a provider-supported identity such as Azure +Managed Identity. + +The workload never receives: + +- the controller's transport credential; +- an Entra refresh token from another VM; +- a realm identity private key; +- authority to advertise descendants; +- a grant broader than its workload identity and negotiated operations. + +If the transport provider cannot issue or validate a workload-scoped transport +identity, that workload cannot use a direct shared-fabric shortcut. Its traffic +continues through the authorized controller/connector path. + +### Entra, YubiKey, browser, and developer authentication + +The physical YubiKey is shared at the CTAP ceremony layer, not at the token +cache layer. + +D2b's security-key proxy may expose persistent virtual FIDO devices to the +controller or connector VM, a work browser VM, and a work development VM. The +host broker serializes ceremonies so only one transaction uses the physical key +at a time. Each VM performs its own Entra authentication and stores its own +tokens. + +Physical touch alone is not sufficient intent when multiple VMs can queue a +ceremony. Before forwarding a CTAP operation that requires user presence or +verification, the proxy requires explicit approval through a trusted d2b +surface showing the source realm, workload, operation class, and RP id when the +RP id can be safely parsed. A background VM cannot consume a touch intended for +the foreground VM. Approval is single-ceremony, expires with the queue entry, +and defaults to deny. Window focus may improve presentation but is never the +authorization signal. + +The CTAP proxy uses a closed command policy. The default browser/provider +profile permits discovery, credential creation, assertion, assertion +continuation, PIN/user-verification exchange, and other explicitly enumerated +non-destructive commands required by supported WebAuthn clients. It denies +authenticator reset, credential deletion/management, biometric enrollment, +authenticator configuration, vendor commands, and unknown CTAP commands. A +future administrative authenticator-management flow requires a separate +exclusive operation, explicit trusted confirmation, and its own audit event. + +Ceremony and queue leases remain bounded. The current 120-second active +ceremony timeout and 15-second contention wait remain finite defaults. The +schema defines hard upper bounds; operators may reduce them but cannot disable +timeouts or configure an unbounded lock. Disconnect, denial, or timeout sends +cancellation where the device protocol supports it and releases the +physical-key lease. + +The expected credential split is: + +| Workload | Credential use | +| --- | --- | +| Controller or provider-executor VM | ARM and Relay tokens obtained inside that VM. | +| Work browser VM | Browser session tokens obtained by its own WebAuthn ceremony. | +| Local work development VM | Its own delegated `az`/SDK login when direct Azure data-plane access is required. | +| Remote Azure VM | Azure Managed Identity for Key Vault, storage, database, and other Azure resource access where supported. | + +Infrastructure control from a development workload should use typed d2b +operations routed to the infrastructure-provider executor. The executor's ARM +token is never returned to that workload. Code running in Azure should prefer +Managed Identity. Code requiring delegated user access authenticates in its own +workload and does not mount or import another VM's token cache. + +When controller or connector authentication requires user interaction, provider +status reports `interaction-required`. D2b never opens an authentication window +merely because status changed. The operator explicitly starts the flow with +`d2b realm provider authenticate ` or an equivalent +deliberate desktop action. Only then may d2b open a provider-owned +authentication window through the Wayland proxy. It must not steal focus, +retry indefinitely, or expose a direct host compositor fallback. + +`interaction-required`, `interaction-started`, `interaction-completed`, and +`interaction-failed` are bounded status/event classes exported to CLI status, +desktop notifications, tracing, and metrics. Human and machine-readable status +include the bounded configured `ProviderId`, realm path, and exact remediation +command so the operator can invoke the correct provider. Metric labels remain +limited to provider type, realm class, and result class; they never include the +provider id, user identity, token subject, RP id, or provider endpoint. + +The CTAP proxy covers FIDO/WebAuthn only. PIV, CCID, OTP, and OpenPGP interfaces +still require exclusive USB ownership. A single physical key cannot +simultaneously use CTAP proxying and USBIP for those interfaces; use explicit +handoff or a second key. + +## Authorization and security invariants + +Implementation must preserve all of the following: + +1. A controller role is parent-authorized configuration, not a capability a + guest can self-assert. +2. Controller, provider-executor, and transport-connector peer identities are + authenticated before any state lookup, token resolution, provisioning, or + route publication. +3. Provider and transport tokens stay in the configured credential-owning workload. +4. Entra, managed identity, TLS, Relay, vsock, and Unix transport identities + are never mapped to local daemon roles or broker authorization. +5. The remote controller re-originates privileged local effects through its own + broker; no remote peer receives the broker wire protocol. +6. A parent may stop or replace the controller workload but cannot use its + storage ledger as authority to repair child realm state. +7. Transport reachability does not expose semantic traffic before + ComponentSession authentication and does not merge realm policy, identity, + audit, or credential domains. +8. Shortcut authorization follows the same parent/child policy chain as the + non-shortcut route. +9. Raw transport endpoints, provider resource ids, token subjects, user ids, + device ids, command data, and stream payloads are forbidden as metric labels. +10. Ambiguous controller identity, route generation, shortcut state, or + infrastructure ownership is preserved and reported degraded rather than + guessed, killed by PID, or broadly cleaned up. +11. Noise static private keys stay inside their realm/workload identity + boundary. Ephemeral keys, transport cipher state, record nonces, sockets, + and session keys are never persisted or adopted across reconnect. +12. The selected authentication mechanism, service protocol, codec, schema, + limits, and transport channel binding are covered by the authenticated + session transcript; downgrade or mismatch fails before API dispatch. +13. API requests inherit the authenticated session principal. A payload cannot + replace or widen that identity, and method-required capabilities are + derived from trusted service metadata. + +## Session and transport observability + +ComponentSession and transport implementations export bounded metrics for: + +| Metric family | Required dimensions | +| --- | --- | +| session/transport active count | transport kind, session purpose | +| per-channel and aggregate queue depth/capacity ratio | channel class | +| dropped/rejected record count | channel class, bounded reason class | +| channel waker/scheduling delay | channel class | +| control-credit exhaustion | session purpose | +| transport connect/reconnect attempt | transport kind, purpose, result class | +| reconnect backoff state | transport kind, bounded backoff class | +| Noise/component-session handshake | mechanism class, purpose, result class | +| terminal session close | purpose, terminal reason class | + +`channel class` is one of `session-control`, `ttrpc-control`, or +`named-stream`; it is never a stream id. Queue metrics are aggregated by class +and may expose bounded max/sum observations, not one metric series per session +or stream. + +Reconnect loops emit a counter for every attempt while audit and log events are +deduplicated. The first transition into reconnecting, each bounded backoff-class +change, authentication-result-class change, recovery, and terminal failure are +recorded. Repeated identical failures within the suppression window increment a +suppressed-event count that is included in the next emitted summary. Raw error +text, endpoint, credential, or peer-provided label never becomes an audit field +or metric label. + +Tracing and audit may retain bounded non-secret correlation identifiers that +would be too cardinal for metrics: + +- configured `provider_id`; +- `operation_id`, `correlation_id`, and trace id; +- `shortcut_id`; +- controller/workload generation; +- bounded session generation. + +These identifiers are permitted as trace span attributes and structured audit +fields, but not metric labels. Raw transport endpoints, provider resource ids, +token subjects, user/device ids, key fingerprints, proof material, and payload +metadata remain excluded from metrics, traces, and audit. + +## Audit retention and export + +Every realm controller owns an append-only local audit log with bounded +rotation and retention. `d2b.realms..audit.retentionDays` inherits +`d2b.site.audit.retentionDays` and therefore defaults to 14 days unless the +realm selects a stricter policy. Disabling retention or silently discarding +records is not a supported remote-controller posture. + +Remote and replaceable controller workloads must advertise +`realm-audit-export-v1`. This is a semantic, authenticated export operation to +the observer or archive sink selected by realm policy; it is not generic file +access and does not make raw realm audit readable by the physical host or an +ancestor by default. Exported batches are signed, sequence-bounded, encrypted +to the selected sink, and acknowledged by checkpoint digest. + +Before a planned controller replacement, the parent requires a signed audit +checkpoint and drains the old controller. The parent records only the +checkpoint digest, sequence range, controller generation, and result class. If +forced loss makes export impossible, replacement may proceed only through an +explicit recovery operation that emits an `audit-gap` event and reports the +realm degraded until acknowledged. Shortcut grants, peer close reports, policy +decisions, credential interaction boundaries, and provider lifecycle +operations are included in the retained/exported audit sequence. Component +session establishment, selected mechanism/service classes, rejection class, +replay, downgrade refusal, and terminal reason are audited with bounded +identities; keys, proofs, nonces, raw endpoints, and payloads are excluded. + +## Failure and continuation behavior + +- If an infrastructure-provider executor is unavailable, existing remote + controllers continue running, but create, power, replace, and delete + operations fail visibly. +- If a local transport connector is unavailable, the remote realm may continue + operating, but local reachability through that connector is unavailable. +- If the remote controller is unavailable, the realm is unavailable even when + its infrastructure and Relay endpoint still exist. +- If the selected transport is unavailable, no provider-native, SSH, or + alternate-network fallback is attempted unless a separately configured and + authorized transport binding exists. +- Daemon, connector, and controller restarts are continuation events. + Reconnection uses realm identity, controller generation, operation ids, route + generations, and shortcut ids rather than persisted sockets or pidfds. +- Every reconnect performs a fresh Noise or local-auth handshake. In-flight + ttrpc calls fail or retry through operation idempotency; named streams resume + only through their explicit generation/cursor contract. +- Expired or superseded shortcut grants are not adopted. +- Losing or replacing persistent controller identity is an explicit + re-enrollment event, not an automatic repair. + +## Public and private contract changes + +Implementation requires coordinated changes across: + +- the canonical `d2b-contracts::provider` DTO and schema module; +- the canonical `d2b-contracts::session` handshake, authentication, envelope, + limits, and typed-error module; +- the `d2b-provider` base and specialized provider interfaces; +- the `d2b-session` component-session runtime and authenticator interfaces; +- ttrpc/protobuf daemon, realm, guest, and provider service contracts; +- type-first provider implementation crates and typed registries; +- `d2b-provider-testkit` conformance suites and provider naming policy; +- `d2b.realms..controller.workload` and the normalized + `d2b._index.workloadRoles` table; +- typed runtime and infrastructure provider bindings replacing the ambiguous + inert provider record; +- `d2b.realms..transport` provider, fabric inheritance, connector, and + shortcut policy; +- workload, controller, provider-executor, and transport-client capability + advertisements; +- controller-workload bootstrap and generation DTOs; +- peer shortcut authorization and transport-binding DTOs; +- realm-controller, workload, session, and transport status output; +- bundle artifacts, generated schemas, reference documentation, and migration + guidance. + +Security-sensitive schema changes require the normal bundle/schema version +bumps. Existing `unsafe-local` provider-kind values and old inert provider +records receive explicit migration errors after the selected cutover; no +success-shaped compatibility fallback is added. Every migration error names +the obsolete option or value, its exact replacement, and the provider/realm +migration guide. + +The same clean cutover applies to transport and session contracts: + +- `relayProviders` and `d2b.realms..relay` fail with migration errors + naming `transportProviders` and `d2b.realms..transport`; +- `ProviderType::Relay`, `RelayProvider`, and `relay-unavailable` are rejected + rather than aliased to their transport replacements; +- pre-ComponentSession realm-peer or guest-control wire generations fail with + typed version/migration errors and cannot negotiate a weaker legacy session; +- a controller that lacks the required ComponentSession transport/auth/service + capabilities is not admitted and publishes no route; +- old and new realm/session generations may be drained side-by-side as separate + deployments, but they never form a mixed authenticated topology. + +The local public Unix compatibility wire remains a local daemon-access surface +only until its explicit migration. It is never accepted as a remote realm, +controller, guest, or provider session fallback. + +## Validation requirements + +Implementation is incomplete without: + +- source-policy tests proving provider crate names use a recognized type-first + axis and the implementation id matches the descriptor; +- dependency-direction tests proving `d2b-contracts` does not depend on + `d2b-provider` or an implementation, and `d2b-provider` does not + depend on an implementation, cloud SDK, daemon, broker, codec, or concrete + transport; +- dependency-direction tests proving live `ByteStream`, `TransportSession`, and + `TransportListener` types live only in `d2b-provider`; `d2b-session` depends + inward on `d2b-provider`, never the reverse; +- dependency-direction tests proving every + `d2b-provider--` crate is a leaf adapter that does not + depend on `d2bd`, `d2b-priv-broker`, or another provider implementation; +- dependency-direction tests proving `d2b-session` depends only on contracts, + provider interfaces, codec-neutral service DTOs, and approved crypto/runtime + primitives, never daemon, guest, broker, or concrete transport code; +- policy tests proving provider implementations reuse + `d2b-contracts::provider` DTOs rather than declaring shadow serialized types; +- conformance tests for every registered provider's primary interface and + advertised optional capabilities; +- registry tests proving optional capability descriptor claims and + capability-specific trait registrations match exactly without trait-object + downcasting; +- compile-time object-safety tests constructing `Arc` for + every primary and optional provider interface; +- contract tests proving `ProviderOperationContext` remains serializable and + runtime cancellation/deadline state exists only in `ProviderCallContext`; +- transport conformance tests for Unix-stream, native-vsock, + Cloud-Hypervisor-vsock, loopback, direct-network, and Azure-Relay adapters, + including purpose gating, bounded endpoints, liveness, reconnect, and active + revocation claims; +- component-session tests for fixed bootstrap framing, Noise transcript + binding, downgrade rejection, channel binding, replay, hard limits, + keepalive, close, record fragmentation/reassembly, and forbidden pre-auth + semantic traffic; +- deterministic demux scheduler tests proving reserved session/ttrpc control + credit, channel-specific waker delivery, bounded queues, cancellation + preemption, round-robin data fairness, no lock held across await, and queue + depth/drop/waker-delay metrics without per-session or per-stream labels; +- fixed Noise profile test vectors plus fuzz/property tests for handshake and + encrypted-record parsers, including truncation, duplicate/reordered + fragments, oversized ttrpc frames, nonce exhaustion, and reconnect; +- authenticator tests for local peer credentials, realm Noise keys, guest PSKs, + one-time bootstrap, and optional mTLS, including rejection of `none` and + cross-purpose credential reuse; +- ttrpc/protobuf contract tests for all four service ids, method-derived + capabilities, deadlines, idempotency, typed errors, and operation-bound named + stream opens, with the d2b 1 MiB cap enforced before allocation; +- source-policy tests proving public daemon, broker, and unsafe-local helper + seqpacket/SCM_RIGHTS endpoints are not silently registered as generic byte + transports; +- Nix evaluation tests for one-controller-per-realm, direct-parent ownership, + cycle rejection, scalar-only controller target, recursion-free role-index + construction, provider capability gating, typed transport-provider + references, ancestor-only transport inheritance, and derived placement; +- runtime route tests proving competing, partitioned, or otherwise ambiguous + controller generations publish no route, report the realm degraded, and + reject superseded generation grants until parent-authorized reconciliation; +- Nix evaluation tests proving obsolete `unsafe-local` provider-kind values, + inert provider records, old gateway declarations, and compatibility aliases + fail with the documented actionable migration errors; +- versioned cutover tests proving legacy `relayProviders`/`realm.relay`, + `ProviderType::Relay`, `RelayProvider`, and `relay-unavailable` inputs fail + with exact transport migration guidance; +- wire-skew tests proving pre-ComponentSession handshakes, unmigrated + controllers, and mixed old/new realm generations cannot publish routes or + fall back to legacy auth/protocol paths; +- tests proving a controller cannot control its own substrate; +- bootstrap tests proving the local pre-guestd seed share and remote temporary + relay rendezvous carry only bounded operation-bound enrollment material, are + replay protected, and are withdrawn before route publication; +- network tests proving parent-owned controller VMs are not parent/child + dual-homed; IPv4/IPv6 forwarding, IPv6 RA acceptance, proxy ARP, and proxy NDP + are disabled; and any explicit multi-interface provider installs + default-deny cross-interface policy; +- sandbox runtime tests covering `host-shared`, `none`, and + `isolated-namespace` networking plus broker-preestablished and unprivileged + self-managed user namespace modes; +- provider-executor tests proving credentials are resolved only inside the + selected workload; +- transport inheritance tests proving nested realms receive distinct identities + and no copied credential references; +- route-engine tests for shared-fabric shortcut authorization, replay, + expiration, policy epoch changes, route revocation, signed endpoint-close + reports, untrusted endpoint byte counts, missing-report expiry, active + shortcut revocation, maximum-lifetime fallback, and teardown; +- end-to-end tests proving shortcut bytes bypass intermediate controllers while + every policy boundary records the decision; +- negative end-to-end tests proving shortcut failure either uses the already + authorized parent transport route or returns a typed transport error without + probing direct networks, SSH, provider-native APIs, generic TCP, or tunnels; +- negative tests proving a transport-authenticated peer is not local `Admin`; +- negative tests proving no remote realm, workload, transport, or provider peer + can receive or invoke the local privileged broker wire protocol; +- YubiKey tests proving controller, browser, and developer ceremonies require + trusted per-ceremony intent, serialize without token-cache sharing, reject + destructive/unknown CTAP commands, cancel on disconnect, and release leases + at the bounded ceremony and queue timeouts; +- restart/adoption tests for local and remote controller workloads, including + systemd-user scope adoption outside `d2b.slice`, rejection of expired or + superseded shortcut grants, and mandatory re-enrollment after controller + identity loss; +- audit tests covering retention/rotation, signed export checkpoints, planned + replacement drain, forced-loss `audit-gap`, and shortcut audit completeness; +- reconnect-flap tests proving every attempt increments bounded metrics while + repeated audit/log events are suppressed and summarized by backoff/result + class without losing recovery or terminal transitions; +- status/telemetry tests proving `interaction-required` is visible without + leaking user, token, RP, or endpoint identity; status includes the bounded + provider id/remediation while metric labels omit the provider id; +- telemetry classification tests proving bounded provider/operation/shortcut + ids remain available in traces/audit but never metric labels, while endpoints, + Entra identities, Azure resource ids, credentials, proofs, and payload + metadata are absent from every telemetry surface. + +## Consequences + +### Positive + +- Gateway VMs become ordinary workloads with a precise role. +- Provider crates sort by authority type and expose one recognizable interface + and conformance contract. +- Existing contract ownership is preserved: provider implementations and + traits share one serialized DTO/schema source in `d2b-contracts`. +- Unix, vsock, direct-network, and Azure Relay connectivity share one byte + transport contract without sharing authentication semantics. +- Noise-authenticated ComponentSession and ttrpc/protobuf services replace + duplicated framing, hello, authentication, and request-correlation code. +- Local and remote realm controllers use one configuration and protocol model. +- Controller hosting lifecycle cannot become self-referential. +- Provider, transport, session, and controller responsibilities have explicit + credential and authority boundaries. +- Nested realms can share one transport fabric without forcing stream bytes + through every controller. +- The existing route-shortcut policy engine remains useful and gains a + provider-neutral transport binding. +- One physical FIDO key can serve multiple work VMs while each retains an + independent Entra session. + +### Negative + +- Provider configuration becomes more explicit and requires migration from the + current inert `providers` records. +- The workspace-wide provider rename is intentionally disruptive and must update + every manifest, Nix build, policy gate, and documentation reference together. +- Component-session migration touches public daemon, realm-peer, guest-control, + codec, mux, generated protobuf, and error contracts and therefore requires a + coordinated versioned cutover. +- Noise key enrollment and rotation become new persistent identity lifecycle + responsibilities. +- Remote controllers need a parent-owned provider executor even after + enrollment. +- Direct workload shortcuts require transport-capable guest agents and scoped + transport identities. +- Shared-transport revocation and audit are more complex than hop-by-hop byte + forwarding. +- A controller requiring interactive Entra renewal may temporarily block + provider operations even while the realm itself remains reachable. + +## Alternatives considered + +### Keep provider crates named by vendor or product only + +Rejected. Names such as `d2b-provider-aca`, `d2b-provider-relay`, and +`d2b-provider-azure` do not reveal whether the crate executes workloads, +provisions infrastructure, owns credentials, or transports bytes. Type-first +names make authority visible in workspace listings and dependency review. + +### Use one universal provider trait with optional methods + +Rejected. A catch-all interface would allow unsupported methods to accumulate, +blur authority boundaries, and make capability claims difficult to verify. A +small common `Provider` base plus mandatory primary-type interfaces preserves +shared status/error semantics without pretending every provider can perform +every operation. + +### Name the interface crate `d2b-provider-api` + +Rejected. The suffix is not used for equivalent Rust crate boundaries in this +repository and adds no information beyond the `provider` domain noun. The +serialized API is already named `d2b-contracts`; `d2b-provider` is the +in-process provider trait surface. + +### Put async provider traits directly in `d2b-contracts` + +Rejected. `d2b-contracts` owns serialized, versioned data shared across process +and crate boundaries. Async trait objects, typed registries, and runtime error +wrappers are an in-process Rust plug-in API with different evolution and +dependency requirements. Keeping those traits in `d2b-provider` prevents +Tokio/runtime concerns from becoming part of the wire-contract layer while +still requiring every trait method to consume and return canonical +`d2b-contracts` types. + +### Use 9P as the component API + +Rejected as the authoritative control protocol. 9P supplies bounded binary +messages, version/msize negotiation, concurrent request tags, `Tflush` +cancellation, and authentication fids, but its semantic model is +attach/walk/open/read/write/clunk over a file tree. `Tauth` deliberately leaves +the authentication exchange unspecified, and 9P supplies no encryption, +realm/node/workload identity, method-derived capability policy, idempotency, +typed operation errors, or native event/stream semantics. + +A future read-only or tightly constrained filesystem projection of d2b status, +logs, or artifacts may use 9P. Such a projection is a client of typed d2b +services and never becomes repair or authorization authority. + +### Use gRPC over HTTP/2 + +Rejected for the internal component baseline. gRPC provides mature streaming, +flow control, deadlines, cancellation, metadata, and mTLS, but requires the +HTTP/2 protocol stack. Carrying HTTP/2 inside Cloud-Hypervisor-vsock and Azure +Relay streams would duplicate d2b transport/session/mux concerns and increase +dependency and parser attack surface. A future external API gateway may expose +gRPC without changing the internal component session. + +### Use Cap'n Proto RPC + +Rejected for this cutover. Cap'n Proto supports arbitrary streams, +capability-secure object references, multiplexing, and promise pipelining, but +adopting its distributed-object model would redesign d2b authorization around +delegable connection-scoped object capabilities. Its automatic direct +third-party connectivity also requires constraints beyond the selected strict +realm tree. The current operation/idempotency/audit model remains canonical. + +### Use the full libp2p or SSH stack + +Rejected. Both provide proven layered transport authentication and stream +multiplexing, but their product models are broader than d2b's requirements. +Libp2p brings swarm, peer discovery, multiaddress, and optional NAT-traversal +concepts that conflict with strict realm-tree routing. SSH brings user-login, +shell, exec, and generic forwarding semantics that d2b explicitly excludes from +its control plane. D2b follows the useful transport -> Noise/TLS -> protocol +negotiation -> mux layering without adopting either runtime. + +### Use ttrpc without ComponentSession + +Rejected. Ttrpc intentionally targets reliable low-latency process connections +and omits authentication, handshake, ping/reset, network recovery, and flow +control. It is selected only as the typed control-RPC framing and service layer +inside an authenticated, bounded ComponentSession. + +### Keep gateway VMs as a separate object + +Rejected. It duplicates workload lifecycle, runtime-provider, component, +network, and status configuration. The special behavior is the controller role, +not the VM kind. + +### Declare the controller workload inside the realm it controls + +Rejected. The realm controller would be required to start the workload that +must start the controller. Parent ownership provides an acyclic bootstrap and a +clear infrastructure repair owner. + +### Let a controller manage its own cloud VM + +Rejected. Loss or corruption of that controller would also remove the only +authority able to inspect or replace its substrate. Parent-owned infrastructure +providers preserve recovery authority. + +### Store Entra, provider, or Relay credentials on the physical host + +Rejected. This violates the realm credential boundary and ADR 0043's rule that +the host control plane does not hold work/provider credentials. + +### Forward one VM's Entra token cache to other work VMs + +Rejected. Token forwarding collapses execution identities and turns the +controller into a credential vending service. Each workload authenticates +independently or uses a provider-managed workload identity. + +### Send every nested stream through every realm controller + +Rejected as the only data path. Controllers remain policy decision points, but +forcing them into the byte path adds latency, bandwidth cost, and cascading +failure without strengthening an already-authorized end-to-end stream. + +### Treat shared transport membership as authorization + +Rejected. Transport credentials establish transport access only. Realm keys, +controller generations, tree policy, operation capabilities, and scoped +shortcut grants remain authoritative. + +### Add a generic network tunnel for nested realms + +Rejected. D2b routes semantic operations and named streams, not arbitrary +cross-realm IP traffic. Shared-fabric shortcuts are operation-scoped and do not +create a VPN or alternate realm topology. diff --git a/docs/adr/README.md b/docs/adr/README.md index e9641319b..e97eb9fc1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -49,3 +49,4 @@ for the broader design narrative, see | [0042. d2b clipboard authority and picker split](0042-d2b-clipboard-authority-and-picker-split.md) | Accepted | 2026-06-28 | Splits trusted clipboard authority into d2b-owned `d2b-clipd` plus Wayland bridge virtualization, keeps `d2b-clip-picker` UI-only in a separate GPL repository, and requires a no-patch Niri paste-intent hook for host cross-realm native paste. | | [0043. Realm-native control plane](0043-realm-native-control-plane.md) | Accepted | 2026-07-05 | Supersedes ADR 0032 with realm-as-control-plane architecture: one `d2bd` plus broker/socket/state/audit boundary per realm, strict parent/child routing, dynamic relay discovery, realm-qualified VM addresses, and migration from current `home`/`dev`/`work` groups into first-class realms. | | [0044. Unsafe-local runtime provider](0044-unsafe-local-runtime-provider.md) | Accepted | 2026-07-09 | Adds an explicit `unsafe-local` provider for host-user/session/network workloads with Wayland-proxy identity rails, first-class desktop launch metadata, and host-local persistent shell UX while preserving the no-isolation security warning. | +| [0045. Provider and transport framework](0045-provider-and-transport-framework.md) | Proposed | 2026-07-10 | Separates provider authorities from transport and protocol seams, selects Noise-authenticated component sessions with ttrpc/protobuf control services, models controllers as parent-owned workloads, and authorizes direct peer streams over shared transport fabrics without bypassing realm-tree policy. | diff --git a/docs/completions/d2b.bash b/docs/completions/d2b.bash index 496f6a1b5..949b6f17c 100644 --- a/docs/completions/d2b.bash +++ b/docs/completions/d2b.bash @@ -58,6 +58,9 @@ _d2b() { d2b,keys) cmd="d2b__subcmd__keys" ;; + d2b,launch) + cmd="d2b__subcmd__launch" + ;; d2b,list) cmd="d2b__subcmd__list" ;; @@ -238,6 +241,9 @@ _d2b() { d2b__subcmd__help,keys) cmd="d2b__subcmd__help__subcmd__keys" ;; + d2b__subcmd__help,launch) + cmd="d2b__subcmd__help__subcmd__launch" + ;; d2b__subcmd__help,list) cmd="d2b__subcmd__help__subcmd__list" ;; @@ -704,7 +710,7 @@ _d2b() { case "${cmd}" in d2b) - opts="-h -V --help --version list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" + opts="-h -V --help --version list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1324,7 +1330,7 @@ _d2b() { return 0 ;; d2b__subcmd__help) - opts="list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" + opts="list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -1841,6 +1847,20 @@ _d2b() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + d2b__subcmd__help__subcmd__launch) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; d2b__subcmd__help__subcmd__list) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -2799,6 +2819,24 @@ _d2b() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + d2b__subcmd__launch) + opts="-h --item --json --human --help " + if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --item) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; d2b__subcmd__list) opts="-h --json --human --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then diff --git a/docs/completions/d2b.fish b/docs/completions/d2b.fish index db8cfc366..05fcfed3b 100644 --- a/docs/completions/d2b.fish +++ b/docs/completions/d2b.fish @@ -28,6 +28,7 @@ complete -c d2b -n "__fish_d2b_needs_command" -s h -l help -d 'Print help (see m complete -c d2b -n "__fish_d2b_needs_command" -s V -l version -d 'Print version' complete -c d2b -n "__fish_d2b_needs_command" -f -a "list" -d 'List declared VMs with daemon runtime state when d2bd is reachable' complete -c d2b -n "__fish_d2b_needs_command" -f -a "status" -d 'Show per-VM runtime status plus bridge health' +complete -c d2b -n "__fish_d2b_needs_command" -f -a "launch" -d 'Launch a trusted configured workload item through its runtime provider' complete -c d2b -n "__fish_d2b_needs_command" -f -a "usb" -d 'USB attach / detach / probe' complete -c d2b -n "__fish_d2b_needs_command" -f -a "console" -d 'Foreground serial console bridge for headless VMs' complete -c d2b -n "__fish_d2b_needs_command" -f -a "audio" -d 'Per-VM audio status and grant controls' @@ -64,6 +65,10 @@ complete -c d2b -n "__fish_d2b_using_subcommand status" -l json complete -c d2b -n "__fish_d2b_using_subcommand status" -l human complete -c d2b -n "__fish_d2b_using_subcommand status" -l check-bridges complete -c d2b -n "__fish_d2b_using_subcommand status" -s h -l help -d 'Print help' +complete -c d2b -n "__fish_d2b_using_subcommand launch" -l item -d 'Configured launcher item id. Omit to use the declared default or sole item' -r +complete -c d2b -n "__fish_d2b_using_subcommand launch" -l json -d 'Emit a structured JSON result' +complete -c d2b -n "__fish_d2b_using_subcommand launch" -l human -d 'Force human-readable output' +complete -c d2b -n "__fish_d2b_using_subcommand launch" -s h -l help -d 'Print help' complete -c d2b -n "__fish_d2b_using_subcommand usb; and not __fish_seen_subcommand_from attach detach probe security-key help" -s h -l help -d 'Print help' complete -c d2b -n "__fish_d2b_using_subcommand usb; and not __fish_seen_subcommand_from attach detach probe security-key help" -f -a "attach" -d 'Bind a host USB busid to a VM via the native daemon path' complete -c d2b -n "__fish_d2b_using_subcommand usb; and not __fish_seen_subcommand_from attach detach probe security-key help" -f -a "detach" -d 'Unbind a host USB busid from a VM via the native daemon path' @@ -423,36 +428,37 @@ complete -c d2b -n "__fish_d2b_using_subcommand clipboard; and __fish_seen_subco complete -c d2b -n "__fish_d2b_using_subcommand clipboard; and __fish_seen_subcommand_from arm" -s h -l help -d 'Print help (see more with \'--help\')' complete -c d2b -n "__fish_d2b_using_subcommand clipboard; and __fish_seen_subcommand_from help" -f -a "arm" -d 'Open the picker and request paste replay for the focused target' complete -c d2b -n "__fish_d2b_using_subcommand clipboard; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "list" -d 'List declared VMs with daemon runtime state when d2bd is reachable' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "status" -d 'Show per-VM runtime status plus bridge health' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "usb" -d 'USB attach / detach / probe' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "console" -d 'Foreground serial console bridge for headless VMs' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "audio" -d 'Per-VM audio status and grant controls' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "audit" -d 'Tail the broker audit log' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "host" -d 'Host-side preflight, install, doctor, and reconcile verbs' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "auth" -d 'Authorisation introspection' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "realm" -d 'Low-level realm gateway helpers' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "shell" -d 'Attach to or manage persistent named guest shells' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "op" -d 'Inspect current constellation operation and trace state' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "vm" -d 'Per-VM lifecycle verbs (start / stop / restart / list / status) plus the admin-only guest-control sub-verb `exec`, which runs commands or an interactive session inside a VM over the authenticated guest-control transport (no SSH)' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "up" -d 'Alias for `vm start `' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "down" -d 'Alias for `vm stop `' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "restart" -d 'Alias for `vm restart `' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "build" -d 'Non-destructive eval + build of the per-VM toplevel' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "generations" -d 'List current / booted / numbered generations for a VM' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "switch" -d 'Atomically activate a new per-VM closure' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "boot" -d 'Stage a per-VM closure for the next boot only' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "test" -d 'Activate a per-VM closure with rollback on reboot' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "rollback" -d 'Roll a VM back to its previous generation' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "gc" -d 'Garbage-collect the per-VM /nix/store hardlink farm' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "store" -d 'Store-view maintenance and verification' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "keys" -d 'Managed-key lifecycle (list / show / rotate)' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "trust" -d 'Trust a VM\'s host key on first use (TOFU)' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "rotate-known-host" -d 'Rotate the consumer\'s recorded known-host entry for a VM' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "migrate" -d 'Analyse the host config and emit a migration plan' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "config" -d 'Sync / review / approve a VM\'s guest-editable config (`guestConfigFile`): pull the operator\'s in-VM edits to a host-side staging file, diff them, and approve them' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "clipboard" -d 'Clipboard authority operations (picker-driven paste replay via d2b-clipd)' -complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "list" -d 'List declared VMs with daemon runtime state when d2bd is reachable' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "status" -d 'Show per-VM runtime status plus bridge health' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "launch" -d 'Launch a trusted configured workload item through its runtime provider' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "usb" -d 'USB attach / detach / probe' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "console" -d 'Foreground serial console bridge for headless VMs' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "audio" -d 'Per-VM audio status and grant controls' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "audit" -d 'Tail the broker audit log' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "host" -d 'Host-side preflight, install, doctor, and reconcile verbs' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "auth" -d 'Authorisation introspection' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "realm" -d 'Low-level realm gateway helpers' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "shell" -d 'Attach to or manage persistent named guest shells' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "op" -d 'Inspect current constellation operation and trace state' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "vm" -d 'Per-VM lifecycle verbs (start / stop / restart / list / status) plus the admin-only guest-control sub-verb `exec`, which runs commands or an interactive session inside a VM over the authenticated guest-control transport (no SSH)' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "up" -d 'Alias for `vm start `' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "down" -d 'Alias for `vm stop `' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "restart" -d 'Alias for `vm restart `' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "build" -d 'Non-destructive eval + build of the per-VM toplevel' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "generations" -d 'List current / booted / numbered generations for a VM' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "switch" -d 'Atomically activate a new per-VM closure' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "boot" -d 'Stage a per-VM closure for the next boot only' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "test" -d 'Activate a per-VM closure with rollback on reboot' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "rollback" -d 'Roll a VM back to its previous generation' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "gc" -d 'Garbage-collect the per-VM /nix/store hardlink farm' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "store" -d 'Store-view maintenance and verification' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "keys" -d 'Managed-key lifecycle (list / show / rotate)' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "trust" -d 'Trust a VM\'s host key on first use (TOFU)' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "rotate-known-host" -d 'Rotate the consumer\'s recorded known-host entry for a VM' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "migrate" -d 'Analyse the host config and emit a migration plan' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "config" -d 'Sync / review / approve a VM\'s guest-editable config (`guestConfigFile`): pull the operator\'s in-VM edits to a host-side staging file, diff them, and approve them' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "clipboard" -d 'Clipboard authority operations (picker-driven paste replay via d2b-clipd)' +complete -c d2b -n "__fish_d2b_using_subcommand help; and not __fish_seen_subcommand_from list status launch usb console audio audit host auth realm shell op vm up down restart build generations switch boot test rollback gc store keys trust rotate-known-host migrate config clipboard help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c d2b -n "__fish_d2b_using_subcommand help; and __fish_seen_subcommand_from usb" -f -a "attach" -d 'Bind a host USB busid to a VM via the native daemon path' complete -c d2b -n "__fish_d2b_using_subcommand help; and __fish_seen_subcommand_from usb" -f -a "detach" -d 'Unbind a host USB busid from a VM via the native daemon path' complete -c d2b -n "__fish_d2b_using_subcommand help; and __fish_seen_subcommand_from usb" -f -a "probe" -d 'List daemon-declared USBIP session claims and qemu-media USB candidates' diff --git a/docs/completions/d2b.zsh b/docs/completions/d2b.zsh index 20d30c9ea..0b5322b6e 100644 --- a/docs/completions/d2b.zsh +++ b/docs/completions/d2b.zsh @@ -47,6 +47,16 @@ _arguments "${_arguments_options[@]}" : \ '::vm:_default' \ && ret=0 ;; +(launch) +_arguments "${_arguments_options[@]}" : \ +'--item=[Configured launcher item id. Omit to use the declared default or sole item]:ITEM:_default' \ +'(--human)--json[Emit a structured JSON result]' \ +'(--json)--human[Force human-readable output]' \ +'-h[Print help]' \ +'--help[Print help]' \ +':target -- Canonical workload target or an unambiguous workload id:_default' \ +&& ret=0 +;; (usb) _arguments "${_arguments_options[@]}" : \ '-h[Print help]' \ @@ -1399,6 +1409,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(launch) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (usb) _arguments "${_arguments_options[@]}" : \ ":: :_d2b__subcmd__help__subcmd__usb_commands" \ @@ -1861,6 +1875,7 @@ _d2b_commands() { local commands; commands=( 'list:List declared VMs with daemon runtime state when d2bd is reachable' \ 'status:Show per-VM runtime status plus bridge health' \ +'launch:Launch a trusted configured workload item through its runtime provider' \ 'usb:USB attach / detach / probe' \ 'console:Foreground serial console bridge for headless VMs' \ 'audio:Per-VM audio status and grant controls' \ @@ -2140,6 +2155,7 @@ _d2b__subcmd__help_commands() { local commands; commands=( 'list:List declared VMs with daemon runtime state when d2bd is reachable' \ 'status:Show per-VM runtime status plus bridge health' \ +'launch:Launch a trusted configured workload item through its runtime provider' \ 'usb:USB attach / detach / probe' \ 'console:Foreground serial console bridge for headless VMs' \ 'audio:Per-VM audio status and grant controls' \ @@ -2379,6 +2395,11 @@ _d2b__subcmd__help__subcmd__keys__subcmd__show_commands() { local commands; commands=() _describe -t commands 'd2b help keys show commands' commands "$@" } +(( $+functions[_d2b__subcmd__help__subcmd__launch_commands] )) || +_d2b__subcmd__help__subcmd__launch_commands() { + local commands; commands=() + _describe -t commands 'd2b help launch commands' commands "$@" +} (( $+functions[_d2b__subcmd__help__subcmd__list_commands] )) || _d2b__subcmd__help__subcmd__list_commands() { local commands; commands=() @@ -2774,6 +2795,11 @@ _d2b__subcmd__keys__subcmd__show_commands() { local commands; commands=() _describe -t commands 'd2b keys show commands' commands "$@" } +(( $+functions[_d2b__subcmd__launch_commands] )) || +_d2b__subcmd__launch_commands() { + local commands; commands=() + _describe -t commands 'd2b launch commands' commands "$@" +} (( $+functions[_d2b__subcmd__list_commands] )) || _d2b__subcmd__list_commands() { local commands; commands=() diff --git a/docs/explanation/persistent-shells.md b/docs/explanation/persistent-shells.md index d7b74621f..6149eec6c 100644 --- a/docs/explanation/persistent-shells.md +++ b/docs/explanation/persistent-shells.md @@ -17,17 +17,23 @@ semantic ADR 0039 attach support lands. ## Persistence boundary -Persistent shell state belongs to the guest-local shell pool, not to the host -CLI process. A session is expected to survive: +Persistent shell state belongs to the target runtime, not to the host CLI +process. For a VM that runtime is the guest-local shell pool. For an +unsafe-local workload it is a separate user-scope supervisor that owns the PTY +and reconnect listener rather than the short-lived user helper. A session is +expected to survive: - the local CLI disconnecting; - the terminal window closing; -- guestd restart when guestd can adopt the still-running shell pool. +- guestd restart when guestd can adopt the still-running shell pool; +- unsafe-local helper or d2bd reconnect while the verified user scope and + supervisor remain alive. It is not expected to survive: - VM reboot or target workload recreation; - shell-pool daemon restart or loss; +- logout/termination of the non-lingering user manager for unsafe-local; - explicit `d2b shell kill --name `; - `exit` or `Ctrl-D` inside the shell. @@ -48,14 +54,34 @@ Persistent shells do not add TCP or UDP listeners, network ports, or network-bound debug/metrics surfaces. The host-to-guest path reuses the existing daemon public socket and authenticated guest-control transport. +Unsafe-local uses only same-UID Unix sockets. Its per-shell listener lives +beneath the validated user runtime directory and is not a root service, broker +operation, or per-VM unit. `d2bd` resolves the target and bundle-owned shell +policy, asks the exact requester-UID helper to create or reconnect, validates the +single connected terminal fd, and multiplexes it behind a fresh opaque public +attachment handle. Closing that public connection detaches the helper-owned +terminal stream; it does not kill the user-scope shell. + +Daemon and helper restarts are reconnect events. The daemon intentionally keeps +no persisted fd authority, while the helper snapshot revalidates the +user-scope `InvocationID`, cgroup, and supervisor status before adoption. +Ambiguous metadata is reported degraded and never triggers a broad kill. + ## Same-UID AF_UNIX boundary -Inside the guest, shpool exposes an AF_UNIX socket under the workload user's -runtime directory. Helpers that connect to that socket run as the workload UID. +Inside a guest, shpool exposes an AF_UNIX socket under the workload user's +runtime directory. Unsafe-local supervisors use the authenticated host user's +runtime directory for the equivalent reconnect boundary. Helpers that connect +to either socket run as the workload UID. The socket is a same-UID IPC boundary, not a cryptographic separation boundary: code already running as that workload user can potentially interact with the same shell pool. +For unsafe-local this is also the containment boundary: there is **no +containment from other processes running as the same host uid**. The transient +scope gives exact lifecycle ownership, not isolation. Persistence ends with the +user-manager lifetime because d2b does not enable linger. + For that reason, persistent shells are appropriate for a trusted workload-user environment. They are not a way to hide admin shell state from other code already executing as the same guest user. diff --git a/docs/explanation/unsafe-local-runtime.md b/docs/explanation/unsafe-local-runtime.md index 660fe090e..4de7577b7 100644 --- a/docs/explanation/unsafe-local-runtime.md +++ b/docs/explanation/unsafe-local-runtime.md @@ -42,6 +42,11 @@ events to the helper. The helper starts the graphical application only after the upstream and listener are ready, and considers launch successful only after the first client connects. An unsafe-local proxy requires an explicit compositor upstream; failure never retries the application against the compositor directly. +The blocked scope supervisor owns both proxy and application in one verified +user scope. It creates a randomized mode-`0700` directory below the validated +`XDG_RUNTIME_DIR`, places the proxy display and private readiness socket there, +and removes the directory after both children are reaped. Launch fails +immediately if the application exits while first-client readiness is pending. The clipboard bridge attributes an unsafe-local endpoint from that canonical target and provider identity. Window app ids and titles remain presentation diff --git a/docs/how-to/configure-desktop-terminal-integration.md b/docs/how-to/configure-desktop-terminal-integration.md index 9735b648b..683cff8d2 100644 --- a/docs/how-to/configure-desktop-terminal-integration.md +++ b/docs/how-to/configure-desktop-terminal-integration.md @@ -45,7 +45,7 @@ d2b.realms.work.workloads.shellbox = { ``` For the shell lifecycle model and CLI fallback commands, see -[Use persistent guest shells](./use-persistent-shells.md). +[Use persistent shells](./use-persistent-shells.md). ## Add aligned flake inputs @@ -76,16 +76,15 @@ desktop companions share the same `d2b-toolkit` input: weezterm = { url = "github:vicondoa/weezterm"; inputs.nixpkgs.follows = "nixpkgs"; - inputs.d2b-toolkit.follows = "d2b-toolkit"; }; }; } ``` -When iterating locally, replace the `url` values with `path:/...` but keep the -same `inputs..follows` lines. That keeps package builds, Home Manager -evaluation, and toolkit path-dependency rewrites on one nixpkgs and toolkit -revision. +When iterating locally, replace the `url` values with `path:/...` but keep each +input's existing `follows` lines. WeezTerm follows `nixpkgs` only because it +does not expose a toolkit input. This keeps package builds, Home Manager +evaluation, and toolkit path-dependency rewrites on compatible revisions. ## Import the Home Manager module diff --git a/docs/how-to/use-persistent-shells.md b/docs/how-to/use-persistent-shells.md index a12a7ae20..042c478e6 100644 --- a/docs/how-to/use-persistent-shells.md +++ b/docs/how-to/use-persistent-shells.md @@ -1,9 +1,9 @@ -# Use persistent guest shells +# Use persistent shells > Diataxis: how-to. Task-oriented operator guide for `d2b shell`. -Persistent shells let you reconnect to a named interactive shell inside a -running guest. Use them for long-lived interactive work. Use +Persistent shells let you reconnect to a named interactive shell in a local VM +or an explicitly unsafe-local workload. Use them for long-lived interactive work. Use `d2b vm exec -- ` for one-off commands. For the persistence model, local IPC boundary, and same-UID trust model, @@ -31,10 +31,46 @@ d2b.vms.work = { Switch the host configuration, then restart the affected VM so guestd sees the new shell policy. +## Enable an unsafe-local shell + +Unsafe-local runs the login shell directly as the authenticated host user and +provides no VM or same-UID containment: + +```nix +d2b.realms.host = { + allowedUsers = [ "alice" ]; + policy.allowUnsafeLocal = true; + workloads.tools = { + kind = "unsafe-local"; + shell = { + enable = true; + defaultName = "primary"; + maxSessions = 4; + }; + launcher.items.terminal = { + type = "shell"; + name = "Terminal"; + }; + }; +}; +``` + +Rebuild the host, log in through a PAM-backed session, and verify the user +helper is active: + +```bash +systemctl --user status d2b-unsafe-local-helper.service +d2b shell tools.host.d2b list +``` + +The daemon must negotiate `unsafe-local-shell-v1`. Version skew fails with an +update recommendation; there is no static, SSH, or host-shell fallback. + ## Attach to the default shell ```bash d2b shell work +d2b shell tools.host.d2b ``` The CLI prints the resolved session name before entering raw terminal mode. To @@ -117,3 +153,8 @@ work-gw$ d2b shell Persistent shells use a workload-user shpool socket inside the guest. Code already running as the same workload UID can reach that AF_UNIX socket. Do not co-locate untrusted same-UID services with persistent admin shells. + +Unsafe-local has the same trust limitation on the host uid. Its shell survives +CLI, d2bd, and helper reconnects while the verified transient user scope stays +alive. Logging out terminates the non-lingering user manager and its shells by +design. diff --git a/docs/manpages/d2b-clipboard-arm.1 b/docs/manpages/d2b-clipboard-arm.1 index 1b5069356..a84c8b799 100644 --- a/docs/manpages/d2b-clipboard-arm.1 +++ b/docs/manpages/d2b-clipboard-arm.1 @@ -4,7 +4,7 @@ .SH NAME arm \- Open the picker and request paste replay for the focused target .SH SYNOPSIS -\fBarm\fR [\fB\-\-json\fR] [\fB\-\-human\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBarm\fR [\fB\-\-json\fR] [\fB\-\-human\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Open the picker and request paste replay for the focused target. .PP diff --git a/docs/manpages/d2b-launch.1 b/docs/manpages/d2b-launch.1 new file mode 100644 index 000000000..b524e6312 --- /dev/null +++ b/docs/manpages/d2b-launch.1 @@ -0,0 +1,25 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH d2b-launch 1 1970-01-01 d2b "d2b CLI" +.SH NAME +launch \- Launch a trusted configured workload item through its runtime provider +.SH SYNOPSIS +\fBlaunch\fR [\fB\-\-item\fR] [\fB\-\-json\fR] [\fB\-\-human\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fITARGET\fR> +.SH DESCRIPTION +Launch a trusted configured workload item through its runtime provider +.SH OPTIONS +.TP +\fB\-\-item\fR \fI\fR +Configured launcher item id. Omit to use the declared default or sole item +.TP +\fB\-\-json\fR +Emit a structured JSON result +.TP +\fB\-\-human\fR +Force human\-readable output +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fITARGET\fR> +Canonical workload target or an unambiguous workload id diff --git a/docs/manpages/d2b-shell.1 b/docs/manpages/d2b-shell.1 index 3db7c4e47..b232f7df3 100644 --- a/docs/manpages/d2b-shell.1 +++ b/docs/manpages/d2b-shell.1 @@ -4,7 +4,7 @@ .SH NAME shell \- Attach to or manage persistent named guest shells .SH SYNOPSIS -\fBshell\fR [\fB\-\-name\fR] [\fB\-\-force\fR] [\fB\-\-json\fR] [\fB\-\-human\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fITARGET\fR> [\fIACTION\fR] +\fBshell\fR [\fB\-\-name\fR] [\fB\-\-force\fR] [\fB\-\-json\fR] [\fB\-\-human\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fITARGET\fR> [\fIACTION\fR] .SH DESCRIPTION Attach to or manage persistent named guest shells .SH OPTIONS diff --git a/docs/manpages/d2b.1 b/docs/manpages/d2b.1 index cd7febb57..8b8f244c1 100644 --- a/docs/manpages/d2b.1 +++ b/docs/manpages/d2b.1 @@ -8,7 +8,7 @@ d2b \- d2b — opinionated NixOS desktop microVM CLI. .SH DESCRIPTION d2b — daemon\-native CLI for d2b microVMs. .PP -All mutating verbs dispatch through d2bd and d2b\-priv\-broker. Read\-only verbs (list, status, audit, host check) prefer d2bd\*(Aqs public socket and fall back to static/local sources where documented. See `d2b \-\-help` for per\-verb usage. +Mutating verbs dispatch through d2bd; privileged host mutations additionally use d2b\-priv\-broker. Read\-only verbs (list, status, audit, host check) prefer d2bd\*(Aqs public socket and fall back to static/local sources where documented. See `d2b \-\-help` for per\-verb usage. .SH OPTIONS .TP \fB\-h\fR, \fB\-\-help\fR @@ -24,6 +24,9 @@ List declared VMs with daemon runtime state when d2bd is reachable d2b\-status(1) Show per\-VM runtime status plus bridge health .TP +d2b\-launch(1) +Launch a trusted configured workload item through its runtime provider +.TP d2b\-usb(1) USB attach / detach / probe .TP diff --git a/docs/reference/cli-contract.md b/docs/reference/cli-contract.md index ceb779194..f97b0da54 100644 --- a/docs/reference/cli-contract.md +++ b/docs/reference/cli-contract.md @@ -52,34 +52,100 @@ removed. See [Realm access resolver contract](./realm-access-resolver.md) for the target grammar, direct host-local socket binding, capability preflight, and typed denial shapes. -Current CLI VM lifecycle and exec commands still operate through the global -`d2bd` public socket and existing VM names unless a command section explicitly -documents a realm-aware backend. The resolver contract is documentation and DTO -shape for future routing; it does not make identity, relay, provider, or -realm-local lifecycle behavior available in this release. +VM lifecycle and arbitrary exec remain VM-only. A trusted unsafe-local target is +rejected before those handlers can coerce its workload id to a VM name. +Provider-neutral list, status, and configured launch use the workload operation +family on the local `d2bd` public socket. ### Configured launcher operation -The staged provider-neutral command contract is: +The provider-neutral command is: ```text d2b launch [--item ] ``` The public request carries only the canonical target, configured item id, and -an idempotency operation id. It never carries argv, uid, environment, cwd, or -display paths. An `exec` item dispatches through the selected provider; a -`shell` item dispatches persistent-shell semantics. When `--item` is omitted, -the daemon selects `defaultItem`, then an only item, otherwise returns the -available item ids and names. +an idempotency operation id. It never carries argv, uid, environment, cwd, +display paths, process ids, or unit names. An `exec` item dispatches through the +selected provider. A local-VM `shell` item dispatches existing persistent-shell +semantics. An unsafe-local `shell` item requires `unsafe-local-shell-v1` and +invokes `d2b shell` with the workload's canonical target; there is no host-shell +or SSH fallback. When `--item` is omitted, the CLI selects `defaultItem`, then +an only item, otherwise returns the available item ids and names. + +For local-VM exec items, d2bd derives an opaque guest exec id from the +authenticated requester, operation id, target, and item id. Guestd persists that +id with the detached exec record, so replay after a daemon restart returns the +existing exec instead of spawning a duplicate. A replay whose trusted argv hash +does not match fails closed. + +The DTOs remain protocol version 3 and are gated by `configured-launch-v1`. +Unsafe-local additionally requires `unsafe-local-provider-v1`; shell items also +require `unsafe-local-shell-v1`. Unsupported peers return an update remediation +and never fall back. -The DTOs are frozen under protocol version 3 and gated by -`configured-launch-v1`; the command is not advertised until the daemon runtime -lands. Unsupported peers return a typed capability refusal and never fall back -to unsafe-local. See [Unsafe-local provider contract](./unsafe-local-provider.md). +**Exit codes** + +| Code | Meaning | +| --- | --- | +| `0` | Launch committed or was already committed for the operation id. | +| `2` | Target/item not found, or omitted item is ambiguous. | +| `31` / `75` | Caller lacks launcher/admin authority or the operation is temporarily busy. | +| `69` | Provider prerequisite or transport unavailable. | +| `70` | Capability unavailable, provider mismatch, or required feature/version skew. | +| `76` | Protocol response or operation-id conflict. | ## Command reference +### `launch` + +**Synopsis:** `d2b launch [--item ] [--human] [--json]` + +**Flags** + +| Flag | Type | Default | Semantics | +| --- | --- | --- | --- | +| `--item ` | string | unset | Select the configured launcher item id. When omitted, use `defaultItem`, then a sole item, otherwise return the available ids and names. | +| `--json` | boolean | `false` | Emit the stable machine-readable launch result on stdout. | +| `--human` | boolean | `false` | Force the human launch confirmation on stdout. | + +**Arguments** + +| Argument | Semantics | +| --- | --- | +| `TARGET` | Canonical workload target or an unambiguous workload id. | + +**Exit codes** + +| Code | Meaning | Typed error / reference | +| --- | --- | --- | +| `0` | Launch committed or was already committed. | — | +| `2` | Target/item not found, or omitted item is ambiguous. | [`usage`](./error-codes.md#usage) | +| `31` / `75` | Caller lacks launcher/admin authority or the operation is temporarily busy. | workload launch error | +| `69` | Provider prerequisite or transport unavailable. | workload launch error | +| `70` | Capability unavailable, provider mismatch, or required feature/version skew. | workload launch error | +| `76` | Protocol response or operation-id conflict. | workload launch error | + +**Human example** + +```text +$ d2b launch tools.host.d2b --item browser +launched tools.host.d2b item browser (committed) +``` + +**`--json` example** — schema: [`launch.schema.json`](./cli-output/launch.schema.json). + +```json +{ + "command": "launch", + "target": "tools.host.d2b", + "itemId": "browser", + "operationId": "launch-1234-5678", + "disposition": "committed" +} +``` + ### `list` **Synopsis:** `d2b list [--human] [--json]` @@ -2988,21 +3054,34 @@ verbs) are emitted on **stderr** for human output only, so they never perturb a - `detach` — detach a live/stale client without killing the shell; - `kill` — terminate a named shell session. -The first positional after `shell` is always a d2b target address. Current -local-shell-only generations accept declared local VM names as target addresses. +The first positional after `shell` is always a d2b target address. Declared +local VM names retain their existing behavior. Canonical direct-local workload +targets and unambiguous workload-id aliases resolve inside `d2bd`: transition +local VMs use `legacyVmName`, first-class local VMs use the workload id, and +unsafe-local targets stay canonical rather than being coerced to VM names. A local VM named `list`, `attach`, `detach`, or `kill` attaches by default; use `d2b shell ` for management. Command-like trailing words such as `d2b shell work htop` are rejected with a hint to use `d2b vm exec -- ` for one-off commands. `shell` keeps declared local VM names on the local daemon public socket and the -authenticated guest-control terminal transport. Gateway-backed management forms +authenticated guest-control terminal transport. Unsafe-local targets use the +same public `ShellOp` shape, but d2bd resolves bundle-owned policy and the exact +requester-UID helper, then multiplexes the validated helper terminal fd behind +an opaque public attachment handle. List/detach/kill use helper management +operations. Disconnect and `closeAttach` detach only; kill tears down only the +verified shell scope. Gateway-backed management forms (`list`, `detach`, `kill`) resolve the local realm entrypoint, verify the gateway VM is running, and run the same `d2b shell ...` command inside the gateway VM over the typed `vm exec` guest-control path. The host does not load realm credentials, provider transports, raw guest-control frames, SSH, or provider-native shell APIs. +All shell actions remain admin-only. Launcher authorization for configured exec +items does not extend to shell. Unsafe-local policy (`defaultName` and +`maxSessions`) never appears in the public request and cannot be supplied by a +client. + Interactive gateway `attach` is fail-closed in this generation with an actionable `gateway-shell-attach-unavailable` error. Use `d2b realm enter ` and run `d2b shell ` inside the @@ -3033,6 +3112,11 @@ $ d2b shell work attached to shell 'default' on vm 'work'; detach with Ctrl-Space Ctrl-q; exit or Ctrl-D ends the session ``` +```text +$ d2b shell tools.host.d2b +attached to shell 'primary' on vm 'tools.host.d2b'; detach with Ctrl-Space Ctrl-q; exit or Ctrl-D ends the session +``` + ```text $ d2b shell work list NAME STATE ATTACHED DEFAULT @@ -3077,8 +3161,9 @@ default detached false true } ``` -The JSON field remains named `vm` for the current schema. For local targets it -contains the resolved local routed VM name. Gateway-backed management commands +The JSON field remains named `vm` for the current schema. For local VM targets +it contains the resolved backing VM name; for unsafe-local it carries the +configured canonical workload target. Gateway-backed management commands forward the requested target through the selected gateway; the in-gateway response keeps its own current schema until a future output-version bump can rename this field to `target`. @@ -3090,16 +3175,21 @@ rename this field to `target`. | `0` | Success, including idempotent detach/kill no-op results. | | `1` | Unexpected daemon reply or local protocol/serialization failure. | | `2` | Usage error, invalid flag combination, missing required `--name` for kill, invalid shell name, non-TTY attach, or gateway-backed interactive attach before semantic shell attach support lands. | -| `69` | Local daemon public socket unavailable for shell dispatch. | -| `70` | Daemon generation does not support persistent shell operations. | -| `75` | Daemon admin authorization failed before guest contact. | +| `42` | Internal scope/daemon failure. | +| `69` | Daemon/helper/user-manager/terminal transport unavailable or timed out. | +| `70` | Required shell capability or `unsafe-local-shell-v1` is unavailable. | +| `75` | Admin authorization failed, another attachment owns the shell, or capacity is exhausted. | +| `76` | Protocol, operation-correlation, name, cursor-gap, offset, or terminal-size failure. | +| `77` | Stale public attachment handle or authenticated guest session. | **Redaction** Shell management JSON may include validated shell names because they are operator-facing identifiers. Daemon audit records use a fixed shell correlation -digest; metrics labels never carry shell names, terminal session handles, attach -ids, helper diagnostics, paths, argv, env, or terminal bytes. +digest and may include the configured canonical target and peer uid; metrics +labels are closed provider/component/operation/outcome/error enums. Neither +surface carries shell names, terminal session handles, helper diagnostics, +supervisor metadata, paths, argv, env, cwd, transcripts, or terminal bytes. ### `vm exec` @@ -3398,3 +3488,4 @@ detached state lives in guestd's detached registry). | `migrate` | `rust-native` | Dry-run analysis is native; `--apply` routes through `d2bd` → broker `RunMigrate`. Daemon-unreachable / native-handler-deferred conditions surface typed envelopes (exit `1` / exit `78` per ADR 0015); the historical bash fallback was retired in v1.0. | | `auth status` | `rust-native` | Auth status is a read-only daemon query that reports caller mapping, socket reachability, and authorization hints. | | `vm exec` | `rust-native` | Daemon public socket → authenticated guest-control session → `guestd` exec RPCs. Admin-only; no SSH, no host PTY, no new privileged broker op. Attached exec uses the in-process `d2bd` session table; detached exec uses guestd's detached registry and VM-first management verbs. | +| `shell` | `rust-native` | Admin-only provider-neutral `ShellOp`: local VMs use authenticated guest-control; unsafe-local uses the exact requester-UID helper and a multiplexed terminal fd. No SSH, host-shell fallback, root unit, per-VM service, or broker op. | diff --git a/docs/reference/cli-output/launch.schema.json b/docs/reference/cli-output/launch.schema.json new file mode 100644 index 000000000..801be48c2 --- /dev/null +++ b/docs/reference/cli-output/launch.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "LaunchOutputV1", + "type": "object", + "required": [ + "command", + "disposition", + "itemId", + "operationId", + "target" + ], + "properties": { + "command": { + "type": "string" + }, + "disposition": { + "$ref": "#/definitions/LauncherExecDisposition" + }, + "itemId": { + "$ref": "#/definitions/ProtocolToken" + }, + "operationId": { + "$ref": "#/definitions/OperationId" + }, + "target": { + "$ref": "#/definitions/RealmTarget" + } + }, + "additionalProperties": false, + "definitions": { + "LauncherExecDisposition": { + "type": "string", + "enum": [ + "committed", + "already-committed" + ] + }, + "OperationId": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "ProtocolToken": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[\\x21-\\x7e]+$" + }, + "RealmTarget": { + "type": "string", + "maxLength": 388, + "minLength": 1, + "pattern": "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+\\.d2b$" + } + } +} diff --git a/docs/reference/daemon-api.md b/docs/reference/daemon-api.md index ee4abe11e..5595f7b0c 100644 --- a/docs/reference/daemon-api.md +++ b/docs/reference/daemon-api.md @@ -77,21 +77,58 @@ compatibility surface. | --- | --- | --- | --- | | `FeatureFlag` | struct | [`FeatureFlag`](../../packages/d2b-contracts/src/lib.rs#L100) | empty struct | | `GuestCapability` | enum | [`GuestCapability`](../../packages/d2b-contracts/src/guest_wire.rs#L351) | `Health`; `Capabilities`; `ExecAttached`; `ExecDetached`; `ExecTty`; `ExecLogs`; `TtyResize`; `Signals`; `ReadGuestFile`; `UsbipImport`; `ShellAttached`; `ShellManagement`; `ShellForceAttach`; `UsbipStatus`; `SystemActivation`; `AudioStatus`; `AudioSet` | -| `Hello` | struct | [`Hello`](../../packages/d2b-contracts/src/lib.rs#L181) | struct { `client_version`: `SemverRange`; `supported_features`: `Vec` } | -| `HelloOk` | struct | [`HelloOk`](../../packages/d2b-contracts/src/lib.rs#L189) | struct { `server_version`: `Version`; `selected_version`: `Version`; `capabilities`: `Vec` } | -| `HelloRejected` | struct | [`HelloRejected`](../../packages/d2b-contracts/src/lib.rs#L197) | struct { `reason`: `HelloRejectedReason` } | -| `HelloRejectedReason` | enum | [`HelloRejectedReason`](../../packages/d2b-contracts/src/lib.rs#L203) | `VersionMismatch`; `CapabilityNegotiationFailed`; `InternalError` | +| `Hello` | struct | [`Hello`](../../packages/d2b-contracts/src/lib.rs#L184) | struct { `client_version`: `SemverRange`; `supported_features`: `Vec` } | +| `HelloOk` | struct | [`HelloOk`](../../packages/d2b-contracts/src/lib.rs#L192) | struct { `server_version`: `Version`; `selected_version`: `Version`; `capabilities`: `Vec` } | +| `HelloRejected` | struct | [`HelloRejected`](../../packages/d2b-contracts/src/lib.rs#L200) | struct { `reason`: `HelloRejectedReason` } | +| `HelloRejectedReason` | enum | [`HelloRejectedReason`](../../packages/d2b-contracts/src/lib.rs#L206) | `VersionMismatch`; `CapabilityNegotiationFailed`; `InternalError` | | `HelloRequest` | struct | [`HelloRequest`](../../packages/d2b-contracts/src/broker_wire.rs#L494) | struct { `client_version`: `String`; `supported_features`: `Vec` } | | `HelloRequest` | struct | [`HelloRequest`](../../packages/d2b-contracts/src/guest_wire.rs#L581) | struct { `metadata`: `GuestRequestMetadata`; `host_nonce`: `GuestNonce`; `transcript_version`: `u32` } | | `HelloResponse` | struct | [`HelloResponse`](../../packages/d2b-contracts/src/broker_wire.rs#L584) | struct { `server_version`: `String`; `selected_version`: `String`; `capabilities`: `Vec` } | | `HelloResponse` | struct | [`HelloResponse`](../../packages/d2b-contracts/src/guest_wire.rs#L589) | struct { `guest_nonce`: `GuestNonce`; `guest_boot_id`: `GuestBootId`; `protocol_version`: `u32` } | -| `KnownFeatureFlag` | enum | [`KnownFeatureFlag`](../../packages/d2b-contracts/src/lib.rs#L153) | `TypedErrors`; `ManifestV04`; `StatusCheckBridges`; `ExportBrokerAudit`; `ConfiguredLaunchV1`; `UnsafeLocalProviderV1` | +| `KnownFeatureFlag` | enum | [`KnownFeatureFlag`](../../packages/d2b-contracts/src/lib.rs#L154) | `TypedErrors`; `ManifestV04`; `StatusCheckBridges`; `ExportBrokerAudit`; `ConfiguredLaunchV1`; `UnsafeLocalProviderV1`; `UnsafeLocalShellV1` | ## Public socket `/run/d2b/public.sock` is the operator-facing socket. +The feature-negotiated `workload` frame carries a `WorkloadOp` body and returns +`workloadResponse`. `List` and `Status` expose provider-neutral, argv-free +metadata. `LauncherExec` accepts only canonical target, item id, and operation +id. The daemon reloads and hash-verifies the trusted bundle, cross-checks public +item metadata against its private configured item, and then routes to +guest-control or the exact requester-UID unsafe-local helper. Remote/relay +principals, UID fields, argv, environment, cwd, proxy paths, process ids, unit +names, and fallback transports are not accepted. + +The existing protocol-v3 `shell` frame is provider-neutral without changing its +serde shape: the historical `vm` field carries either a local VM name or a +canonical target. `d2bd` resolves canonical and bare targets to local VM, +unsafe-local, or a typed capability refusal. Unsafe-local policy comes only from +the hash-verified private workload artifact. Attach dispatches a private helper +v2 request for the exact authenticated peer uid, validates one connected +terminal fd, generates a bounded opaque public session handle, and multiplexes +terminal-v1 operations over that fd. List/detach/kill use correlated helper +management responses. Public requests carry no policy, uid, argv, environment, +cwd, path, operation id, or provider override. + +All shell operations require the local admin role before target resolution or +helper/guest contact. `unsafe-local-shell-v1` is negotiated independently of +guest shell support; a client that does not negotiate it cannot reach the +unsafe-local route. Local VM shell behavior and protocol version 3 remain +unchanged. + +Local-VM launcher execution uses a deterministic opaque guest exec id derived +from the local peer uid and public operation identity. Guestd's durable detached +record is the restart-stable idempotency authority; the same id and argv hash +replay the existing exec, while a hash mismatch is rejected. + +Daemon audit records the authenticated `peer_uid` and public `operation_id` on +each configured-launch outcome. Local-VM events also carry the opaque guest +`exec_id` and emit the standard detached-create event with that id, allowing +the launch request and guest execution to be correlated without recording argv, +environment, cwd, or output. + - transport: non-abstract Unix-domain `SOCK_SEQPACKET` - path: `/run/d2b/public.sock` - mode: `0660` @@ -179,31 +216,31 @@ host reboot. | Type | Kind | Rust definition | Shape | | --- | --- | --- | --- | -| `PublicRequest` | enum | [`PublicRequest`](../../packages/d2b-contracts/src/public_wire.rs#L24) | `Capabilities`; `AuthStatus`; `List` — (ListRequest); `Status` — (StatusRequest); `Audit` — (AuditRequest); `HostCheck` — (HostCheckRequest); `VmStart` — (VmLifecycleRequest); `VmStop` — (VmLifecycleRequest); `VmRestart` — (VmLifecycleRequest); `Switch` — (ActivationRequest); `Boot` — (ActivationRequest); `Test` — (ActivationRequest); `Rollback` — (ActivationRequest); `Gc` — (GcRequest); `KeysList`; `KeysShow` — (KeysShowRequest); `KeysRotate` — (KeysRotateRequest); `Trust` — (TrustRequest); `RotateKnownHost` — (RotateKnownHostRequest); `UsbipBind` — (UsbipBindCliRequest); `UsbipUnbind` — (UsbipUnbindCliRequest); `UsbipProbe`; `StoreVerify` — (StoreVerifyRequest); `Migrate` — (MigrateRequest); `HostPrepare` — (HostPrepareRequest); `HostDestroy` — (HostDestroyRequest); `HostInstall` — (HostInstallRequest); `HostReconcile` — (HostReconcileRequest); `ReadGuestConfig` — (ReadGuestConfigRequest); `Exec` — (ExecOp); `Shell` — (ShellOp); `Console` — (ConsoleOp); `Audio` — (AudioOp); `GatewayDisplay` — (GatewayDisplayOp); `UsbSecurityKeyStatus`; `UsbSecurityKeySessions`; `UsbSecurityKeyCancel` — (crate::security_key::SecurityKeyCancelRequest) | -| `ListRequest` | struct | [`ListRequest`](../../packages/d2b-contracts/src/public_wire.rs#L428) | struct { `env`: `Option`; `vm`: `Option` } | -| `StatusRequest` | struct | [`StatusRequest`](../../packages/d2b-contracts/src/public_wire.rs#L435) | struct { `check_bridges`: `bool`; `vm`: `Option` } | -| `AuditRequest` | struct | [`AuditRequest`](../../packages/d2b-contracts/src/public_wire.rs#L443) | struct { `filter`: `Option`; `format`: `AuditFormat`; `since`: `Option` } | -| `HostCheckRequest` | struct | [`HostCheckRequest`](../../packages/d2b-contracts/src/public_wire.rs#L452) | struct { `read_only`: `bool`; `strict`: `bool` } | -| `VmLifecycleRequest` | struct | [`VmLifecycleRequest`](../../packages/d2b-contracts/src/public_wire.rs#L478) | struct { `vm`: `String`; `flags`: `MutationFlags`; `force`: `bool`; `no_wait_api`: `bool` } | -| `ActivationRequest` | struct | [`ActivationRequest`](../../packages/d2b-contracts/src/public_wire.rs#L498) | struct { `vm`: `String`; `flags`: `MutationFlags` } | -| `GcRequest` | struct | [`GcRequest`](../../packages/d2b-contracts/src/public_wire.rs#L506) | struct { `flags`: `MutationFlags`; `keep_generations`: `Option` } | -| `KeysShowRequest` | struct | [`KeysShowRequest`](../../packages/d2b-contracts/src/public_wire.rs#L515) | struct { `vm`: `String` } | -| `KeysRotateRequest` | struct | [`KeysRotateRequest`](../../packages/d2b-contracts/src/public_wire.rs#L521) | struct { `vm`: `String`; `flags`: `MutationFlags` } | -| `TrustRequest` | struct | [`TrustRequest`](../../packages/d2b-contracts/src/public_wire.rs#L529) | struct { `vm`: `String`; `flags`: `MutationFlags` } | -| `RotateKnownHostRequest` | struct | [`RotateKnownHostRequest`](../../packages/d2b-contracts/src/public_wire.rs#L537) | struct { `vm`: `String`; `flags`: `MutationFlags` } | -| `UsbipBindCliRequest` | struct | [`UsbipBindCliRequest`](../../packages/d2b-contracts/src/public_wire.rs#L545) | struct { `vm`: `String`; `bus_id`: `String`; `flags`: `MutationFlags` } | -| `UsbipUnbindCliRequest` | struct | [`UsbipUnbindCliRequest`](../../packages/d2b-contracts/src/public_wire.rs#L554) | struct { `vm`: `String`; `bus_id`: `String`; `flags`: `MutationFlags` } | -| `StoreVerifyRequest` | struct | [`StoreVerifyRequest`](../../packages/d2b-contracts/src/public_wire.rs#L563) | struct { `vm`: `String`; `repair`: `bool` } | -| `ReadGuestConfigRequest` | struct | [`ReadGuestConfigRequest`](../../packages/d2b-contracts/src/public_wire.rs#L576) | struct { `vm`: `String` } | -| `MigrateRequest` | struct | [`MigrateRequest`](../../packages/d2b-contracts/src/public_wire.rs#L2035) | struct { `flags`: `MutationFlags` } | -| `HostPrepareRequest` | struct | [`HostPrepareRequest`](../../packages/d2b-contracts/src/public_wire.rs#L2042) | struct { `flags`: `MutationFlags` } | -| `HostDestroyRequest` | struct | [`HostDestroyRequest`](../../packages/d2b-contracts/src/public_wire.rs#L2049) | struct { `flags`: `MutationFlags` } | -| `HostInstallRequest` | struct | [`HostInstallRequest`](../../packages/d2b-contracts/src/public_wire.rs#L2056) | struct { `flags`: `MutationFlags`; `enable`: `bool`; `start`: `bool`; `no_start`: `bool` } | -| `HostReconcileRequest` | struct | [`HostReconcileRequest`](../../packages/d2b-contracts/src/public_wire.rs#L2074) | struct { `flags`: `MutationFlags`; `network`: `bool` } | -| `UsbSecurityKeyStatusRequest` | struct | [`UsbSecurityKeyStatusRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3685) | empty struct | -| `UsbSecurityKeySessionsRequest` | struct | [`UsbSecurityKeySessionsRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3771) | empty struct | -| `UsbSecurityKeyCancelRequest` | struct | [`UsbSecurityKeyCancelRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3825) | struct { `session_id`: `Option`; `current`: `bool` } | -| `UsbSecurityKeyTestRequest` | struct | [`UsbSecurityKeyTestRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3850) | struct { `vm`: `String` } | +| `PublicRequest` | enum | [`PublicRequest`](../../packages/d2b-contracts/src/public_wire.rs#L24) | `Capabilities`; `AuthStatus`; `List` — (ListRequest); `Status` — (StatusRequest); `Audit` — (AuditRequest); `HostCheck` — (HostCheckRequest); `VmStart` — (VmLifecycleRequest); `VmStop` — (VmLifecycleRequest); `VmRestart` — (VmLifecycleRequest); `Switch` — (ActivationRequest); `Boot` — (ActivationRequest); `Test` — (ActivationRequest); `Rollback` — (ActivationRequest); `Gc` — (GcRequest); `KeysList`; `KeysShow` — (KeysShowRequest); `KeysRotate` — (KeysRotateRequest); `Trust` — (TrustRequest); `RotateKnownHost` — (RotateKnownHostRequest); `UsbipBind` — (UsbipBindCliRequest); `UsbipUnbind` — (UsbipUnbindCliRequest); `UsbipProbe`; `StoreVerify` — (StoreVerifyRequest); `Migrate` — (MigrateRequest); `HostPrepare` — (HostPrepareRequest); `HostDestroy` — (HostDestroyRequest); `HostInstall` — (HostInstallRequest); `HostReconcile` — (HostReconcileRequest); `ReadGuestConfig` — (ReadGuestConfigRequest); `Exec` — (ExecOp); `Shell` — (ShellOp); `Console` — (ConsoleOp); `Audio` — (AudioOp); `GatewayDisplay` — (GatewayDisplayOp); `Workload` — (WorkloadOp); `UsbSecurityKeyStatus`; `UsbSecurityKeySessions`; `UsbSecurityKeyCancel` — (crate::security_key::SecurityKeyCancelRequest) | +| `ListRequest` | struct | [`ListRequest`](../../packages/d2b-contracts/src/public_wire.rs#L433) | struct { `env`: `Option`; `vm`: `Option` } | +| `StatusRequest` | struct | [`StatusRequest`](../../packages/d2b-contracts/src/public_wire.rs#L440) | struct { `check_bridges`: `bool`; `vm`: `Option` } | +| `AuditRequest` | struct | [`AuditRequest`](../../packages/d2b-contracts/src/public_wire.rs#L448) | struct { `filter`: `Option`; `format`: `AuditFormat`; `since`: `Option` } | +| `HostCheckRequest` | struct | [`HostCheckRequest`](../../packages/d2b-contracts/src/public_wire.rs#L457) | struct { `read_only`: `bool`; `strict`: `bool` } | +| `VmLifecycleRequest` | struct | [`VmLifecycleRequest`](../../packages/d2b-contracts/src/public_wire.rs#L483) | struct { `vm`: `String`; `flags`: `MutationFlags`; `force`: `bool`; `no_wait_api`: `bool` } | +| `ActivationRequest` | struct | [`ActivationRequest`](../../packages/d2b-contracts/src/public_wire.rs#L503) | struct { `vm`: `String`; `flags`: `MutationFlags` } | +| `GcRequest` | struct | [`GcRequest`](../../packages/d2b-contracts/src/public_wire.rs#L511) | struct { `flags`: `MutationFlags`; `keep_generations`: `Option` } | +| `KeysShowRequest` | struct | [`KeysShowRequest`](../../packages/d2b-contracts/src/public_wire.rs#L520) | struct { `vm`: `String` } | +| `KeysRotateRequest` | struct | [`KeysRotateRequest`](../../packages/d2b-contracts/src/public_wire.rs#L526) | struct { `vm`: `String`; `flags`: `MutationFlags` } | +| `TrustRequest` | struct | [`TrustRequest`](../../packages/d2b-contracts/src/public_wire.rs#L534) | struct { `vm`: `String`; `flags`: `MutationFlags` } | +| `RotateKnownHostRequest` | struct | [`RotateKnownHostRequest`](../../packages/d2b-contracts/src/public_wire.rs#L542) | struct { `vm`: `String`; `flags`: `MutationFlags` } | +| `UsbipBindCliRequest` | struct | [`UsbipBindCliRequest`](../../packages/d2b-contracts/src/public_wire.rs#L550) | struct { `vm`: `String`; `bus_id`: `String`; `flags`: `MutationFlags` } | +| `UsbipUnbindCliRequest` | struct | [`UsbipUnbindCliRequest`](../../packages/d2b-contracts/src/public_wire.rs#L559) | struct { `vm`: `String`; `bus_id`: `String`; `flags`: `MutationFlags` } | +| `StoreVerifyRequest` | struct | [`StoreVerifyRequest`](../../packages/d2b-contracts/src/public_wire.rs#L568) | struct { `vm`: `String`; `repair`: `bool` } | +| `ReadGuestConfigRequest` | struct | [`ReadGuestConfigRequest`](../../packages/d2b-contracts/src/public_wire.rs#L581) | struct { `vm`: `String` } | +| `MigrateRequest` | struct | [`MigrateRequest`](../../packages/d2b-contracts/src/public_wire.rs#L2040) | struct { `flags`: `MutationFlags` } | +| `HostPrepareRequest` | struct | [`HostPrepareRequest`](../../packages/d2b-contracts/src/public_wire.rs#L2047) | struct { `flags`: `MutationFlags` } | +| `HostDestroyRequest` | struct | [`HostDestroyRequest`](../../packages/d2b-contracts/src/public_wire.rs#L2054) | struct { `flags`: `MutationFlags` } | +| `HostInstallRequest` | struct | [`HostInstallRequest`](../../packages/d2b-contracts/src/public_wire.rs#L2061) | struct { `flags`: `MutationFlags`; `enable`: `bool`; `start`: `bool`; `no_start`: `bool` } | +| `HostReconcileRequest` | struct | [`HostReconcileRequest`](../../packages/d2b-contracts/src/public_wire.rs#L2079) | struct { `flags`: `MutationFlags`; `network`: `bool` } | +| `UsbSecurityKeyStatusRequest` | struct | [`UsbSecurityKeyStatusRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3695) | empty struct | +| `UsbSecurityKeySessionsRequest` | struct | [`UsbSecurityKeySessionsRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3781) | empty struct | +| `UsbSecurityKeyCancelRequest` | struct | [`UsbSecurityKeyCancelRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3835) | struct { `session_id`: `Option`; `current`: `bool` } | +| `UsbSecurityKeyTestRequest` | struct | [`UsbSecurityKeyTestRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3860) | struct { `vm`: `String` } | ### Broker socket request types @@ -305,28 +342,28 @@ see the auto-generated tables above for the committed Rust variants. | Type | Kind | Rust definition | Shape | | --- | --- | --- | --- | -| `PublicResponse` | enum | [`PublicResponse`](../../packages/d2b-contracts/src/public_wire.rs#L142) | `Capabilities` — (CapabilitiesResponse); `AuthStatus` — (AuthStatusResponse); `List` — (ListResponse); `Status` — (StatusResponse); `Audit` — (AuditResponse); `HostCheck` — (HostCheckResponse); `KeysList` — (KeysListResponse); `KeysShow` — (KeysShowResponse); `UsbipProbe` — (UsbipProbeResponse); `StoreVerify` — (StoreVerifyResponse); `MutatingVerb` — (MutatingVerbResponse); `ReadGuestConfig` — (ReadGuestConfigResponse); `Exec` — (ExecOpResponse); `Shell` — (ShellOpResponse); `Console` — (ConsoleOpResponse); `Audio` — (AudioOpResponse); `GatewayDisplay` — (GatewayDisplayOpResponse); `UsbSecurityKeyStatus` — (crate::security_key::SecurityKeyStatusResponse); `UsbSecurityKeySessions` — (crate::security_key::SecurityKeySessionsResponse); `UsbSecurityKeyCancel` — (crate::security_key::SecurityKeyCancelResponse); `Error` — (Error) | -| `WorkloadOpResponse` | enum | [`WorkloadOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L205) | `List` — (WorkloadListResult); `Status` — (Box); `LauncherExec` — (LauncherExecResult) | -| `GatewayDisplayOpResponse` | enum | [`GatewayDisplayOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L360) | `Start` — (GatewayDisplayStartResult); `Stop` — (GatewayDisplayStopResult); `Open` — (GatewayDisplayOpenResult); `Close` — (GatewayDisplayCloseResult); `List` — (GatewayDisplayListResult); `ListDetailed` — (GatewayDisplayListDetailedResult) | -| `ReadGuestConfigResponse` | struct | [`ReadGuestConfigResponse`](../../packages/d2b-contracts/src/public_wire.rs#L588) | struct { `content_base64`: `String` } | -| `ExecOpResponse` | enum | [`ExecOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L1228) | `Start` — (ExecStartResult); `DetachedCreate` — (ExecDetachedCreateResult); `WriteStdin` — (ExecWriteStdinResult); `ReadOutput` — (ExecReadOutputResult); `Signal` — (ExecControlResult); `Resize` — (ExecControlResult); `Wait` — (ExecWaitResult); `Close` — (ExecCloseResult); `List` — (ExecDetachedListResult); `Logs` — (ExecDetachedLogsResult); `Status` — (ExecDetachedStatusResult); `Kill` — (ExecDetachedKillResult) | -| `ShellOpResponse` | enum | [`ShellOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L1527) | `Attach` — (ShellAttachResult); `WriteStdin` — (crate::terminal_wire::TerminalWriteStdinResult); `ReadOutput` — (crate::terminal_wire::TerminalReadOutputChunk); `Resize` — (crate::terminal_wire::TerminalControlResult); `Wait` — (crate::terminal_wire::TerminalWaitResult); `CloseStdin` — (crate::terminal_wire::TerminalCloseResult); `CloseAttach` — (ShellDetachResult); `List` — (ShellListResult); `Detach` — (ShellDetachResult); `Kill` — (ShellKillResult) | -| `ConsoleOpResponse` | enum | [`ConsoleOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L1820) | `Attach` — (ConsoleAttachResult); `WriteStdin` — (ConsoleControlResult); `ReadOutput` — (ConsoleReadOutputResult); `Resize` — (ConsoleControlResult); `Wait` — (ConsoleWaitResult); `Close` — (ConsoleCloseResult) | -| `AudioOpResponse` | enum | [`AudioOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2025) | `Status` — (AudioStatusResult); `SetVolume` — (AudioSetResult); `Mute` — (AudioSetResult) | -| `MutatingVerbResponse` | struct | [`MutatingVerbResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2107) | struct { `verb`: `String`; `outcome`: `MutatingVerbOutcome`; `target_wave`: `Option`; `summary`: `Option`; `remediation`: `Option`; `api_ready`: `Option` } | -| `CapabilitiesResponse` | struct | [`CapabilitiesResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2133) | struct { `broker_socket`: `String`; `capabilities`: `Vec`; `public_socket`: `String`; `server_version`: `Version`; `selected_version`: `Version` } | -| `AuthStatusResponse` | struct | [`AuthStatusResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2143) | struct { `allowed_subcommands`: `Vec`; `denied_subcommands`: `Vec`; `role`: `AuthRole`; `sockets`: `Vec` } | -| `ListResponse` | struct | [`ListResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2152) | struct { `vms`: `Vec`; `read_model`: `Option` } | -| `StatusResponse` | struct | [`StatusResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2160) | struct { `entries`: `Vec`; `read_model`: `Option` } | -| `AuditResponse` | struct | [`AuditResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2180) | struct { `entries`: `Vec` } | -| `HostCheckResponse` | struct | [`HostCheckResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2186) | struct { `exit_code`: `u8`; `findings`: `Vec` } | -| `KeysListResponse` | struct | [`KeysListResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2204) | struct { `entries`: `Vec` } | -| `KeysShowResponse` | struct | [`KeysShowResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2210) | struct { `vm`: `String`; `env`: `Option`; `managed_key_path`: `String`; `public_key`: `String`; `fingerprint`: `String`; `known_hosts_entry`: `Option` } | -| `UsbipProbeResponse` | struct | [`UsbipProbeResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2451) | struct { `entries`: `Vec` } | -| `UsbSecurityKeyStatusResponse` | struct | [`UsbSecurityKeyStatusResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3756) | struct { `host_proxy_enabled`: `bool`; `physical_keys`: `Vec`; `vm_devices`: `Vec`; `lease`: `UsbSkLeaseStatus` } | -| `UsbSecurityKeySessionsResponse` | struct | [`UsbSecurityKeySessionsResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3818) | struct { `sessions`: `Vec` } | -| `UsbSecurityKeyCancelResponse` | struct | [`UsbSecurityKeyCancelResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3836) | struct { `cancelled_session_id`: `Option`; `already_idle`: `bool` } | -| `UsbSecurityKeyTestResponse` | struct | [`UsbSecurityKeyTestResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3873) | struct { `vm`: `String`; `ok`: `bool`; `checks`: `Vec` } | +| `PublicResponse` | enum | [`PublicResponse`](../../packages/d2b-contracts/src/public_wire.rs#L145) | `Capabilities` — (CapabilitiesResponse); `AuthStatus` — (AuthStatusResponse); `List` — (ListResponse); `Status` — (StatusResponse); `Audit` — (AuditResponse); `HostCheck` — (HostCheckResponse); `KeysList` — (KeysListResponse); `KeysShow` — (KeysShowResponse); `UsbipProbe` — (UsbipProbeResponse); `StoreVerify` — (StoreVerifyResponse); `MutatingVerb` — (MutatingVerbResponse); `ReadGuestConfig` — (ReadGuestConfigResponse); `Exec` — (ExecOpResponse); `Shell` — (ShellOpResponse); `Console` — (ConsoleOpResponse); `Audio` — (AudioOpResponse); `GatewayDisplay` — (GatewayDisplayOpResponse); `Workload` — (WorkloadOpResponse); `UsbSecurityKeyStatus` — (crate::security_key::SecurityKeyStatusResponse); `UsbSecurityKeySessions` — (crate::security_key::SecurityKeySessionsResponse); `UsbSecurityKeyCancel` — (crate::security_key::SecurityKeyCancelResponse); `Error` — (Error) | +| `WorkloadOpResponse` | enum | [`WorkloadOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L210) | `List` — (WorkloadListResult); `Status` — (Box); `LauncherExec` — (LauncherExecResult) | +| `GatewayDisplayOpResponse` | enum | [`GatewayDisplayOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L365) | `Start` — (GatewayDisplayStartResult); `Stop` — (GatewayDisplayStopResult); `Open` — (GatewayDisplayOpenResult); `Close` — (GatewayDisplayCloseResult); `List` — (GatewayDisplayListResult); `ListDetailed` — (GatewayDisplayListDetailedResult) | +| `ReadGuestConfigResponse` | struct | [`ReadGuestConfigResponse`](../../packages/d2b-contracts/src/public_wire.rs#L593) | struct { `content_base64`: `String` } | +| `ExecOpResponse` | enum | [`ExecOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L1233) | `Start` — (ExecStartResult); `DetachedCreate` — (ExecDetachedCreateResult); `WriteStdin` — (ExecWriteStdinResult); `ReadOutput` — (ExecReadOutputResult); `Signal` — (ExecControlResult); `Resize` — (ExecControlResult); `Wait` — (ExecWaitResult); `Close` — (ExecCloseResult); `List` — (ExecDetachedListResult); `Logs` — (ExecDetachedLogsResult); `Status` — (ExecDetachedStatusResult); `Kill` — (ExecDetachedKillResult) | +| `ShellOpResponse` | enum | [`ShellOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L1532) | `Attach` — (ShellAttachResult); `WriteStdin` — (crate::terminal_wire::TerminalWriteStdinResult); `ReadOutput` — (crate::terminal_wire::TerminalReadOutputChunk); `Resize` — (crate::terminal_wire::TerminalControlResult); `Wait` — (crate::terminal_wire::TerminalWaitResult); `CloseStdin` — (crate::terminal_wire::TerminalCloseResult); `CloseAttach` — (ShellDetachResult); `List` — (ShellListResult); `Detach` — (ShellDetachResult); `Kill` — (ShellKillResult) | +| `ConsoleOpResponse` | enum | [`ConsoleOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L1825) | `Attach` — (ConsoleAttachResult); `WriteStdin` — (ConsoleControlResult); `ReadOutput` — (ConsoleReadOutputResult); `Resize` — (ConsoleControlResult); `Wait` — (ConsoleWaitResult); `Close` — (ConsoleCloseResult) | +| `AudioOpResponse` | enum | [`AudioOpResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2030) | `Status` — (AudioStatusResult); `SetVolume` — (AudioSetResult); `Mute` — (AudioSetResult) | +| `MutatingVerbResponse` | struct | [`MutatingVerbResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2112) | struct { `verb`: `String`; `outcome`: `MutatingVerbOutcome`; `target_wave`: `Option`; `summary`: `Option`; `remediation`: `Option`; `api_ready`: `Option` } | +| `CapabilitiesResponse` | struct | [`CapabilitiesResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2138) | struct { `broker_socket`: `String`; `capabilities`: `Vec`; `public_socket`: `String`; `server_version`: `Version`; `selected_version`: `Version` } | +| `AuthStatusResponse` | struct | [`AuthStatusResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2148) | struct { `allowed_subcommands`: `Vec`; `denied_subcommands`: `Vec`; `role`: `AuthRole`; `sockets`: `Vec` } | +| `ListResponse` | struct | [`ListResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2157) | struct { `vms`: `Vec`; `read_model`: `Option` } | +| `StatusResponse` | struct | [`StatusResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2165) | struct { `entries`: `Vec`; `read_model`: `Option` } | +| `AuditResponse` | struct | [`AuditResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2185) | struct { `entries`: `Vec` } | +| `HostCheckResponse` | struct | [`HostCheckResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2191) | struct { `exit_code`: `u8`; `findings`: `Vec` } | +| `KeysListResponse` | struct | [`KeysListResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2209) | struct { `entries`: `Vec` } | +| `KeysShowResponse` | struct | [`KeysShowResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2215) | struct { `vm`: `String`; `env`: `Option`; `managed_key_path`: `String`; `public_key`: `String`; `fingerprint`: `String`; `known_hosts_entry`: `Option` } | +| `UsbipProbeResponse` | struct | [`UsbipProbeResponse`](../../packages/d2b-contracts/src/public_wire.rs#L2456) | struct { `entries`: `Vec` } | +| `UsbSecurityKeyStatusResponse` | struct | [`UsbSecurityKeyStatusResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3766) | struct { `host_proxy_enabled`: `bool`; `physical_keys`: `Vec`; `vm_devices`: `Vec`; `lease`: `UsbSkLeaseStatus` } | +| `UsbSecurityKeySessionsResponse` | struct | [`UsbSecurityKeySessionsResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3828) | struct { `sessions`: `Vec` } | +| `UsbSecurityKeyCancelResponse` | struct | [`UsbSecurityKeyCancelResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3846) | struct { `cancelled_session_id`: `Option`; `already_idle`: `bool` } | +| `UsbSecurityKeyTestResponse` | struct | [`UsbSecurityKeyTestResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3883) | struct { `vm`: `String`; `ok`: `bool`; `checks`: `Vec` } | ### Broker socket response types @@ -430,7 +467,7 @@ running live guest activation. | Type | Kind | Rust definition | Shape | | --- | --- | --- | --- | -| `VmLifecycleState` | enum | [`VmLifecycleState`](../../packages/d2b-contracts/src/public_wire.rs#L2605) | `Stopped`; `Starting`; `Booted`; `Running`; `Stopping`; `Restarting`; `Failed`; `Unknown` | +| `VmLifecycleState` | enum | [`VmLifecycleState`](../../packages/d2b-contracts/src/public_wire.rs#L2610) | `Stopped`; `Starting`; `Booted`; `Running`; `Stopping`; `Restarting`; `Failed`; `Unknown` | ### Other documented enums @@ -482,55 +519,55 @@ running live guest activation. | `TerminalStatus` | enum | [`TerminalStatus`](../../packages/d2b-contracts/src/guest_wire.rs#L1454) | `ExitCode` — struct { `exit_code`: `i32` }; `Signal` — struct { `signal`: `u32` }; `StatusCode` — struct { `status_code`: `i32` }; `Error` — struct { `error`: `GuestControlErrorKind` } | | `SignalTarget` | enum | [`SignalTarget`](../../packages/d2b-contracts/src/guest_wire.rs#L1473) | `ForegroundProcessGroup`; `ProcessTree` | | `ExecCancelReason` | enum | [`ExecCancelReason`](../../packages/d2b-contracts/src/guest_wire.rs#L1480) | `ClientDisconnect`; `UserRequested`; `SlowConsumer`; `ProtocolError` | -| `KnownFeatureFlag` | enum | [`KnownFeatureFlag`](../../packages/d2b-contracts/src/lib.rs#L153) | `TypedErrors`; `ManifestV04`; `StatusCheckBridges`; `ExportBrokerAudit`; `ConfiguredLaunchV1`; `UnsafeLocalProviderV1` | -| `WorkloadOp` | enum | [`WorkloadOp`](../../packages/d2b-contracts/src/public_wire.rs#L197) | `List` — (WorkloadListArgs); `Status` — (WorkloadStatusArgs); `LauncherExec` — (LauncherExecArgs) | -| `WorkloadAvailability` | enum | [`WorkloadAvailability`](../../packages/d2b-contracts/src/public_wire.rs#L237) | `Ready`; `HelperUnavailable`; `HelperStale`; `UserManagerUnavailable`; `GraphicalSessionInactive`; `WaylandUnavailable`; `ProxyUnavailable`; `Degraded` | -| `GraphicalLaunchPosture` | enum | [`GraphicalLaunchPosture`](../../packages/d2b-contracts/src/public_wire.rs#L251) | `Proxied`; `NotApplicable`; `GraphicalSessionInactive`; `WaylandUnavailable`; `ProxyUnavailable` | -| `LauncherExecDisposition` | enum | [`LauncherExecDisposition`](../../packages/d2b-contracts/src/public_wire.rs#L292) | `Committed`; `AlreadyCommitted` | -| `GatewayDisplayOp` | enum | [`GatewayDisplayOp`](../../packages/d2b-contracts/src/public_wire.rs#L308) | `Start` — (GatewayDisplayStartArgs); `Stop` — (GatewayDisplayStopArgs); `Open` — (GatewayDisplayOpenArgs); `Close` — (GatewayDisplayCloseArgs); `List` — (GatewayDisplayListArgs); `ListDetailed` — (GatewayDisplayListArgs) | -| `ExecStream` | enum | [`ExecStream`](../../packages/d2b-contracts/src/public_wire.rs#L602) | `Stdout`; `Stderr` | -| `ExecOp` | enum | [`ExecOp`](../../packages/d2b-contracts/src/public_wire.rs#L893) | `Start` — (ExecStartArgs); `WriteStdin` — (ExecWriteStdinArgs); `ReadOutput` — (ExecReadOutputArgs); `Signal` — (ExecSignalArgs); `Resize` — (ExecResizeArgs); `Wait` — (ExecWaitArgs); `Close` — (ExecCloseArgs); `List` — (ExecDetachedListArgs); `Logs` — (ExecDetachedLogsArgs); `Status` — (ExecDetachedStatusArgs); `Kill` — (ExecDetachedKillArgs) | -| `ExecTerminalStatus` | enum | [`ExecTerminalStatus`](../../packages/d2b-contracts/src/public_wire.rs#L1009) | `Exited` — struct { `code`: `i32` }; `Signaled` — struct { `signal`: `u32` }; `Error` — struct { `slug`: `String` } | -| `ExecDetachedKillOutcome` | enum | [`ExecDetachedKillOutcome`](../../packages/d2b-contracts/src/public_wire.rs#L1200) | `Cancelling`; `AlreadyTerminal` | -| `ShellOp` | enum | [`ShellOp`](../../packages/d2b-contracts/src/public_wire.rs#L1394) | `Attach` — (ShellAttachArgs); `WriteStdin` — (crate::terminal_wire::TerminalWriteStdin); `ReadOutput` — (crate::terminal_wire::TerminalReadOutput); `Resize` — (crate::terminal_wire::TerminalResize); `Wait` — (crate::terminal_wire::TerminalWait); `CloseStdin` — (crate::terminal_wire::TerminalClose); `CloseAttach` — (ShellCloseAttachArgs); `List` — (ShellListArgs); `Detach` — (ShellDetachArgs); `Kill` — (ShellKillArgs) | -| `ShellSessionState` | enum | [`ShellSessionState`](../../packages/d2b-contracts/src/public_wire.rs#L1409) | `Attached`; `Detached`; `Killed`; `PoolUnavailable`; `FeatureDisabled`; `OutputGap` | -| `ShellCloseCause` | enum | [`ShellCloseCause`](../../packages/d2b-contracts/src/public_wire.rs#L1420) | `ClientDetach`; `EvictedByForce`; `EvictedByAdminDetach`; `KilledByAdmin`; `PoolUnavailable`; `OutputGap` | -| `ConsoleProviderKind` | enum | [`ConsoleProviderKind`](../../packages/d2b-contracts/src/public_wire.rs#L1545) | `LocalHypervisor`; `QemuMedia`; `AcaSandbox` | -| `ConsoleOp` | enum | [`ConsoleOp`](../../packages/d2b-contracts/src/public_wire.rs#L1688) | `Attach` — (ConsoleAttachArgs); `WriteStdin` — (ConsoleWriteStdinArgs); `ReadOutput` — (ConsoleReadOutputArgs); `Resize` — (ConsoleResizeArgs); `Wait` — (ConsoleWaitArgs); `Close` — (ConsoleCloseArgs) | -| `AudioChannel` | enum | [`AudioChannel`](../../packages/d2b-contracts/src/public_wire.rs#L1834) | `Speaker`; `Microphone` | -| `AudioEnforcementPosture` | enum | [`AudioEnforcementPosture`](../../packages/d2b-contracts/src/public_wire.rs#L1848) | `HostAndGuest`; `HostOnly`; `GuestOnly`; `Unsupported` | -| `AudioProviderKind` | enum | [`AudioProviderKind`](../../packages/d2b-contracts/src/public_wire.rs#L1889) | `LocalHypervisor`; `QemuMedia`; `AcaSandbox` | -| `AudioOp` | enum | [`AudioOp`](../../packages/d2b-contracts/src/public_wire.rs#L1934) | `Status` — (AudioStatusArgs); `SetVolume` — (AudioSetVolumeArgs); `Mute` — (AudioMuteArgs) | -| `AudioSetApplied` | enum | [`AudioSetApplied`](../../packages/d2b-contracts/src/public_wire.rs#L1997) | `HostAndGuest`; `HostOnly`; `GuestOnly`; `Unsupported` | -| `MutatingVerbOutcome` | enum | [`MutatingVerbOutcome`](../../packages/d2b-contracts/src/public_wire.rs#L2122) | `DryRunPlanned`; `Applied`; `ApiReadyTimeout`; `NotYetImplemented`; `BrokerError`; `InvalidRequest` | -| `UsbipProbeStatus` | enum | [`UsbipProbeStatus`](../../packages/d2b-contracts/src/public_wire.rs#L2222) | `Bound`; `Unbound`; `Degraded`; `Enrollable`; `Enrolled`; `Stale`; `DirectConfig`; `Unknown` | -| `UsbipDurableClaimState` | enum | [`UsbipDurableClaimState`](../../packages/d2b-contracts/src/public_wire.rs#L2240) | `Missing`; `HeldByDesiredOwner`; `HeldByOtherOwner`; `StaleOwner`; `Corrupt`; `NotApplicable`; `Unknown` | -| `UsbipHostBindState` | enum | [`UsbipHostBindState`](../../packages/d2b-contracts/src/public_wire.rs#L2265) | `Unbound`; `BoundToUsbipHost`; `BoundToUnexpectedDriver`; `DeviceMissing`; `NotApplicable`; `Unknown` | -| `UsbipHostCarrierState` | enum | [`UsbipHostCarrierState`](../../packages/d2b-contracts/src/public_wire.rs#L2278) | `Absent`; `Unavailable`; `WithheldForOwner`; `Ready`; `DepartedDuringProbe`; `NotApplicable`; `Unknown` | -| `UsbipProxyState` | enum | [`UsbipProxyState`](../../packages/d2b-contracts/src/public_wire.rs#L2292) | `NotDeclared`; `Stopped`; `Starting`; `Listening`; `Stale`; `Failed`; `NotApplicable`; `Unknown` | -| `UsbipGuestImportState` | enum | [`UsbipGuestImportState`](../../packages/d2b-contracts/src/public_wire.rs#L2315) | `Detached`; `Imported`; `Unavailable`; `NotApplicable`; `Unknown` | -| `UsbipTopologyState` | enum | [`UsbipTopologyState`](../../packages/d2b-contracts/src/public_wire.rs#L2333) | `Match`; `Mismatch`; `Incomplete`; `NotObserved`; `NotApplicable`; `Unknown` | -| `UsbipPolicyState` | enum | [`UsbipPolicyState`](../../packages/d2b-contracts/src/public_wire.rs#L2346) | `Allowed`; `Denied`; `Missing`; `NotApplicable`; `Unknown` | -| `UsbipProbeDegradedReasonCode` | enum | [`UsbipProbeDegradedReasonCode`](../../packages/d2b-contracts/src/public_wire.rs#L2365) | `PolicyFailed`; `DeviceDepartedBeforeClaim`; `DeviceDepartedAfterLock`; `DeviceDepartedDuringMutation`; `DeviceReappearedWithDifferentTopology`; `LockHeldByOtherOwner`; `InvalidPersistedLockClaim`; `CarrierUnavailable`; `HostBindUnavailable`; `ProxyUnavailable`; `GuestImportUnavailable`; `StaleHostState`; `StaleGuestState`; `ProbeIncomplete`; `Unknown` | -| `UsbProbeEntryKind` | enum | [`UsbProbeEntryKind`](../../packages/d2b-contracts/src/public_wire.rs#L2401) | `Usbip`; `QemuMediaSlot` | -| `AuditFormat` | enum | [`AuditFormat`](../../packages/d2b-contracts/src/public_wire.rs#L2465) | `Human`; `Json` | -| `AuthRole` | enum | [`AuthRole`](../../packages/d2b-contracts/src/public_wire.rs#L2473) | `None`; `Launcher`; `Admin` | -| `HostFindingSeverity` | enum | [`HostFindingSeverity`](../../packages/d2b-contracts/src/public_wire.rs#L2694) | `Pass`; `Warn`; `Fail` | -| `UsbSkLeaseState` | enum | [`UsbSkLeaseState`](../../packages/d2b-contracts/src/public_wire.rs#L3723) | `Idle`; `Active`; `Queued`; `Unknown` | -| `UsbSkSessionOutcome` | enum | [`UsbSkSessionOutcome`](../../packages/d2b-contracts/src/public_wire.rs#L3778) | `Success`; `Timeout`; `Cancelled`; `DeviceUnavailable`; `Active`; `Unknown` | +| `KnownFeatureFlag` | enum | [`KnownFeatureFlag`](../../packages/d2b-contracts/src/lib.rs#L154) | `TypedErrors`; `ManifestV04`; `StatusCheckBridges`; `ExportBrokerAudit`; `ConfiguredLaunchV1`; `UnsafeLocalProviderV1`; `UnsafeLocalShellV1` | +| `WorkloadOp` | enum | [`WorkloadOp`](../../packages/d2b-contracts/src/public_wire.rs#L202) | `List` — (WorkloadListArgs); `Status` — (WorkloadStatusArgs); `LauncherExec` — (LauncherExecArgs) | +| `WorkloadAvailability` | enum | [`WorkloadAvailability`](../../packages/d2b-contracts/src/public_wire.rs#L242) | `Ready`; `HelperUnavailable`; `HelperStale`; `UserManagerUnavailable`; `GraphicalSessionInactive`; `WaylandUnavailable`; `ProxyUnavailable`; `Degraded` | +| `GraphicalLaunchPosture` | enum | [`GraphicalLaunchPosture`](../../packages/d2b-contracts/src/public_wire.rs#L256) | `Proxied`; `NotApplicable`; `GraphicalSessionInactive`; `WaylandUnavailable`; `ProxyUnavailable` | +| `LauncherExecDisposition` | enum | [`LauncherExecDisposition`](../../packages/d2b-contracts/src/public_wire.rs#L297) | `Committed`; `AlreadyCommitted` | +| `GatewayDisplayOp` | enum | [`GatewayDisplayOp`](../../packages/d2b-contracts/src/public_wire.rs#L313) | `Start` — (GatewayDisplayStartArgs); `Stop` — (GatewayDisplayStopArgs); `Open` — (GatewayDisplayOpenArgs); `Close` — (GatewayDisplayCloseArgs); `List` — (GatewayDisplayListArgs); `ListDetailed` — (GatewayDisplayListArgs) | +| `ExecStream` | enum | [`ExecStream`](../../packages/d2b-contracts/src/public_wire.rs#L607) | `Stdout`; `Stderr` | +| `ExecOp` | enum | [`ExecOp`](../../packages/d2b-contracts/src/public_wire.rs#L898) | `Start` — (ExecStartArgs); `WriteStdin` — (ExecWriteStdinArgs); `ReadOutput` — (ExecReadOutputArgs); `Signal` — (ExecSignalArgs); `Resize` — (ExecResizeArgs); `Wait` — (ExecWaitArgs); `Close` — (ExecCloseArgs); `List` — (ExecDetachedListArgs); `Logs` — (ExecDetachedLogsArgs); `Status` — (ExecDetachedStatusArgs); `Kill` — (ExecDetachedKillArgs) | +| `ExecTerminalStatus` | enum | [`ExecTerminalStatus`](../../packages/d2b-contracts/src/public_wire.rs#L1014) | `Exited` — struct { `code`: `i32` }; `Signaled` — struct { `signal`: `u32` }; `Error` — struct { `slug`: `String` } | +| `ExecDetachedKillOutcome` | enum | [`ExecDetachedKillOutcome`](../../packages/d2b-contracts/src/public_wire.rs#L1205) | `Cancelling`; `AlreadyTerminal` | +| `ShellOp` | enum | [`ShellOp`](../../packages/d2b-contracts/src/public_wire.rs#L1399) | `Attach` — (ShellAttachArgs); `WriteStdin` — (crate::terminal_wire::TerminalWriteStdin); `ReadOutput` — (crate::terminal_wire::TerminalReadOutput); `Resize` — (crate::terminal_wire::TerminalResize); `Wait` — (crate::terminal_wire::TerminalWait); `CloseStdin` — (crate::terminal_wire::TerminalClose); `CloseAttach` — (ShellCloseAttachArgs); `List` — (ShellListArgs); `Detach` — (ShellDetachArgs); `Kill` — (ShellKillArgs) | +| `ShellSessionState` | enum | [`ShellSessionState`](../../packages/d2b-contracts/src/public_wire.rs#L1414) | `Attached`; `Detached`; `Killed`; `PoolUnavailable`; `FeatureDisabled`; `OutputGap` | +| `ShellCloseCause` | enum | [`ShellCloseCause`](../../packages/d2b-contracts/src/public_wire.rs#L1425) | `ClientDetach`; `EvictedByForce`; `EvictedByAdminDetach`; `KilledByAdmin`; `PoolUnavailable`; `OutputGap` | +| `ConsoleProviderKind` | enum | [`ConsoleProviderKind`](../../packages/d2b-contracts/src/public_wire.rs#L1550) | `LocalHypervisor`; `QemuMedia`; `AcaSandbox` | +| `ConsoleOp` | enum | [`ConsoleOp`](../../packages/d2b-contracts/src/public_wire.rs#L1693) | `Attach` — (ConsoleAttachArgs); `WriteStdin` — (ConsoleWriteStdinArgs); `ReadOutput` — (ConsoleReadOutputArgs); `Resize` — (ConsoleResizeArgs); `Wait` — (ConsoleWaitArgs); `Close` — (ConsoleCloseArgs) | +| `AudioChannel` | enum | [`AudioChannel`](../../packages/d2b-contracts/src/public_wire.rs#L1839) | `Speaker`; `Microphone` | +| `AudioEnforcementPosture` | enum | [`AudioEnforcementPosture`](../../packages/d2b-contracts/src/public_wire.rs#L1853) | `HostAndGuest`; `HostOnly`; `GuestOnly`; `Unsupported` | +| `AudioProviderKind` | enum | [`AudioProviderKind`](../../packages/d2b-contracts/src/public_wire.rs#L1894) | `LocalHypervisor`; `QemuMedia`; `AcaSandbox` | +| `AudioOp` | enum | [`AudioOp`](../../packages/d2b-contracts/src/public_wire.rs#L1939) | `Status` — (AudioStatusArgs); `SetVolume` — (AudioSetVolumeArgs); `Mute` — (AudioMuteArgs) | +| `AudioSetApplied` | enum | [`AudioSetApplied`](../../packages/d2b-contracts/src/public_wire.rs#L2002) | `HostAndGuest`; `HostOnly`; `GuestOnly`; `Unsupported` | +| `MutatingVerbOutcome` | enum | [`MutatingVerbOutcome`](../../packages/d2b-contracts/src/public_wire.rs#L2127) | `DryRunPlanned`; `Applied`; `ApiReadyTimeout`; `NotYetImplemented`; `BrokerError`; `InvalidRequest` | +| `UsbipProbeStatus` | enum | [`UsbipProbeStatus`](../../packages/d2b-contracts/src/public_wire.rs#L2227) | `Bound`; `Unbound`; `Degraded`; `Enrollable`; `Enrolled`; `Stale`; `DirectConfig`; `Unknown` | +| `UsbipDurableClaimState` | enum | [`UsbipDurableClaimState`](../../packages/d2b-contracts/src/public_wire.rs#L2245) | `Missing`; `HeldByDesiredOwner`; `HeldByOtherOwner`; `StaleOwner`; `Corrupt`; `NotApplicable`; `Unknown` | +| `UsbipHostBindState` | enum | [`UsbipHostBindState`](../../packages/d2b-contracts/src/public_wire.rs#L2270) | `Unbound`; `BoundToUsbipHost`; `BoundToUnexpectedDriver`; `DeviceMissing`; `NotApplicable`; `Unknown` | +| `UsbipHostCarrierState` | enum | [`UsbipHostCarrierState`](../../packages/d2b-contracts/src/public_wire.rs#L2283) | `Absent`; `Unavailable`; `WithheldForOwner`; `Ready`; `DepartedDuringProbe`; `NotApplicable`; `Unknown` | +| `UsbipProxyState` | enum | [`UsbipProxyState`](../../packages/d2b-contracts/src/public_wire.rs#L2297) | `NotDeclared`; `Stopped`; `Starting`; `Listening`; `Stale`; `Failed`; `NotApplicable`; `Unknown` | +| `UsbipGuestImportState` | enum | [`UsbipGuestImportState`](../../packages/d2b-contracts/src/public_wire.rs#L2320) | `Detached`; `Imported`; `Unavailable`; `NotApplicable`; `Unknown` | +| `UsbipTopologyState` | enum | [`UsbipTopologyState`](../../packages/d2b-contracts/src/public_wire.rs#L2338) | `Match`; `Mismatch`; `Incomplete`; `NotObserved`; `NotApplicable`; `Unknown` | +| `UsbipPolicyState` | enum | [`UsbipPolicyState`](../../packages/d2b-contracts/src/public_wire.rs#L2351) | `Allowed`; `Denied`; `Missing`; `NotApplicable`; `Unknown` | +| `UsbipProbeDegradedReasonCode` | enum | [`UsbipProbeDegradedReasonCode`](../../packages/d2b-contracts/src/public_wire.rs#L2370) | `PolicyFailed`; `DeviceDepartedBeforeClaim`; `DeviceDepartedAfterLock`; `DeviceDepartedDuringMutation`; `DeviceReappearedWithDifferentTopology`; `LockHeldByOtherOwner`; `InvalidPersistedLockClaim`; `CarrierUnavailable`; `HostBindUnavailable`; `ProxyUnavailable`; `GuestImportUnavailable`; `StaleHostState`; `StaleGuestState`; `ProbeIncomplete`; `Unknown` | +| `UsbProbeEntryKind` | enum | [`UsbProbeEntryKind`](../../packages/d2b-contracts/src/public_wire.rs#L2406) | `Usbip`; `QemuMediaSlot` | +| `AuditFormat` | enum | [`AuditFormat`](../../packages/d2b-contracts/src/public_wire.rs#L2470) | `Human`; `Json` | +| `AuthRole` | enum | [`AuthRole`](../../packages/d2b-contracts/src/public_wire.rs#L2478) | `None`; `Launcher`; `Admin` | +| `HostFindingSeverity` | enum | [`HostFindingSeverity`](../../packages/d2b-contracts/src/public_wire.rs#L2699) | `Pass`; `Warn`; `Fail` | +| `UsbSkLeaseState` | enum | [`UsbSkLeaseState`](../../packages/d2b-contracts/src/public_wire.rs#L3733) | `Idle`; `Active`; `Queued`; `Unknown` | +| `UsbSkSessionOutcome` | enum | [`UsbSkSessionOutcome`](../../packages/d2b-contracts/src/public_wire.rs#L3788) | `Success`; `Timeout`; `Cancelled`; `DeviceUnavailable`; `Active`; `Unknown` | | `SecurityKeyVmSessionState` | enum | [`SecurityKeyVmSessionState`](../../packages/d2b-contracts/src/security_key.rs#L165) | `Idle`; `AwaitingLease`; `Active`; `Completed`; `Cancelled` | | `SecurityKeySessionResult` | enum | [`SecurityKeySessionResult`](../../packages/d2b-contracts/src/security_key.rs#L230) | `InProgress`; `Success`; `CtapError`; `Timeout`; `Cancelled`; `InternalError` | | `SecurityKeyEvent` | enum | [`SecurityKeyEvent`](../../packages/d2b-contracts/src/security_key.rs#L290) | `SessionStarted` — struct { `session_id`: `SecurityKeySessionId`; `vm`: `String`; `device_label`: `SecurityKeyDeviceLabel`; `started_at`: `String` }; `SessionSucceeded` — struct { `session_id`: `SecurityKeySessionId`; `vm`: `String`; `device_label`: `SecurityKeyDeviceLabel`; `ended_at`: `String` }; `SessionFailed` — struct { `session_id`: `SecurityKeySessionId`; `vm`: `String`; `device_label`: `SecurityKeyDeviceLabel`; `result`: `SecurityKeySessionResult`; `ended_at`: `String` }; `SessionCancelled` — struct { `session_id`: `SecurityKeySessionId`; `vm`: `String`; `device_label`: `SecurityKeyDeviceLabel`; `ended_at`: `String` }; `DeviceRemoved` — struct { `device_label`: `SecurityKeyDeviceLabel`; `interrupted_session_id`: `Option` }; `DeviceReinserted` — struct { `device_label`: `SecurityKeyDeviceLabel` }; `SessionQueued` — struct { `session_id`: `SecurityKeySessionId`; `vm`: `String`; `device_label`: `SecurityKeyDeviceLabel`; `queued_at`: `String`; `blocking_vm`: `String` } | | `TerminalStream` | enum | [`TerminalStream`](../../packages/d2b-contracts/src/terminal_wire.rs#L19) | `Stdout`; `Stderr` | | `TerminalStatus` | enum | [`TerminalStatus`](../../packages/d2b-contracts/src/terminal_wire.rs#L197) | `Exited` — struct { `code`: `i32` }; `Signaled` — struct { `signal`: `u32` }; `Error` — struct { `slug`: `String` } | | `PathClass` | enum | [`PathClass`](../../packages/d2b-contracts/src/types.rs#L171) | `Vm`; `Runtime` | -| `HelperScopeKind` | enum | [`HelperScopeKind`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L53) | `LauncherApp`; `WaylandProxy`; `PersistentShell` | -| `HelperScopeState` | enum | [`HelperScopeState`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L77) | `Starting`; `Active`; `Stopping`; `Exited`; `Degraded` | -| `HelperFailureCode` | enum | [`HelperFailureCode`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L196) | `InvalidRequest`; `OperationIdConflict`; `QueueFull`; `Timeout`; `UserManagerUnavailable`; `EnvironmentInvalid`; `ExecutableUnavailable`; `ScopeCreateFailed`; `ScopeIdentityMismatch`; `GraphicalSessionInactive`; `WaylandUnavailable`; `ProxyUnavailable`; `FirstClientTimeout`; `ShellUnavailable`; `Internal` | -| `HelperOperationDisposition` | enum | [`HelperOperationDisposition`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L216) | `Committed`; `AlreadyCommitted`; `Completed` | -| `HelperTerminalTransport` | enum | [`HelperTerminalTransport`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L245) | `ConnectedUnixStream` | -| `DaemonToUnsafeLocalHelper` | enum | [`DaemonToUnsafeLocalHelper`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L264) | `HelloAccepted` — (HelperHelloAccepted); `Heartbeat` — (HelperHeartbeat); `Launch` — (HelperLaunchRequest); `Shell` — (HelperShellRequest) | -| `UnsafeLocalHelperToDaemon` | enum | [`UnsafeLocalHelperToDaemon`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L273) | `Hello` — (HelperHello); `Snapshot` — (HelperSnapshot); `Heartbeat` — (HelperHeartbeat); `Operation` — (HelperOperationResult); `TerminalReady` — (HelperTerminalReady); `Rejected` — (HelperOperationRejected) | +| `HelperScopeKind` | enum | [`HelperScopeKind`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L126) | `LauncherApp`; `WaylandProxy`; `PersistentShell` | +| `HelperScopeState` | enum | [`HelperScopeState`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L150) | `Starting`; `Active`; `Stopping`; `Exited`; `Degraded` | +| `HelperFailureCode` | enum | [`HelperFailureCode`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L503) | `InvalidRequest`; `OperationIdConflict`; `QueueFull`; `Timeout`; `UserManagerUnavailable`; `EnvironmentInvalid`; `ExecutableUnavailable`; `ScopeCreateFailed`; `ScopeIdentityMismatch`; `GraphicalSessionInactive`; `WaylandUnavailable`; `ProxyUnavailable`; `FirstClientTimeout`; `ShellUnavailable`; `ShellNotFound`; `ShellAlreadyAttached`; `TerminalOutputGap`; `TerminalOffsetMismatch`; `TerminalClosed`; `InvalidTerminalSize`; `Internal` | +| `HelperOperationDisposition` | enum | [`HelperOperationDisposition`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L529) | `Committed`; `AlreadyCommitted`; `Completed` | +| `HelperTerminalTransport` | enum | [`HelperTerminalTransport`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L572) | `ConnectedUnixStream` | +| `DaemonToUnsafeLocalHelper` | enum | [`DaemonToUnsafeLocalHelper`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L975) | `HelloAccepted` — (HelperHelloAccepted); `Heartbeat` — (HelperHeartbeat); `Launch` — (HelperLaunchRequest); `Shell` — (HelperShellRequest) | +| `UnsafeLocalHelperToDaemon` | enum | [`UnsafeLocalHelperToDaemon`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L984) | `Hello` — (HelperHello); `Snapshot` — (HelperSnapshot); `Heartbeat` — (HelperHeartbeat); `Operation` — (HelperOperationResult); `TerminalReady` — (HelperTerminalReady); `Shell` — (HelperShellResponse); `Rejected` — (HelperOperationRejected) | | `UsbipClaimSource` | enum | [`UsbipClaimSource`](../../packages/d2b-contracts/src/usbip.rs#L99) | `Declared` — struct { `firewall_ref`: `String`; `bind_ref`: `String` }; `Explicit` | @@ -573,7 +610,8 @@ The redaction policy is strict: public contract; - no secrets, stack traces, credential material, or raw command output; - no terminal payloads, argv/env/cwd, helper stderr/stdout, raw shell session - handles, or raw shell names in daemon metrics or broad debug/audit fields; + handles, raw shell names, supervisor ids, unit names, or helper diagnostics in + daemon metrics or broad debug/audit fields; - one human-readable remediation hint per failure; - one docs anchor into [`error-codes.md`](./error-codes.md). @@ -589,9 +627,9 @@ the failure class, for example `host check`, `audit`, `status`, or | `BrokerErrorResponse` | struct | [`BrokerErrorResponse`](../../packages/d2b-contracts/src/broker_wire.rs#L570) | struct { `kind`: `String`; `operation`: `String`; `target_wave`: `Option`; `message`: `String`; `action`: `String` } | | `GuestControlError` | struct | [`GuestControlError`](../../packages/d2b-contracts/src/guest_wire.rs#L1489) | struct { `kind`: `GuestControlErrorKind`; `remediation`: `HealthRemediation`; `retry_after_ms`: `Option` } | | `GuestControlErrorKind` | enum | [`GuestControlErrorKind`](../../packages/d2b-contracts/src/guest_wire.rs#L1497) | `ProtocolError`; `MaxChunkExceeded`; `StdinBackpressure`; `StdinClosed`; `StdinNotOpen`; `StdinClosedByProcess`; `StdinOffsetMismatch`; `StdinByteBudgetExhausted`; `OffsetExpired`; `OffsetInFuture`; `OffsetExhausted`; `OutputLost`; `TtyStderrUnavailable`; `TtyRequired`; `ExecCapacityExceeded`; `ExecAttachCapacityExceeded`; `ExecNotFound`; `ExecAlreadyExited`; `GuestExecDisabled`; `GuestExecRootDenied`; `GuestExecUserDenied`; `CwdInvalid`; `CwdDenied`; `RetainedLogPathUnsafe`; `RetainedLogQuotaExceeded`; `ReadWaitCapacityExceeded`; `WaitCapacityExceeded`; `SupersededReadWait`; `RateLimited`; `RequestIdConflict`; `ControlSeqMismatch`; `SlowConsumerCancelled`; `StaleSession`; `GuestControlUnavailableOldGeneration`; `AuthFailed`; `TransportUnreachable`; `ExecExpired`; `FileNotFound`; `FileTooLarge`; `PathUnsafe`; `ReadDenied`; `InvalidProgram`; `UsbipUnavailable`; `UsbipCommandFailed`; `UsbipInvalidBusId`; `UsbipInvalidHost`; `GuestShellDisabled`; `ShellInvalidName`; `ShellCapacityExceeded`; `ShellAttachCapacityExceeded`; `ShellNotFound`; `ShellAlreadyAttached`; `ShellPoolUnavailable`; `ShellDaemonEpochMismatch`; `ShellOutputGap`; `UsbipCommandTimeout`; `UsbipInvalidOutput`; `ActivationInvalidId`; `ActivationInvalidPath`; `ActivationInvalidMode`; `ActivationNotFound`; `ActivationStatusUnavailable`; `ActivationTimedOut`; `ActivationSpawnFailed`; `AudioPipeWireUnavailable`; `AudioChannelUnknown`; `AudioLevelOutOfRange`; `AudioEnforcementFailed` | -| `ShellNameError` | struct | [`ShellNameError`](../../packages/d2b-contracts/src/public_wire.rs#L1263) | empty struct | -| `AudioErrorKind` | enum | [`AudioErrorKind`](../../packages/d2b-contracts/src/public_wire.rs#L1870) | `ProviderMisconfigured`; `VmNotFound`; `EnforcementUnavailable`; `AudioNotEnabled`; `InternalError` | -| `AudioVmError` | struct | [`AudioVmError`](../../packages/d2b-contracts/src/public_wire.rs#L1971) | struct { `vm`: `String`; `kind`: `AudioErrorKind`; `remediation`: `Option` } | +| `ShellNameError` | struct | [`ShellNameError`](../../packages/d2b-contracts/src/public_wire.rs#L1268) | empty struct | +| `AudioErrorKind` | enum | [`AudioErrorKind`](../../packages/d2b-contracts/src/public_wire.rs#L1875) | `ProviderMisconfigured`; `VmNotFound`; `EnforcementUnavailable`; `AudioNotEnabled`; `InternalError` | +| `AudioVmError` | struct | [`AudioVmError`](../../packages/d2b-contracts/src/public_wire.rs#L1976) | struct { `vm`: `String`; `kind`: `AudioErrorKind`; `remediation`: `Option` } | | `BusIdError` | enum | [`BusIdError`](../../packages/d2b-contracts/src/usbip.rs#L22) | `Empty`; `Invalid`; `TooLong` — struct { `max`: `usize` } | @@ -697,12 +735,17 @@ helper reports tampering explicitly; the write path remains best-effort so an audit sink outage does not abort an already-authorized local operation. Persistent shell daemon events are in this daemon-owned stream, not the broker -audit log. The shell variants record only the VM name, admin peer uid, closed -action/result enums, force flag when relevant, and a fixed shell correlation -digest. Raw shell names, terminal session handles, terminal bytes, helper -diagnostics, argv, env, and paths are never written. Abrupt owner disconnects, -close timeouts, and stale/unsupported guest generations are represented by -closed result or typed-error buckets rather than free-form text. +audit log. The provider-neutral `ShellLifecycle` variant is the sole runtime +shell audit event for both `guest-control` and `unsafe-local`. It records only a +configured canonical target or local VM id, admin peer uid, closed +provider/action/result enums, optional force-takeover intent, and optional fixed +operation/session correlation digests. It covers create, attach, list, detach, +kill, close, and failure boundaries. Raw shell names, public handles, +supervisor ids, terminal bytes, helper diagnostics, argv, env, +cwd, PIDs, unit names, and paths are never written. Abrupt owner disconnects, +close timeouts, stale helper generations, and malformed terminal frames are +represented by closed results or typed-error buckets rather than free-form +text. Graceful-shutdown daemon events also use this daemon-owned stream. Before a normal stop sends CH `vm.shutdown` or broker-mediated QMP `system_powerdown`, diff --git a/docs/reference/daemon-metrics.md b/docs/reference/daemon-metrics.md index 530513f62..61497694a 100644 --- a/docs/reference/daemon-metrics.md +++ b/docs/reference/daemon-metrics.md @@ -216,6 +216,39 @@ declared schema; see "Cardinality bounds" below. provider credentials, process environments, working directories, helper diagnostics, and terminal bytes are never metric labels. +### `d2b_daemon_shell_lifecycle_total` + +- **Type:** counter +- **Labels:** `provider`, `component`, `operation`, `outcome`, `error_kind` +- **Meaning:** Provider-neutral persistent-shell lifecycle outcomes. Providers + are `guest-control` and `unsafe-local`; component is the closed value `shell`; + operations are `list`, `create`, `attach`, `detach`, `kill`, and `close`. + Outcomes and error kinds are closed daemon enums. No uid, target, shell name, + operation/session id, supervisor metadata, terminal bytes, helper diagnostic, + path, environment, or cwd is a label. The guest-control-specific counter above + remains available for compatibility. + +### `d2b_daemon_workload_availability` + +- **Type:** gauge +- **Labels:** `provider`, `component`, `state` +- **Meaning:** Count of workloads in the most recently observed inventory + snapshot for each closed provider/component/state tuple. An authorized + workload list or status request refreshes the complete inventory atomically, + including zero values for tuples no longer present. For unsafe-local + workloads, the snapshot reflects the requesting launcher's helper posture. + Components are `helper`, `scope`, `proxy`, `launcher`, and `shell`; provider + and state are closed enums (`not-applicable` is used where a provider has no + component). Workload ids and runtime details are not labels. + +### `d2b_daemon_workload_lifecycle_total` + +- **Type:** counter +- **Labels:** `provider`, `operation`, `outcome` +- **Meaning:** Configured workload lifecycle outcomes. Values are bounded + provider/operation/outcome enums and never include argv, environment, cwd, + paths, process ids, unit names, or helper diagnostics. + ## Cardinality bounds | Label | Source | Bound | @@ -226,6 +259,11 @@ declared schema; see "Cardinality bounds" below. | `step` | closed enum (host-prep DAG step IDs) | bounded by [`host-prep-dag.md`](./host-prep-dag.md) | | `op` | closed enum (broker wire op names) | bounded by [`daemon-api.md`](./daemon-api.md) | | `outcome` (broker) | closed enum | 3 | +| `provider` (workload) | closed runtime-provider enum | 4 | +| `component` (workload) | closed prerequisite enum | 5 | +| `operation` (workload) | closed lifecycle-operation enum | 2 | +| `state` (workload) | closed availability enum plus `not-applicable` | 9 | +| `outcome` (workload) | closed lifecycle-result enum | 3 | | `vmm` | closed VM shutdown runtime enum | 3 | | `outcome` (VM shutdown) | closed daemon enum | bounded by daemon code | | `phase` | closed activation orchestration enum | 5 | @@ -235,6 +273,10 @@ declared schema; see "Cardinality bounds" below. | `subsystem` | closed guest-control subsystem enum | bounded by daemon code | | `outcome` (guest-control) | closed enum | bounded by daemon code | | `error_kind` | normalized daemon error bucket | bounded by daemon code | +| `provider` (shell) | closed shell backend enum | 2 | +| `component` (shell) | constant `shell` | 1 | +| `operation` (shell) | closed lifecycle-operation enum | 6 | +| `outcome` (shell) | closed lifecycle-result enum | 5 | No label carries free-form text (no error messages, no store paths, no activation ids, no command output, no shell session names, no diff --git a/docs/reference/error-codes.md b/docs/reference/error-codes.md index ea200b521..966636fd9 100644 --- a/docs/reference/error-codes.md +++ b/docs/reference/error-codes.md @@ -55,6 +55,40 @@ shape, remediation hint, and docs anchor. | `#provider-misconfigured` | `provider-misconfigured` | `80` | `provider` | provider for {vm} is misconfigured: {reason} | Check the provider configuration for the VM and verify the expected guestd-compatible agent or sidecar is running. | +### Unsafe-local persistent-shell errors + +These daemon wire errors carry only a closed kind. They never include helper +diagnostics, shell names, public session handles, terminal bytes, supervisor +metadata, paths, environment, or cwd. + +| docs anchor / kind | exit | Meaning and remediation | +| --- | --- | --- | +| `unsafe-local-shell-feature-unavailable` | `70` | Update `d2b`, `d2bd`, and the helper together; no host-shell fallback is permitted. | +| `unsafe-local-shell-helper-unavailable` | `69` | Start the requesting user's `d2b-unsafe-local-helper` in a login session. | +| `unsafe-local-shell-helper-stale` | `69` | Restart the same-UID helper; d2bd rejected a stale generation. | +| `unsafe-local-shell-capacity` | `75` | Wait for pending work, or detach/kill an existing session before retrying. | +| `unsafe-local-shell-timeout` | `69` | Inspect helper/user-manager health and list sessions before retrying; daemon timeouts are not replayed automatically. | +| `unsafe-local-shell-protocol-error` | `76` | Update `d2b`, `d2bd`, and the helper together. | +| `unsafe-local-shell-operation-conflict` | `76` | Retry the public action so d2bd derives a fresh private operation id. | +| `unsafe-local-shell-user-manager-unavailable` | `69` | Log in through a PAM-backed session and start the user manager; d2b does not enable linger. | +| `unsafe-local-shell-environment-invalid` | `70` | Repair the user manager's runtime-directory and D-Bus environment, then restart the helper. | +| `unsafe-local-shell-executable-unavailable` | `70` | Configure an absolute executable login shell for the user. | +| `unsafe-local-shell-scope-create-failed` | `42` | Repair transient user-scope creation and retry. | +| `unsafe-local-shell-scope-identity-mismatch` | `70` | Restart the helper and inspect the degraded user scope; ambiguous metadata is not adopted or killed. | +| `unsafe-local-shell-graphical-session-inactive` | `69` | Start a graphical login session. | +| `unsafe-local-shell-wayland-unavailable` | `69` | Start the login session's Wayland compositor. | +| `unsafe-local-shell-proxy-unavailable` | `69` | Repair the d2b Wayland proxy; direct compositor fallback is forbidden. | +| `unsafe-local-shell-first-client-timeout` | `69` | Repair proxy readiness and retry. | +| `unsafe-local-shell-unavailable` | `69` | List sessions and attach again; attachment loss does not kill the shell. | +| `unsafe-local-shell-not-found` | `76` | List sessions and retry with a listed name. | +| `unsafe-local-shell-already-attached` | `75` | Detach the existing client or use `--force`. | +| `unsafe-local-shell-output-gap` | `76` | Reattach to redraw from the retained output cursor. | +| `unsafe-local-shell-offset-mismatch` | `76` | Reattach before writing more input. | +| `unsafe-local-shell-terminal-closed` | `69` | List and reattach; the persistent shell may still be running. | +| `unsafe-local-shell-invalid-terminal-size` | `76` | Retry from a terminal reporting non-zero rows and columns. | +| `unsafe-local-shell-stale-session` | `77` | Reattach to obtain a fresh opaque public handle. | +| `unsafe-local-shell-internal` | `42` | Retry, then inspect the bounded daemon lifecycle event if it persists. | + ## CLI host-verb refusal envelope The CLI host verbs (`d2b host prepare`, `host destroy`, @@ -88,7 +122,7 @@ matches an `#anchor` here. The goldens are the contract. | --- | --- | --- | --- | --- | | `#daemon-down` | `daemon-down` | `1` | Daemon connectivity at `/run/d2b/public.sock` and broker dispatch readiness. | `d2bd` is reachable, but the daemon-side typed-intent dispatch and bundle resolver that back `host prepare/destroy --apply` are not yet wired through `d2bd` (the broker op is staged but not yet reachable from the public socket). | | `#broker-error` | `broker-error` | `78` | Daemon → broker execution for `d2b --apply`. | The daemon reached the live broker executor, but the broker refused or failed the request. Disk-init failures use a `broker-error:DiskInit:` shape when the broker fails while creating a declared image via `fallocate` + `mkfs.ext4` or when automatic declared-posture repair is bypassed because the existing image is ambiguous or unsafe. | -| `#not-yet-implemented` | `not-yet-implemented` | `78` | Whether the requested native surface is shipped in this release. | The CLI/daemon has the verb shell, but the concrete backend is still deferred; the CLI returns a typed exit-78 envelope instead of falling back to bash. | +| `#not-yet-implemented` | `not-yet-implemented` | `78` | Whether the requested native surface is shipped in this release. | The requested native backend is deferred; the CLI returns a typed exit-78 envelope instead of falling back to bash. | | `#--read-only-required` | `--read-only-required` | `78` | `host doctor` invocation flags. | `--read-only` flag missing. The current `host doctor` verb is read-only; mutation forms are separate surfaces. | | `#--apply-or-dry-run-required` | `--apply-or-dry-run-required` | `78` | `host prepare` / `host destroy` / `host install` invocation flags. | Neither `--dry-run` nor `--apply` was provided; these host verbs require one of the two to disambiguate plan vs mutate. | diff --git a/docs/reference/manifest-bundle.md b/docs/reference/manifest-bundle.md index 8fbee4008..e586bcf82 100644 --- a/docs/reference/manifest-bundle.md +++ b/docs/reference/manifest-bundle.md @@ -22,7 +22,7 @@ world-readable system profile. | `realm-controllers.json` | private realm controller metadata | root:`d2bd` `0640` | Metadata-only per-realm daemon, broker, socket, state, audit, allocator binding, provider placement, and direct-access plan rooted in `d2b.realms`. | | `realm-identity.json` | private realm identity metadata | root:`d2bd` `0640` | Realm identity, provider binding, policy, and capability metadata consumed by the daemon. | | `realm-workloads-launcher-v2.json` | public launcher metadata in the private bundle | root:`d2bd` `0640` | Argv-free provider, posture, realm color, and generic launcher-item metadata served to authorized clients through the public daemon API. | -| `unsafe-local-workloads.json` | private unsafe-local execution intent | root:`d2bd` `0640` | Normalized configured argv, workload identity, default item, and persistent-shell policy resolved only by `d2bd`. | +| `unsafe-local-workloads.json` | private configured launcher intent | root:`d2bd` `0640` | Normalized configured argv and default items for unsafe-local and local-VM workloads, plus unsafe-local persistent-shell policy, resolved only by `d2bd`. | | `privileges.json` | private authorization policy | root:`d2bd` `0640` | Public API/CLI authorization matrix and private broker operation matrix. | | `closures/.json` | private closure metadata | root:`d2bd` `0640` | Per-VM toplevel, closure paths, declared-runner parity data, and generation metadata. | | `minijail-profile.json` | private sandbox profile catalog | root:`d2bd` `0640` | Typed minijail profile fields, mount policy, and bounded start-as-root exceptions. | @@ -45,8 +45,8 @@ The policy is defined by schema directory is `docs/reference/schemas/v2/`; the bundle and per-artifact schemas were bumped from `v1` to `v2` to land the host-prepare additions; the current emitted -bundle keeps `schemaVersion = "v2"` and uses `bundleVersion = 10` -for the unsafe-local workload execution artifact. +bundle keeps `schemaVersion = "v2"` and uses `bundleVersion = 11` +for provider-neutral configured launcher execution. Each artifact now carries a matching v2 markdown companion beside the committed JSON schema. `cargo xtask gen-schemas` regenerates the JSON files under diff --git a/docs/reference/naming-conventions.md b/docs/reference/naming-conventions.md index ca1cbdbfc..8e29b3dff 100644 --- a/docs/reference/naming-conventions.md +++ b/docs/reference/naming-conventions.md @@ -70,7 +70,7 @@ canon; see [AGENTS.md](../../AGENTS.md#existing-code-is-canon). Launcher item ids also use `^[a-z][a-z0-9-]*$`. They are scoped to one workload and appear in `d2b launch --item `. -Private configured unsafe-local data is installed as +Private configured unsafe-local and local-VM launcher data is installed as `/etc/d2b/unsafe-local-workloads.json`. Public provider-neutral launcher metadata uses `/etc/d2b/realm-workloads-launcher-v2.json`; the compatibility schema remains `/etc/d2b/realm-workloads-launcher.json`. diff --git a/docs/reference/privileges.md b/docs/reference/privileges.md index 8e8d103db..0ae910a2e 100644 --- a/docs/reference/privileges.md +++ b/docs/reference/privileges.md @@ -98,6 +98,13 @@ are denied (`defaultForUnknown: deny`). > The existing shutdown guard remains responsible for ensuring the hook exits > without mutation during ordinary daemon restarts. +> **Configured launch is a public daemon operation, not a broker operation.** +> `launch` is authorized per workload/realm for the `d2b-launcher` and +> `d2b-admin` classes, is audited as a destructive runtime action, has no secret +> access, and does not require the privileged broker. It therefore appears in +> `PrivilegesJson.publicOperations` and the generated +> `OperationAuthz.operation` enum, but not in the broker-only catalog below. + ## Operation catalog (PROTOCOL_VERSION = 2) The currently implemented broker operation catalog. Every row carries diff --git a/docs/reference/schemas/v2/bundle.json b/docs/reference/schemas/v2/bundle.json index 88726f092..5ccea8b72 100644 --- a/docs/reference/schemas/v2/bundle.json +++ b/docs/reference/schemas/v2/bundle.json @@ -40,7 +40,7 @@ ] }, "bundleVersion": { - "description": "Version of the bundle format; bundleVersion 10 adds provider-neutral launcher metadata and configured unsafe-local workload metadata while artifact schemaVersion stays v2.", + "description": "Version of the bundle format; bundleVersion 11 extends the private configured-item artifact to local VM workloads while artifact schemaVersion stays v2.", "type": "integer", "format": "uint32", "minimum": 0.0 diff --git a/docs/reference/schemas/v2/bundle.md b/docs/reference/schemas/v2/bundle.md index 20a8f90b7..772d4202e 100644 --- a/docs/reference/schemas/v2/bundle.md +++ b/docs/reference/schemas/v2/bundle.md @@ -13,7 +13,7 @@ find the current `host.json`, `processes.json`, `privileges.json`, ## Top-level fields - `schemaVersion` — schema directory/version for every referenced artifact. -- `bundleVersion` — additive bundle contract rev (`10` in the current tree). +- `bundleVersion` — additive bundle contract rev (`11` in the current tree). - `publicManifestPath` — path to the public `vms.json` manifest. - `hostPath` — path to the private `host.json` artifact. - `processesPath` — path to the private `processes.json` artifact. diff --git a/docs/reference/schemas/v2/privileges.json b/docs/reference/schemas/v2/privileges.json index 0a66cf430..8e29f4581 100644 --- a/docs/reference/schemas/v2/privileges.json +++ b/docs/reference/schemas/v2/privileges.json @@ -248,6 +248,7 @@ "keys list", "keys rotate", "keys show", + "launch", "list", "migrate", "op", diff --git a/docs/reference/schemas/v2/unsafe-local-helper-wire.json b/docs/reference/schemas/v2/unsafe-local-helper-wire.json index be8bb6d1e..39d3a5fee 100644 --- a/docs/reference/schemas/v2/unsafe-local-helper-wire.json +++ b/docs/reference/schemas/v2/unsafe-local-helper-wire.json @@ -6,7 +6,9 @@ "daemonToHelper", "helperToDaemon", "protocolVersion", - "terminalProtocolVersion" + "terminalProtocolVersion", + "terminalRequest", + "terminalResponse" ], "properties": { "daemonToHelper": { @@ -24,6 +26,12 @@ "type": "integer", "format": "uint32", "minimum": 0.0 + }, + "terminalRequest": { + "$ref": "#/definitions/HelperTerminalRequest" + }, + "terminalResponse": { + "$ref": "#/definitions/HelperTerminalResponse" } }, "additionalProperties": false, @@ -128,6 +136,12 @@ "proxy-unavailable", "first-client-timeout", "shell-unavailable", + "shell-not-found", + "shell-already-attached", + "terminal-output-gap", + "terminal-offset-mismatch", + "terminal-closed", + "invalid-terminal-size", "internal" ] }, @@ -217,6 +231,7 @@ "graphical", "itemId", "operationId", + "realmAccentColor", "requestId", "workload" ], @@ -236,6 +251,9 @@ "operationId": { "$ref": "#/definitions/OperationId" }, + "realmAccentColor": { + "type": "string" + }, "requestId": { "type": "integer", "format": "uint64", @@ -309,6 +327,30 @@ }, "additionalProperties": false }, + "HelperPersistentShellSnapshot": { + "type": "object", + "required": [ + "attached", + "name", + "state", + "supervisorId" + ], + "properties": { + "attached": { + "type": "boolean" + }, + "name": { + "$ref": "#/definitions/ShellName" + }, + "state": { + "$ref": "#/definitions/ShellSessionState" + }, + "supervisorId": { + "$ref": "#/definitions/HelperSupervisorId" + } + }, + "additionalProperties": false + }, "HelperScopeKind": { "type": "string", "enum": [ @@ -329,6 +371,16 @@ "operationId": { "$ref": "#/definitions/OperationId" }, + "persistentShell": { + "anyOf": [ + { + "$ref": "#/definitions/HelperPersistentShellSnapshot" + }, + { + "type": "null" + } + ] + }, "scope": { "$ref": "#/definitions/ScopeIdentity" }, @@ -351,6 +403,111 @@ "degraded" ] }, + "HelperShellAttachResult": { + "type": "object", + "required": [ + "resolvedName", + "state" + ], + "properties": { + "forceEvicted": { + "default": false, + "type": "boolean" + }, + "resolvedName": { + "$ref": "#/definitions/ShellName" + }, + "state": { + "$ref": "#/definitions/ShellSessionState" + } + }, + "additionalProperties": false + }, + "HelperShellDetachResponse": { + "type": "object", + "required": [ + "operationId", + "requestId", + "result" + ], + "properties": { + "operationId": { + "$ref": "#/definitions/OperationId" + }, + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "result": { + "$ref": "#/definitions/ShellDetachResult" + } + }, + "additionalProperties": false + }, + "HelperShellKillResponse": { + "type": "object", + "required": [ + "operationId", + "requestId", + "result" + ], + "properties": { + "operationId": { + "$ref": "#/definitions/OperationId" + }, + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "result": { + "$ref": "#/definitions/ShellKillResult" + } + }, + "additionalProperties": false + }, + "HelperShellListResponse": { + "type": "object", + "required": [ + "operationId", + "requestId", + "result" + ], + "properties": { + "operationId": { + "$ref": "#/definitions/OperationId" + }, + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "result": { + "$ref": "#/definitions/ShellListResult" + } + }, + "additionalProperties": false + }, + "HelperShellPolicy": { + "type": "object", + "required": [ + "defaultName", + "maxSessions" + ], + "properties": { + "defaultName": { + "$ref": "#/definitions/ShellName" + }, + "maxSessions": { + "type": "integer", + "format": "uint16", + "maximum": 64.0, + "minimum": 1.0 + } + }, + "additionalProperties": false + }, "HelperShellRequest": { "oneOf": [ { @@ -363,11 +520,19 @@ "args": { "type": "object", "required": [ - "request_id", + "operationId", + "policy", + "requestId", "workload" ], "properties": { - "request_id": { + "operationId": { + "$ref": "#/definitions/OperationId" + }, + "policy": { + "$ref": "#/definitions/HelperShellPolicy" + }, + "requestId": { "type": "integer", "format": "uint64", "minimum": 0.0 @@ -375,7 +540,8 @@ "workload": { "$ref": "#/definitions/WorkloadIdentity" } - } + }, + "additionalProperties": false }, "op": { "type": "string", @@ -383,7 +549,8 @@ "list" ] } - } + }, + "additionalProperties": false }, { "type": "object", @@ -395,12 +562,39 @@ "args": { "type": "object", "required": [ - "operation_id", - "request_id", - "tty", + "initialTerminalSize", + "operationId", + "policy", + "requestId", "workload" ], "properties": { + "force": { + "default": false, + "type": "boolean" + }, + "initialTerminalSize": { + "type": "object", + "required": [ + "cols", + "rows" + ], + "properties": { + "cols": { + "type": "integer", + "format": "uint32", + "maximum": 65535.0, + "minimum": 1.0 + }, + "rows": { + "type": "integer", + "format": "uint32", + "maximum": 65535.0, + "minimum": 1.0 + } + }, + "additionalProperties": false + }, "name": { "anyOf": [ { @@ -411,21 +605,22 @@ } ] }, - "operation_id": { + "operationId": { "$ref": "#/definitions/OperationId" }, - "request_id": { + "policy": { + "$ref": "#/definitions/HelperShellPolicy" + }, + "requestId": { "type": "integer", "format": "uint64", "minimum": 0.0 }, - "tty": { - "type": "boolean" - }, "workload": { "$ref": "#/definitions/WorkloadIdentity" } - } + }, + "additionalProperties": false }, "op": { "type": "string", @@ -433,7 +628,8 @@ "attach" ] } - } + }, + "additionalProperties": false }, { "type": "object", @@ -446,18 +642,22 @@ "type": "object", "required": [ "name", - "operation_id", - "request_id", + "operationId", + "policy", + "requestId", "workload" ], "properties": { "name": { "$ref": "#/definitions/ShellName" }, - "operation_id": { + "operationId": { "$ref": "#/definitions/OperationId" }, - "request_id": { + "policy": { + "$ref": "#/definitions/HelperShellPolicy" + }, + "requestId": { "type": "integer", "format": "uint64", "minimum": 0.0 @@ -465,7 +665,8 @@ "workload": { "$ref": "#/definitions/WorkloadIdentity" } - } + }, + "additionalProperties": false }, "op": { "type": "string", @@ -473,7 +674,8 @@ "detach" ] } - } + }, + "additionalProperties": false }, { "type": "object", @@ -486,18 +688,22 @@ "type": "object", "required": [ "name", - "operation_id", - "request_id", + "operationId", + "policy", + "requestId", "workload" ], "properties": { "name": { "$ref": "#/definitions/ShellName" }, - "operation_id": { + "operationId": { "$ref": "#/definitions/OperationId" }, - "request_id": { + "policy": { + "$ref": "#/definitions/HelperShellPolicy" + }, + "requestId": { "type": "integer", "format": "uint64", "minimum": 0.0 @@ -505,7 +711,8 @@ "workload": { "$ref": "#/definitions/WorkloadIdentity" } - } + }, + "additionalProperties": false }, "op": { "type": "string", @@ -513,7 +720,69 @@ "kill" ] } - } + }, + "additionalProperties": false + } + ] + }, + "HelperShellResponse": { + "oneOf": [ + { + "type": "object", + "required": [ + "op", + "result" + ], + "properties": { + "op": { + "type": "string", + "enum": [ + "list" + ] + }, + "result": { + "$ref": "#/definitions/HelperShellListResponse" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "op", + "result" + ], + "properties": { + "op": { + "type": "string", + "enum": [ + "detach" + ] + }, + "result": { + "$ref": "#/definitions/HelperShellDetachResponse" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "op", + "result" + ], + "properties": { + "op": { + "type": "string", + "enum": [ + "kill" + ] + }, + "result": { + "$ref": "#/definitions/HelperShellKillResponse" + } + }, + "additionalProperties": false } ] }, @@ -538,97 +807,988 @@ }, "additionalProperties": false }, - "HelperTerminalReady": { + "HelperSupervisorId": { + "description": "Opaque persistent-shell supervisor identity.", + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + }, + "HelperTerminalAttachmentClosed": { "type": "object", "required": [ - "operationId", - "requestId", - "scope", - "terminalProtocolVersion", - "transport" + "detached" ], "properties": { - "operationId": { - "$ref": "#/definitions/OperationId" - }, - "requestId": { - "type": "integer", - "format": "uint64", - "minimum": 0.0 - }, - "scope": { - "$ref": "#/definitions/ScopeIdentity" - }, - "terminalProtocolVersion": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 + "cause": { + "anyOf": [ + { + "$ref": "#/definitions/ShellCloseCause" + }, + { + "type": "null" + } + ] }, - "transport": { - "$ref": "#/definitions/HelperTerminalTransport" + "detached": { + "type": "boolean" } }, "additionalProperties": false }, - "HelperTerminalTransport": { - "description": "Transport represented by the single fd attached to a terminal-ready frame.", - "oneOf": [ - { - "description": "A connected `AF_UNIX` `SOCK_STREAM`.\n\nReceivers must require `SO_TYPE == SOCK_STREAM`, `SO_ACCEPTCONN == 0`, and a successful `getpeername`; listeners, datagrams, and unconnected sockets are invalid.", - "type": "string", - "enum": [ - "connected-unix-stream" - ] - } - ] - }, - "OperationId": { - "type": "string", - "maxLength": 128, - "minLength": 1, - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" - }, - "ProtocolToken": { - "type": "string", - "maxLength": 64, - "minLength": 1, - "pattern": "^[\\x21-\\x7e]+$" - }, - "RealmId": { - "type": "string", - "maxLength": 128, - "minLength": 1, - "pattern": "^[a-z][a-z0-9-]*$" - }, - "RealmTarget": { + "HelperTerminalChunkBase64": { + "description": "Bounded standard padded base64 terminal bytes.", "type": "string", - "maxLength": 388, - "minLength": 1, - "pattern": "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+\\.d2b$" + "maxLength": 87384, + "minLength": 0, + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" }, - "ScopeIdentity": { + "HelperTerminalControl": { "type": "object", "required": [ - "invocationId", - "kind" + "controlSequence", + "requestId" ], "properties": { - "invocationId": { - "type": "string" + "controlSequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 }, - "kind": { - "$ref": "#/definitions/HelperScopeKind" + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "HelperTerminalControlResponse_for_HelperTerminalAttachmentClosed": { + "type": "object", + "required": [ + "controlSequence", + "requestId", + "result" + ], + "properties": { + "controlSequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "result": { + "$ref": "#/definitions/HelperTerminalAttachmentClosed" + } + }, + "additionalProperties": false + }, + "HelperTerminalControlResponse_for_TerminalCloseResult": { + "type": "object", + "required": [ + "controlSequence", + "requestId", + "result" + ], + "properties": { + "controlSequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "result": { + "$ref": "#/definitions/TerminalCloseResult" + } + }, + "additionalProperties": false + }, + "HelperTerminalControlResponse_for_TerminalControlResult": { + "type": "object", + "required": [ + "controlSequence", + "requestId", + "result" + ], + "properties": { + "controlSequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "result": { + "$ref": "#/definitions/TerminalControlResult" + } + }, + "additionalProperties": false + }, + "HelperTerminalOperationResult_for_HelperTerminalReadOutputResult": { + "type": "object", + "required": [ + "requestId", + "result" + ], + "properties": { + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "result": { + "$ref": "#/definitions/HelperTerminalReadOutputResult" + } + }, + "additionalProperties": false + }, + "HelperTerminalOperationResult_for_TerminalWaitResult": { + "type": "object", + "required": [ + "requestId", + "result" + ], + "properties": { + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "result": { + "$ref": "#/definitions/TerminalWaitResult" + } + }, + "additionalProperties": false + }, + "HelperTerminalOperationResult_for_TerminalWriteStdinResult": { + "type": "object", + "required": [ + "requestId", + "result" + ], + "properties": { + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "result": { + "$ref": "#/definitions/TerminalWriteStdinResult" + } + }, + "additionalProperties": false + }, + "HelperTerminalReadOutput": { + "type": "object", + "required": [ + "cursor", + "maxLen", + "requestId", + "stream" + ], + "properties": { + "cursor": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "maxLen": { + "type": "integer", + "format": "uint64", + "maximum": 65536.0, + "minimum": 1.0 + }, + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "stream": { + "$ref": "#/definitions/TerminalStream" + }, + "timeoutMs": { + "default": 0, + "type": "integer", + "format": "uint64", + "maximum": 1000.0, + "minimum": 0.0 + }, + "wait": { + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "HelperTerminalReadOutputResult": { + "type": "object", + "required": [ + "dataBase64", + "nextCursor" + ], + "properties": { + "dataBase64": { + "$ref": "#/definitions/HelperTerminalChunkBase64" + }, + "droppedBytes": { + "default": 0, + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "eof": { + "default": false, + "type": "boolean" + }, + "nextCursor": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "timedOut": { + "default": false, + "type": "boolean" + }, + "truncated": { + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "HelperTerminalReady": { + "type": "object", + "required": [ + "operationId", + "requestId", + "result", + "scope", + "terminalProtocolVersion", + "transport" + ], + "properties": { + "operationId": { + "$ref": "#/definitions/OperationId" + }, + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "result": { + "$ref": "#/definitions/HelperShellAttachResult" + }, + "scope": { + "$ref": "#/definitions/ScopeIdentity" + }, + "terminalProtocolVersion": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + "transport": { + "$ref": "#/definitions/HelperTerminalTransport" + } + }, + "additionalProperties": false + }, + "HelperTerminalRejected": { + "type": "object", + "required": [ + "code", + "requestId" + ], + "properties": { + "code": { + "$ref": "#/definitions/HelperFailureCode" + }, + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "HelperTerminalRequest": { + "oneOf": [ + { + "type": "object", + "required": [ + "args", + "op" + ], + "properties": { + "args": { + "$ref": "#/definitions/HelperTerminalWriteStdin" + }, + "op": { + "type": "string", + "enum": [ + "writeStdin" + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "args", + "op" + ], + "properties": { + "args": { + "$ref": "#/definitions/HelperTerminalReadOutput" + }, + "op": { + "type": "string", + "enum": [ + "readOutput" + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "args", + "op" + ], + "properties": { + "args": { + "$ref": "#/definitions/HelperTerminalResize" + }, + "op": { + "type": "string", + "enum": [ + "resize" + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "args", + "op" + ], + "properties": { + "args": { + "$ref": "#/definitions/HelperTerminalWait" + }, + "op": { + "type": "string", + "enum": [ + "wait" + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "args", + "op" + ], + "properties": { + "args": { + "$ref": "#/definitions/HelperTerminalControl" + }, + "op": { + "type": "string", + "enum": [ + "closeStdin" + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "args", + "op" + ], + "properties": { + "args": { + "$ref": "#/definitions/HelperTerminalControl" + }, + "op": { + "type": "string", + "enum": [ + "closeAttachment" + ] + } + }, + "additionalProperties": false + } + ] + }, + "HelperTerminalResize": { + "type": "object", + "required": [ + "cols", + "controlSequence", + "requestId", + "rows" + ], + "properties": { + "cols": { + "type": "integer", + "format": "uint32", + "maximum": 65535.0, + "minimum": 1.0 + }, + "controlSequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "rows": { + "type": "integer", + "format": "uint32", + "maximum": 65535.0, + "minimum": 1.0 + } + }, + "additionalProperties": false + }, + "HelperTerminalResponse": { + "oneOf": [ + { + "type": "object", + "required": [ + "op", + "result" + ], + "properties": { + "op": { + "type": "string", + "enum": [ + "writeStdin" + ] + }, + "result": { + "$ref": "#/definitions/HelperTerminalOperationResult_for_TerminalWriteStdinResult" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "op", + "result" + ], + "properties": { + "op": { + "type": "string", + "enum": [ + "readOutput" + ] + }, + "result": { + "$ref": "#/definitions/HelperTerminalOperationResult_for_HelperTerminalReadOutputResult" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "op", + "result" + ], + "properties": { + "op": { + "type": "string", + "enum": [ + "resize" + ] + }, + "result": { + "$ref": "#/definitions/HelperTerminalControlResponse_for_TerminalControlResult" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "op", + "result" + ], + "properties": { + "op": { + "type": "string", + "enum": [ + "wait" + ] + }, + "result": { + "$ref": "#/definitions/HelperTerminalOperationResult_for_TerminalWaitResult" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "op", + "result" + ], + "properties": { + "op": { + "type": "string", + "enum": [ + "closeStdin" + ] + }, + "result": { + "$ref": "#/definitions/HelperTerminalControlResponse_for_TerminalCloseResult" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "op", + "result" + ], + "properties": { + "op": { + "type": "string", + "enum": [ + "closeAttachment" + ] + }, + "result": { + "$ref": "#/definitions/HelperTerminalControlResponse_for_HelperTerminalAttachmentClosed" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "op", + "result" + ], + "properties": { + "op": { + "type": "string", + "enum": [ + "rejected" + ] + }, + "result": { + "$ref": "#/definitions/HelperTerminalRejected" + } + }, + "additionalProperties": false + } + ] + }, + "HelperTerminalTransport": { + "description": "Transport represented by the single fd attached to a terminal-ready frame.", + "oneOf": [ + { + "description": "A connected `AF_UNIX` `SOCK_STREAM`.\n\nReceivers must require `SO_TYPE == SOCK_STREAM`, `SO_ACCEPTCONN == 0`, and a successful `getpeername`; listeners, datagrams, and unconnected sockets are invalid.", + "type": "string", + "enum": [ + "connected-unix-stream" + ] + } + ] + }, + "HelperTerminalWait": { + "type": "object", + "required": [ + "requestId" + ], + "properties": { + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "timeoutMs": { + "default": 0, + "type": "integer", + "format": "uint64", + "maximum": 1000.0, + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "HelperTerminalWriteStdin": { + "type": "object", + "required": [ + "chunkBase64", + "offset", + "requestId" + ], + "properties": { + "chunkBase64": { + "$ref": "#/definitions/HelperTerminalChunkBase64" + }, + "eof": { + "default": false, + "type": "boolean" + }, + "offset": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + } + }, + "additionalProperties": false + }, + "OperationId": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "ProtocolToken": { + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[\\x21-\\x7e]+$" + }, + "RealmId": { + "type": "string", + "maxLength": 128, + "minLength": 1, + "pattern": "^[a-z][a-z0-9-]*$" + }, + "RealmTarget": { + "type": "string", + "maxLength": 388, + "minLength": 1, + "pattern": "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+\\.d2b$" + }, + "ScopeIdentity": { + "type": "object", + "required": [ + "invocationId", + "kind" + ], + "properties": { + "invocationId": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/HelperScopeKind" + } + }, + "additionalProperties": false + }, + "ShellCloseCause": { + "type": "string", + "enum": [ + "client-detach", + "evicted-by-force", + "evicted-by-admin-detach", + "killed-by-admin", + "pool-unavailable", + "output-gap" + ] + }, + "ShellDetachResult": { + "type": "object", + "required": [ + "detached", + "resolvedName" + ], + "properties": { + "cause": { + "anyOf": [ + { + "$ref": "#/definitions/ShellCloseCause" + }, + { + "type": "null" + } + ] + }, + "detached": { + "type": "boolean" + }, + "resolvedName": { + "$ref": "#/definitions/ShellName" + } + }, + "additionalProperties": false + }, + "ShellKillResult": { + "type": "object", + "required": [ + "killed", + "name", + "state" + ], + "properties": { + "killed": { + "type": "boolean" + }, + "name": { + "$ref": "#/definitions/ShellName" + }, + "state": { + "$ref": "#/definitions/ShellSessionState" + } + }, + "additionalProperties": false + }, + "ShellListEntry": { + "type": "object", + "required": [ + "name", + "state" + ], + "properties": { + "attached": { + "default": false, + "type": "boolean" + }, + "isDefault": { + "default": false, + "type": "boolean" + }, + "name": { + "$ref": "#/definitions/ShellName" + }, + "state": { + "$ref": "#/definitions/ShellSessionState" + } + }, + "additionalProperties": false + }, + "ShellListResult": { + "type": "object", + "required": [ + "defaultName", + "sessions" + ], + "properties": { + "defaultName": { + "$ref": "#/definitions/ShellName" + }, + "sessions": { + "type": "array", + "items": { + "$ref": "#/definitions/ShellListEntry" + } + } + }, + "additionalProperties": false + }, + "ShellName": { + "description": "Persistent shell session name.", + "type": "string", + "maxLength": 64, + "minLength": 1, + "pattern": "^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$" + }, + "ShellSessionState": { + "type": "string", + "enum": [ + "attached", + "detached", + "killed", + "pool-unavailable", + "feature-disabled", + "output-gap" + ] + }, + "TerminalCloseResult": { + "type": "object", + "properties": { + "stdinClosed": { + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "TerminalControlResult": { + "type": "object", + "properties": { + "delivered": { + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "TerminalStatus": { + "oneOf": [ + { + "type": "object", + "required": [ + "kind", + "value" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "exited" + ] + }, + "value": { + "type": "object", + "required": [ + "code" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + { + "type": "object", + "required": [ + "kind", + "value" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "signaled" + ] + }, + "value": { + "type": "object", + "required": [ + "signal" + ], + "properties": { + "signal": { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + } + } + } + }, + { + "type": "object", + "required": [ + "kind", + "value" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "error" + ] + }, + "value": { + "type": "object", + "required": [ + "slug" + ], + "properties": { + "slug": { + "type": "string" + } + } + } + } + } + ] + }, + "TerminalStream": { + "type": "string", + "enum": [ + "stdout", + "stderr" + ] + }, + "TerminalWaitResult": { + "type": "object", + "properties": { + "running": { + "default": false, + "type": "boolean" + }, + "terminalStatus": { + "anyOf": [ + { + "$ref": "#/definitions/TerminalStatus" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "TerminalWriteStdinResult": { + "type": "object", + "required": [ + "acceptedLen", + "nextOffset" + ], + "properties": { + "acceptedLen": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "backpressured": { + "default": false, + "type": "boolean" + }, + "nextOffset": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "stdinClosed": { + "default": false, + "type": "boolean" } }, "additionalProperties": false }, - "ShellName": { - "description": "Persistent shell session name.", - "type": "string", - "maxLength": 64, - "minLength": 1, - "pattern": "^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$" - }, "UnsafeLocalHelperToDaemon": { "oneOf": [ { @@ -721,6 +1881,24 @@ } } }, + { + "type": "object", + "required": [ + "payload", + "type" + ], + "properties": { + "payload": { + "$ref": "#/definitions/HelperShellResponse" + }, + "type": { + "type": "string", + "enum": [ + "shell" + ] + } + } + }, { "type": "object", "required": [ diff --git a/docs/reference/schemas/v2/unsafe-local-helper-wire.md b/docs/reference/schemas/v2/unsafe-local-helper-wire.md index 5f8bcb29b..1cb266cb8 100644 --- a/docs/reference/schemas/v2/unsafe-local-helper-wire.md +++ b/docs/reference/schemas/v2/unsafe-local-helper-wire.md @@ -2,14 +2,32 @@ Schema: [`unsafe-local-helper-wire.json`](./unsafe-local-helper-wire.json) -This schema captures private helper protocol version 1 between `d2bd` and the -same-UID unsafe-local user helper. Peer credentials, not payload fields, -establish execution identity. +This schema captures private helper protocol version 2 between `d2bd` and the +same-UID unsafe-local user helper. It is an install-together contract with no +version-1 fallback. Peer credentials, not payload fields, establish execution +identity. The dedicated terminal stream remains terminal protocol version 1. ## Contract notes - Control frames use bounded `AF_UNIX` `SOCK_SEQPACKET` messages. - Terminal readiness transfers exactly one connected `AF_UNIX` `SOCK_STREAM`. +- Shell management requests and responses correlate both request and operation + ids. Each request also carries the bounded default name and session limit + resolved from the private workload policy; callers cannot supply that policy + through the public protocol. List, detach, and kill results map directly to + the public shell result DTOs. +- The connected terminal stream is bound to one attachment. Its frames use a + four-byte little-endian JSON-body length prefix, contain no client-supplied + session handle, and cover bounded stdin writes, output reads, resize, wait, + stdin close, attachment close, and typed rejection. +- Terminal JSON frames are limited to 128 KiB, decoded chunks to 64 KiB, + per-stream output rings to 8 MiB, and long polls to 1000 ms. +- Persistent-shell snapshots carry only a redacted shell name, state/attachment + posture, and a bounded opaque supervisor id in addition to common scope + identity. - Socket and received descriptors use the frozen CLOEXEC requirements. -- Requests contain no uid, environment, cwd, compositor path, or - public-supplied argv. +- Shell and terminal requests contain no uid, argv, environment, cwd, host + path, transcript, PID, unit name, compositor data, or terminal session + handle. +- The user helper implements this contract. Public `d2bd` shell routing and + feature advertisement remain unavailable until the next integration slice. diff --git a/docs/reference/schemas/v2/unsafe-local-workloads.json b/docs/reference/schemas/v2/unsafe-local-workloads.json index d3d589e64..24950da14 100644 --- a/docs/reference/schemas/v2/unsafe-local-workloads.json +++ b/docs/reference/schemas/v2/unsafe-local-workloads.json @@ -7,6 +7,14 @@ "workloads" ], "properties": { + "localVmWorkloads": { + "description": "Configured launcher items for local VM workloads. They share this private, bundle-hashed artifact so argv never enters public launcher metadata or the public request protocol.", + "type": "array", + "items": { + "$ref": "#/definitions/LocalVmConfiguredWorkload" + }, + "maxItems": 256 + }, "schemaVersion": { "type": "string" }, @@ -14,7 +22,8 @@ "type": "array", "items": { "$ref": "#/definitions/UnsafeLocalWorkload" - } + }, + "maxItems": 256 } }, "additionalProperties": false, @@ -45,6 +54,37 @@ }, "additionalProperties": false }, + "LocalVmConfiguredWorkload": { + "type": "object", + "required": [ + "identity", + "items" + ], + "properties": { + "defaultItemId": { + "anyOf": [ + { + "$ref": "#/definitions/ProtocolToken" + }, + { + "type": "null" + } + ] + }, + "identity": { + "$ref": "#/definitions/WorkloadIdentity" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/UnsafeLocalLauncherItem" + }, + "maxItems": 64, + "minItems": 1 + } + }, + "additionalProperties": false + }, "ProtocolToken": { "type": "string", "maxLength": 64, @@ -180,7 +220,9 @@ "type": "array", "items": { "$ref": "#/definitions/UnsafeLocalLauncherItem" - } + }, + "maxItems": 64, + "minItems": 1 }, "shell": { "anyOf": [ diff --git a/docs/reference/schemas/v2/unsafe-local-workloads.md b/docs/reference/schemas/v2/unsafe-local-workloads.md index 97173b8c6..7ab6e89d6 100644 --- a/docs/reference/schemas/v2/unsafe-local-workloads.md +++ b/docs/reference/schemas/v2/unsafe-local-workloads.md @@ -3,13 +3,21 @@ Schema: [`unsafe-local-workloads.json`](./unsafe-local-workloads.json) This private artifact contains normalized configured argv and persistent-shell -policy for explicitly enabled unsafe-local workloads. `d2bd` resolves it from -the hash-verified bundle; public requests never supply argv. +policy for explicitly enabled unsafe-local workloads, plus configured launcher +items for local VM workloads. `d2bd` resolves it from the hash-verified bundle; +public requests never supply argv. ## Contract notes - `schemaVersion` is `v2`. -- Workload and launcher-item counts are bounded. -- Every identity names `runtimeKind` and `providerId` as `unsafe-local`. -- Exec argv is bounded, non-empty, and NUL-free; shell items require shell - policy. +- Unsafe-local workloads are bounded to 256 entries; configured local-VM + workloads are bounded to 256 entries. The combined private artifact is + therefore bounded to 512 workload rows. +- Every unsafe-local identity names `runtimeKind` and `providerId` as + `unsafe-local`. +- `localVmWorkloads` entries require `runtimeKind = "nixos"`. + `legacyVmName` is optional; first-class workloads use their workload id as + the backing VM name. +- Every emitted workload has between 1 and 64 private exec/shell items. + Unsupported public-only item kinds are omitted. Exec argv is bounded, + non-empty, and NUL-free; shell items require shell policy. diff --git a/docs/reference/schemas/v2/wire-protocol.json b/docs/reference/schemas/v2/wire-protocol.json index 119f33d26..9db7981a2 100644 --- a/docs/reference/schemas/v2/wire-protocol.json +++ b/docs/reference/schemas/v2/wire-protocol.json @@ -7777,6 +7777,25 @@ } } }, + { + "description": "Provider-neutral workload inventory, status, and configured launch.", + "type": "object", + "required": [ + "kind", + "payload" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "workload" + ] + }, + "payload": { + "$ref": "#/definitions/WorkloadOp" + } + } + }, { "description": "Security-key proxy status: host proxy health, configured FIDO device selectors, current lease, and per-VM virtual-device state. `d2b usb security-key status`", "type": "object", @@ -8136,6 +8155,24 @@ } } }, + { + "type": "object", + "required": [ + "kind", + "payload" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "workload" + ] + }, + "payload": { + "$ref": "#/definitions/WorkloadOpResponse" + } + } + }, { "description": "`d2b usb security-key status` response.", "type": "object", diff --git a/docs/reference/schemas/v2/wire-protocol.md b/docs/reference/schemas/v2/wire-protocol.md index 62774cad0..616b33d35 100644 --- a/docs/reference/schemas/v2/wire-protocol.md +++ b/docs/reference/schemas/v2/wire-protocol.md @@ -12,9 +12,9 @@ control plane accepts. - `framing` — length-prefixed AF_UNIX seqpacket frame rules. - `hello`, `helloOk`, `helloRejected` — version negotiation handshake. - `publicSocket` / `publicRequest` / `publicResponse` — CLI ↔ daemon wire. -- `workloadOp` / `workloadOpResponse` — staged provider-neutral workload - operation DTOs; these remain outside `publicRequest` until runtime dispatch - is enabled. +- `workloadOp` / `workloadOpResponse` — provider-neutral workload operation + DTOs, carried by the feature-negotiated `Workload` public request/response + variants. - `brokerSocket` / `brokerRequest` / `brokerResponse` — daemon ↔ broker wire. ## Contract notes diff --git a/docs/reference/unsafe-local-provider.md b/docs/reference/unsafe-local-provider.md index 22242545e..c9bdf1def 100644 --- a/docs/reference/unsafe-local-provider.md +++ b/docs/reference/unsafe-local-provider.md @@ -5,8 +5,9 @@ `unsafe-local` is an explicit realm workload provider for commands and persistent shells that run as the authenticated host user. It provides **no isolation boundary**. The helper connection and user-scope runtime are enabled -for configured eligible users. Public workload launch and shell dispatch remain -feature-negotiated until their provider routes are enabled. +for configured eligible users. Public configured launch and persistent-shell +dispatch are feature-negotiated and run only through `d2bd` and the exact +requester-UID helper. ## Nix options @@ -54,6 +55,8 @@ local-VM settings, QEMU settings, or legacy launcher command strings. Launcher item ids match `^[a-z][a-z0-9-]*$`. Exec `argv` is a non-empty vector, not a shell string, and is bounded to 128 entries, 16 KiB total, and 4 KiB per argument. A shell item requires `shell.enable = true`. +Firefox is an ordinary configured `exec` item; a terminal launcher is an +ordinary `shell` item. Neither has a provider-specific public request shape. ## Public metadata @@ -87,21 +90,44 @@ The closed unsafe-local posture is: | `executionIdentity` | `authenticated-requester-uid` | | `sessionPersistence` | `user-manager-lifetime` | -`configured-launch-v1` and `unsafe-local-provider-v1` are additive protocol-v3 -feature flags. Clients must hide or refuse unsupported operations; they must -never fall back to unsafe-local. +`configured-launch-v1`, `unsafe-local-provider-v1`, and +`unsafe-local-shell-v1` are additive protocol-v3 feature flags. `d2bd` +advertises the shell flag only with the complete helper-management and terminal +path enabled. Clients must hide or refuse unsupported operations and recommend +updating `d2b`, `d2bd`, and the helper together; they must never fall back to a +host shell, SSH, or a different provider. + +Availability values are directly actionable: `helper-unavailable` and +`helper-stale` require restarting the caller's user helper; +`user-manager-unavailable` requires a PAM-backed graphical login; +`graphical-session-inactive` and `wayland-unavailable` require an active Wayland +session; and `proxy-unavailable` requires repairing the proxy prerequisite. +There is no direct-compositor remediation path. ## Private bundle and helper wire -Bundle version 10 adds -`unsafeLocalWorkloadsPath = /etc/d2b/unsafe-local-workloads.json`. The private -artifact contains normalized configured argv and shell policy and is covered by -the normal bundle artifact hash. +Bundle version 10 added +`unsafeLocalWorkloadsPath = /etc/d2b/unsafe-local-workloads.json`; bundle +version 11 extends that artifact with local-VM configured launcher items. The +private artifact contains normalized configured argv and unsafe-local shell +policy, and is covered by the normal bundle artifact +hash. `d2bd` cross-checks its item id/type/graphical shape against public +metadata before dispatch. +For a local-VM workload, dispatch uses `legacyVmName` when present and otherwise +uses the first-class workload id as the backing VM name. + +Configured exec launch accepts only local launcher/admin peers on the direct +host-local Unix binding. The helper lookup is keyed by the requester's +`SO_PEERCRED` UID, so requester and helper identity are exactly equal. Relay, +remote, stale-helper, cross-UID, direct-compositor, root, SSH, and arbitrary +command fallbacks are not available. -The separate helper protocol is version 1 on the daemon-owned +The separate helper protocol is version 3 on the daemon-owned `/run/d2b/unsafe-local-helper.sock` `SOCK_SEQPACKET` endpoint. Peer credentials, -not a uid field, establish identity. Frames contain no uid, environment, cwd, -or public-supplied command. Both peers request at least 256 KiB for +not a uid field, establish identity. Earlier versions are rejected without a +compatibility fallback because the daemon and helper are installed together. +Frames contain no uid, environment, cwd, or public-supplied command. Both peers +request at least 256 KiB for `SO_SNDBUF` and `SO_RCVBUF` before exchanging frames and verify that Linux reports effective buffers of at least 512 KiB. A smaller effective buffer makes the helper unavailable rather than allowing a valid 256 KiB frame to fail with @@ -111,6 +137,11 @@ limits. Operators may raise restrictive host limits independently; helper registration remains fail-closed if the effective per-socket requirement is not met. +Shell requests additionally carry a closed trusted policy containing +`defaultName` and `maxSessions`. The daemon populates those fields only from the +bundle-hashed unsafe-local workload record after checking public/private shell +item parity; no public shell request can choose or raise them. + The globally installed `d2b-unsafe-local-helper.service` is a systemd user service. `ConditionGroup=d2b-unsafe-local` prevents users who are not allowed to access an enabled unsafe-local realm from registering or entering a restart @@ -118,6 +149,12 @@ loop. The helper connects outward; `d2bd` does not discover a user bus or impersonate a user. Both peers verify `SO_PEERCRED`, and a valid reconnect atomically supersedes the prior generation for that UID. +Supplementary groups are fixed when the login session starts. After enabling +the first unsafe-local realm for a user, or adding that user to `allowedUsers`, +the user must log out and back in before the helper can connect. The existing +session remains fail-closed because neither its user manager nor helper +processes have the new `d2b-unsafe-local` group. + For each operation the helper reads `org.freedesktop.systemd1.Manager.Environment` from the current user manager. It rejects malformed or oversized data rather than trimming it, clears the @@ -126,6 +163,18 @@ Graphical operations additionally remove `DISPLAY` and require a proxy-owned `WAYLAND_DISPLAY`; if no proxy endpoint is ready, launch fails without a direct display fallback. +The user service supplies the helper an immutable +`d2b-wayland-proxy` Nix-store path. A graphical launch carries the validated +realm accent color over private helper protocol v3. Its existing blocked +supervisor creates one randomized mode-`0700` directory beneath the validated +`XDG_RUNTIME_DIR`; the proxy display and mode-`0600` readiness socket live +inside it. The supervisor starts the proxy in the same verified user scope, +requires matching upstream and listener events before starting the application, +and requires the matching first-client event before acknowledging launch. App +exit concurrently aborts that wait. It then owns proxy termination, child +reaping, and private-directory cleanup. Proxy and application therefore remain +under the one persisted scope identity used for restart adoption. + Every launched process begins behind a blocked supervisor. The helper calls `StartTransientUnit`, verifies the returned scope's `InvocationID` and exact control-group identity, and only then releases the supervisor to start the @@ -134,13 +183,63 @@ preserved and reported degraded rather than killed by PID, name, or a broad cgroup sweep. These scopes last only for the systemd user-manager lifetime; d2b does not enable lingering. +Persistent shells use a separate hidden `shell-supervisor` process in a +`persistent-shell` transient user scope. The helper reserves the operation and +name, reads the complete user-manager environment and passwd identity, starts +the supervisor blocked, verifies the scope identity, releases it, waits for +socket, PTY, and login-shell readiness, and only then atomically extends the +existing scope ledger. The supervisor—not the reconnectable helper—owns the PTY +master, login-shell child, output ring, attachment state, and private listener. +The child executes the authenticated user's absolute passwd login shell in the +passwd home with the complete manager environment; no shell string, PATH +lookup, configurable command, or journal output is involved. + +The supervisor id is random and opaque. Its listener is derived beneath a +validated same-UID runtime directory with mode `0700`; the socket is mode +`0600`, rejects the wrong owner or file type, and cleanup removes only the +original owned socket inode. Ledger adoption re-verifies both the transient +scope and supervisor status. A missing or ambiguous listener is preserved and +reported degraded rather than swept or killed. + Terminal data uses exactly one connected `AF_UNIX` `SOCK_STREAM` passed with `SCM_RIGHTS`; listeners, datagram sockets, zero fds, and multiple fds are rejected. The receiver requires `getsockopt(SO_TYPE) == SOCK_STREAM`, `getsockopt(SO_ACCEPTCONN) == 0`, and a successful `getpeername`, then verifies the authenticated helper generation, request correlation, and terminal protocol version before accepting the fd. -Terminal bytes do not share the helper control queue. +Terminal protocol version 1 uses bounded JSON frames with a four-byte +little-endian body-length prefix for stdin writes, output reads, resize, wait, +stdin close, attachment close, and typed rejections. The connected socket binds +one attachment, so these frames never accept a client-supplied session handle. +Frames are limited to 128 KiB, decoded chunks to 64 KiB, per-stream output rings +to the contract ceiling of 8 MiB, and waits to 1000 ms. The helper currently +reserves 512 KiB per merged PTY output ring and caps all such reservations for +one helper at 32 MiB. `stdout` is the authoritative merged PTY stream; `stderr` +reads return an empty terminal result. Reads use absolute cursors and report +dropped bytes after wrap. Writes and control sequences are strictly monotonic, +and long read or wait polls do not block writes or resize. Terminal bytes do not +share the helper control queue. + +`d2bd` resolves canonical targets and unambiguous workload-id aliases before +dispatch. Transition local-VM workloads keep `legacyVmName`; first-class local +VMs use their workload id. Unsafe-local is never coerced to a VM name, and +remote, relay, non-direct-local, ambiguous, and unsupported-provider targets +fail closed. Attach returns an opaque daemon-generated public session handle. +Every later terminal operation must present that exact handle. Disconnect and +`closeAttach` detach only; they never kill the persistent shell. + +List, detach, and kill are helper protocol operations with daemon-generated +operation ids. Helper idempotency and name reservations prevent duplicate named +supervisors. A daemon timeout is ambiguous: d2bd does not replay it +automatically, and operators should list before retrying a destructive action. +Helper unavailability, stale generation, user-manager failure, invalid terminal +fd/frame, output gap, stale offset, quota, name conflict, terminal close, and +timeout all return typed errors without provider fallback. + +Detach closes only the current attachment. Force attach atomically evicts the +old attachment. Kill closes the owned PTY master, waits briefly, then signals +only the still-verified transient scope with `SIGTERM` and finally `SIGKILL`; +unrelated same-UID processes and scopes are never targeted. Every socket is created with `SOCK_CLOEXEC`, every other control or PTY fd uses `O_CLOEXEC`, and rights are received with `MSG_CMSG_CLOEXEC`. Only descriptors @@ -149,10 +248,15 @@ explicitly remapped for a child may survive `exec`. ## Runtime observability Helper registration, reconnect, supersede, and stale events use bounded event -kinds and result classes. Runtime signals never expose uid, argv, environment, -cwd, paths, PIDs, unit names, shell names, transcripts, or terminal bytes. -Scope, proxy, launcher, and shell signals follow the same rule as those provider -routes become available. +kinds and result classes. Provider-neutral `ShellLifecycle` is the sole runtime +shell audit event for both providers. It covers create, attach, list, detach, +kill, close, and failure boundaries with only the configured canonical target, +peer uid, closed provider/action/result values, optional force-takeover intent, +and optional fixed operation/session correlation digests. The +`d2b_daemon_shell_lifecycle_total` metric uses only closed +provider/component/operation/outcome/error labels. Neither surface includes +argv, environment, cwd, paths, PIDs, unit names, helper diagnostics, shell +names, supervisor ids, transcripts, terminal bytes, or public session handles. Generated schemas: diff --git a/nix/prebuilt.json b/nix/prebuilt.json index d3137e2bb..85e5a88ca 100644 --- a/nix/prebuilt.json +++ b/nix/prebuilt.json @@ -1,11 +1,30 @@ { - "version": "1.3.1", + "version": "1.4.1", "system": "x86_64-linux", "binaries": { - "d2b": {"url": "https://github.com/vicondoa/d2b/releases/download/v1.3.1/d2b-1.3.1-x86_64-linux.tar.gz", "hash": "sha256-RfwEsXtUxzVnZrmVmkHNtqiHxdIp6Sjr75ixPRslDKE="}, - "d2b-activation-helper": {"url": "https://github.com/vicondoa/d2b/releases/download/v1.3.1/d2b-activation-helper-1.3.1-x86_64-linux.tar.gz", "hash": "sha256-0u6PjHxQtAKwjbctSYK1WFcZRsnb+QdLvmRxp+ZdvPU="}, - "d2bd": {"url": "https://github.com/vicondoa/d2b/releases/download/v1.3.1/d2bd-1.3.1-x86_64-linux.tar.gz", "hash": "sha256-BX9/h+Gy6atGbhZ+3M8HC2Sh377udNZiyUugmufPEAE="}, - "d2b-priv-broker": {"url": "https://github.com/vicondoa/d2b/releases/download/v1.3.1/d2b-priv-broker-1.3.1-x86_64-linux.tar.gz", "hash": "sha256-xGi3najDZgekJXTkZkqK4h1XFp/v63fo7q7t0Kuv4dc="}, - "d2b-wayland-filter": {"url": "https://github.com/vicondoa/d2b/releases/download/v1.3.1/d2b-wayland-filter-1.3.1-x86_64-linux.tar.gz", "hash": "sha256-76BsbCokOvd8DSE5yteucTRM1Uku+j76amVGdaMkfXI="} + "d2b": { + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.1/d2b-1.4.1-x86_64-linux.tar.gz", + "hash": "sha256-eZJbPlB3p1Gk0q4Au46Rm4+b9rkilt51Ja1agB9umAE=" + }, + "d2b-activation-helper": { + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.1/d2b-activation-helper-1.4.1-x86_64-linux.tar.gz", + "hash": "sha256-krP84+rfDOi/7EghxtPqjIWMx9Klryz9JLFMPZnGV8M=" + }, + "d2b-priv-broker": { + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.1/d2b-priv-broker-1.4.1-x86_64-linux.tar.gz", + "hash": "sha256-qY5eXFpenVG6UrgwwV4nQC+QQxFr3O+mJHVdOSj256A=" + }, + "d2b-unsafe-local-helper": { + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.1/d2b-unsafe-local-helper-1.4.1-x86_64-linux.tar.gz", + "hash": "sha256-SP9+mvCNedtOS3PxQN9bNi9fo8Z+BT1/qbOlzUWbDck=" + }, + "d2b-wayland-proxy": { + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.1/d2b-wayland-proxy-1.4.1-x86_64-linux.tar.gz", + "hash": "sha256-Rq7+w4JcDGY9ca7VAEFdHH00XFfb9V4YuxnPYNVPWIg=" + }, + "d2bd": { + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.1/d2bd-1.4.1-x86_64-linux.tar.gz", + "hash": "sha256-8ccn5AiYjiwW8Sjally4pWYPwIzjC34sybKfXoXxujk=" + } } } diff --git a/nixos-modules/assertions.nix b/nixos-modules/assertions.nix index f3ed68087..539911301 100644 --- a/nixos-modules/assertions.nix +++ b/nixos-modules/assertions.nix @@ -161,6 +161,15 @@ let # (Same-VM references across realms share a CID by design and are not # flagged here; the global vmVsockCidCollisions check covers per-VM uniqueness.) realmWorkloadRows = realmIndex.workloads.enabled; + hasConfiguredLocalVmLaunch = row: + let + declared = cfg.realms.${row.realmName}.workloads.${row.workloadName}; + in + row.kind == "local-vm" + && row.launcherEnabled + && (declared.launcher.items != { } + || declared.launcher.defaultItem != null + || declared.shell.enable); nixosWorkloadRows = lib.filter (row: @@ -698,7 +707,22 @@ let workload.launcher.items; hasExecItem = lib.any (row: row.item.type == "exec") itemRows; hasShellItem = lib.any (row: row.item.type == "shell") itemRows; + hasPrivateItem = lib.any + (row: builtins.elem row.item.type [ "exec" "shell" ]) + itemRows; + privateItemNames = map + (row: row.itemId) + (lib.filter + (row: builtins.elem row.item.type [ "exec" "shell" ]) + itemRows); unsafeLocal = workload.kind == "unsafe-local"; + configuredLocalVm = + workload.enable + && workload.kind == "local-vm" + && workload.launcher.enable + && (workload.launcher.items != { } + || workload.launcher.defaultItem != null + || workload.shell.enable); localVmOptionsUnused = workload.localVm.memoryMiB == null && workload.localVm.vcpus == null @@ -777,6 +801,22 @@ let persistent shell support. ''; } + { + assertion = !configuredLocalVm || hasPrivateItem; + message = '' + ${path} enables configured launch for a local-vm workload but + declares no supported exec or shell launcher item. + ''; + } + { + assertion = !configuredLocalVm + || workload.launcher.defaultItem == null + || builtins.elem workload.launcher.defaultItem privateItemNames; + message = '' + ${path}.launcher.defaultItem must name a supported exec or + shell item when local-vm configured launch is enabled. + ''; + } { assertion = builtins.length itemNames <= 64; message = '' @@ -822,18 +862,35 @@ let realm.workloads)) (lib.filterAttrs (_: realm: realm.enable) cfg.realms)); - unsafeLocalWorkloadCountAssertions = [ - { - assertion = - builtins.length - (lib.filter (row: row.kind == "unsafe-local") realmWorkloadRows) - <= 256; - message = '' - d2b declares more than the supported maximum of 256 enabled - unsafe-local workloads. - ''; - } - ]; + privateConfiguredWorkloadCountAssertions = + let + unsafeLocalCount = builtins.length + (lib.filter (row: row.kind == "unsafe-local") realmWorkloadRows); + localVmConfiguredCount = builtins.length + (lib.filter hasConfiguredLocalVmLaunch realmWorkloadRows); + in [ + { + assertion = unsafeLocalCount <= 256; + message = '' + d2b declares more than the supported maximum of 256 enabled + unsafe-local workloads. + ''; + } + { + assertion = localVmConfiguredCount <= 256; + message = '' + d2b declares more than the supported maximum of 256 enabled local-vm + workloads with configured launch. + ''; + } + { + assertion = unsafeLocalCount + localVmConfiguredCount <= 512; + message = '' + d2b declares more than the supported maximum of 512 private configured + workloads. + ''; + } + ]; gatewayStateBoundaryAssertions = lib.mapAttrsToList @@ -2177,7 +2234,7 @@ in ++ realmPortForwardAssertions ++ realmWorkloadTargetAssertions ++ realmLauncherItemAssertions - ++ unsafeLocalWorkloadCountAssertions + ++ privateConfiguredWorkloadCountAssertions ++ securityKeyHostRequiredAssertions ++ securityKeyUsbipMutualExclusionAssertions ++ securityKeyDeviceAssertions diff --git a/nixos-modules/bundle.nix b/nixos-modules/bundle.nix index d7ff3b3e9..cf12700d0 100644 --- a/nixos-modules/bundle.nix +++ b/nixos-modules/bundle.nix @@ -89,7 +89,7 @@ let # presence of this field; the resolver nullifies it before comparing. dataWithoutHash = { artifactHashes = null; - bundleVersion = 10; + bundleVersion = 11; schemaVersion = "v2"; publicManifestPath = "/run/current-system/sw/share/d2b/vms.json"; hostPath = "/etc/d2b/host.json"; diff --git a/nixos-modules/components/observability/dashboards/01-d2b-overview.json b/nixos-modules/components/observability/dashboards/01-d2b-overview.json index 444b7b81e..61f598542 100644 --- a/nixos-modules/components/observability/dashboards/01-d2b-overview.json +++ b/nixos-modules/components/observability/dashboards/01-d2b-overview.json @@ -169,6 +169,66 @@ "legendFormat": "{{vm}} {{outcome}}" } ] + }, + { + "id": 6, + "type": "timeseries", + "title": "Observed workload inventory", + "description": "Workload counts from the latest list/status inventory refresh by bounded provider, prerequisite component, and state.", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 24}, + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []}, + "options": { + "legend": {"showLegend": true, "displayMode": "list", "placement": "bottom"}, + "tooltip": {"mode": "multi", "sort": "none"} + }, + "targets": [ + { + "refId": "A", + "expr": "max by (provider, component, state) (d2b_daemon_workload_availability)", + "legendFormat": "{{provider}} {{component}} {{state}}" + } + ] + }, + { + "id": 7, + "type": "timeseries", + "title": "Workload launch outcomes", + "description": "Configured launcher lifecycle outcomes with closed provider, operation, and outcome labels.", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 24}, + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []}, + "options": { + "legend": {"showLegend": true, "displayMode": "list", "placement": "bottom"}, + "tooltip": {"mode": "multi", "sort": "none"} + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (provider, operation, outcome) (rate(d2b_daemon_workload_lifecycle_total[5m]))", + "legendFormat": "{{provider}} {{operation}} {{outcome}}" + } + ] + }, + { + "id": 8, + "type": "timeseries", + "title": "Persistent shell lifecycle", + "description": "Provider-neutral shell outcomes using only closed provider, operation, outcome, and error labels.", + "gridPos": {"h": 8, "w": 24, "x": 0, "y": 32}, + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []}, + "options": { + "legend": {"showLegend": true, "displayMode": "list", "placement": "bottom"}, + "tooltip": {"mode": "multi", "sort": "none"} + }, + "targets": [ + { + "refId": "A", + "expr": "sum by (provider, operation, outcome, error_kind) (rate(d2b_daemon_shell_lifecycle_total[5m]))", + "legendFormat": "{{provider}} {{operation}} {{outcome}} {{error_kind}}" + } + ] } ] } diff --git a/nixos-modules/options-gateway.nix b/nixos-modules/options-gateway.nix index 6d832179e..846d65035 100644 --- a/nixos-modules/options-gateway.nix +++ b/nixos-modules/options-gateway.nix @@ -33,6 +33,13 @@ in description = "Internal: resolved same-uid unsafe-local user helper package."; }; + d2bWaylandProxy = lib.mkOption { + type = lib.types.nullOr lib.types.package; + default = null; + internal = true; + description = "Internal: resolved immutable d2b Wayland proxy package."; + }; + d2bGatewayRuntime = lib.mkOption { type = lib.types.nullOr lib.types.package; default = null; diff --git a/nixos-modules/privileges-json.nix b/nixos-modules/privileges-json.nix index 14a597cc1..8c9b4dc38 100644 --- a/nixos-modules/privileges-json.nix +++ b/nixos-modules/privileges-json.nix @@ -419,6 +419,19 @@ let "brokerRequired": "no", "auditMode": "yes" }, + { + "operation": "launch", + "subject": "workload/configured launch", + "scope": "per-workload/per-realm", + "allowedGroups": [ + "d2b-launcher", + "d2b-admin" + ], + "destructive": true, + "secretAccess": "none", + "brokerRequired": "no", + "auditMode": "yes" + }, { "operation": "exec", "subject": "VM/process", diff --git a/nixos-modules/processes-json.nix b/nixos-modules/processes-json.nix index 741d5c3c4..5870bfc53 100644 --- a/nixos-modules/processes-json.nix +++ b/nixos-modules/processes-json.nix @@ -1345,6 +1345,7 @@ in { config = { assertions = videoAssertions; + d2b._hostToolPackages.d2bWaylandProxy = d2bWaylandProxyPackage; d2b._bundle.processesJson = { inherit data jsonText; path = "${jsonFile}"; diff --git a/nixos-modules/unsafe-local-helper.nix b/nixos-modules/unsafe-local-helper.nix index 622f1675d..332ca9a49 100644 --- a/nixos-modules/unsafe-local-helper.nix +++ b/nixos-modules/unsafe-local-helper.nix @@ -64,7 +64,7 @@ in unitConfig.ConditionGroup = "d2b-unsafe-local"; serviceConfig = { Type = "simple"; - ExecStart = "${helperPackage}/bin/d2b-unsafe-local-helper"; + ExecStart = "${helperPackage}/bin/d2b-unsafe-local-helper --wayland-proxy ${cfg._hostToolPackages.d2bWaylandProxy}/bin/d2b-wayland-proxy"; Restart = "on-failure"; RestartSec = "5s"; Slice = "app.slice"; diff --git a/nixos-modules/unsafe-local-workloads-json.nix b/nixos-modules/unsafe-local-workloads-json.nix index 4ea310273..74b44099c 100644 --- a/nixos-modules/unsafe-local-workloads-json.nix +++ b/nixos-modules/unsafe-local-workloads-json.nix @@ -6,6 +6,19 @@ let unsafeLocalWorkloads = lib.filter (workload: workload.kind == "unsafe-local") cfg._index.realms.workloads.enabled; + hasConfiguredLocalVmLaunch = workload: + let + declared = + cfg.realms.${workload.realmName}.workloads.${workload.workloadName}; + in + workload.kind == "local-vm" + && workload.launcherEnabled + && (declared.launcher.items != { } + || declared.launcher.defaultItem != null + || declared.shell.enable); + localVmWorkloads = lib.filter + hasConfiguredLocalVmLaunch + cfg._index.realms.workloads.enabled; privateItem = item: if item.type == "exec" @@ -14,11 +27,16 @@ let inherit (item) id name argv graphical; icon = lib.filterAttrs (_: value: value != null) item.icon; } - else { + else if item.type == "shell" + then { type = "shell"; inherit (item) id name; icon = lib.filterAttrs (_: value: value != null) item.icon; - }; + } + else null; + + privateItems = items: + lib.filter (item: item != null) (map privateItem items); privateWorkload = workload: lib.filterAttrs (_: value: value != null) { @@ -36,7 +54,7 @@ let providerId = "unsafe-local"; }; defaultItemId = workload.defaultItemId; - items = map privateItem workload.launcherItems; + items = privateItems workload.launcherItems; shell = if workload.shell.enable then { @@ -45,9 +63,29 @@ let else null; }; + privateLocalVmWorkload = workload: + lib.filterAttrs (_: value: value != null) { + identity = lib.filterAttrs (_: value: value != null) { + workloadId = workload.workloadId; + workloadName = + if workload.label == workload.workloadId + then null + else workload.label; + realmId = workload.realmId; + realmPath = lib.splitString "." workload.realmPath; + canonicalTarget = workload.canonicalTarget; + legacyVmName = workload.legacyVmName; + runtimeKind = workload.runtimeKind; + providerId = workload.runtimeProviderId; + }; + defaultItemId = workload.defaultItemId; + items = privateItems workload.launcherItems; + }; + data = { schemaVersion = "v2"; workloads = map privateWorkload unsafeLocalWorkloads; + localVmWorkloads = map privateLocalVmWorkload localVmWorkloads; }; in { diff --git a/packages/Cargo.lock b/packages/Cargo.lock index a6bdfffe7..05dd84371 100644 --- a/packages/Cargo.lock +++ b/packages/Cargo.lock @@ -1151,10 +1151,13 @@ dependencies = [ "d2b-contracts", "d2b-core", "d2b-realm-core", + "d2b-wayland-proxy", "getrandom 0.2.17", "nix 0.29.0", + "rustix 0.38.44", "serde", "serde_json", + "sha2", "socket2 0.5.10", "uzers", "zbus", diff --git a/packages/d2b-clipd/src/main.rs b/packages/d2b-clipd/src/main.rs index f019cb187..10b4e054a 100644 --- a/packages/d2b-clipd/src/main.rs +++ b/packages/d2b-clipd/src/main.rs @@ -65,6 +65,8 @@ const ACCEPT_RESOURCE_BACKOFF: Duration = Duration::from_millis(50); const ACCEPT_WARN_INTERVAL: Duration = Duration::from_secs(60); const STREAM_FRAME_IDLE_TIMEOUT: Duration = Duration::from_secs(30); const ASYNC_MATERIALIZE_POLL_INTERVAL: Duration = Duration::from_millis(50); +const PASTE_FOCUS_RESTORE_TIMEOUT: Duration = Duration::from_secs(2); +const PASTE_FOCUS_POLL_INTERVAL: Duration = Duration::from_millis(20); static HELPER_THREADS: AtomicUsize = AtomicUsize::new(0); @@ -155,7 +157,7 @@ fn run(args_iter: impl IntoIterator) -> Result<(), String> { let (niri_tx, niri_rx) = mpsc::channel::(); // ── Host clipboard state ───────────────────────────────────────────────── - let niri_query = NiriQueryProvider::new(niri_socket); + let niri_query = NiriQueryProvider::new(niri_socket.clone()); let attributor = HostClipboardAttributor::new(niri_query); let mut host_clipboard: HostClipboard = HostClipboard::new(attributor); @@ -187,10 +189,6 @@ fn run(args_iter: impl IntoIterator) -> Result<(), String> { let bridge_listeners = install_bridge_listeners(&args.bridge_root, &bridge_peers)?; // ── Niri IPC event stream thread ───────────────────────────────────────── - let niri_socket = args - .niri_socket - .clone() - .or_else(|| std::env::var("NIRI_SOCKET").ok().map(PathBuf::from)); if let Some(ref socket) = niri_socket { spawn_niri_event_thread(socket.clone(), niri_tx); } else { @@ -225,6 +223,7 @@ fn run(args_iter: impl IntoIterator) -> Result<(), String> { control_accept_backoff_until: None, bridge_accept_backoff_until: None, data_control: &mut data_control, + niri_socket, niri_rx, host_clipboard: &mut host_clipboard, supervisor: &mut supervisor, @@ -388,6 +387,7 @@ struct EventLoop<'a> { control_accept_backoff_until: Option, bridge_accept_backoff_until: Option, data_control: &'a mut DataControlClient, + niri_socket: Option, niri_rx: mpsc::Receiver, host_clipboard: &'a mut HostClipboard, supervisor: &'a mut PickerSupervisor, @@ -662,6 +662,7 @@ impl EventLoop<'_> { notifier: self.notifier, fallback: self.fallback, supervisor: self.supervisor, + niri_socket: self.niri_socket.clone(), }; handle_picker_message(message, &mut context); } @@ -2584,6 +2585,7 @@ struct PickerMessageContext<'a> { notifier: &'a mut DesktopNotifier, fallback: &'a mut FallbackArming, supervisor: &'a mut PickerSupervisor, + niri_socket: Option, } fn handle_picker_message(message: PickerToDaemonMessage, context: &mut PickerMessageContext<'_>) { @@ -2603,14 +2605,22 @@ fn handle_picker_message(message: PickerToDaemonMessage, context: &mut PickerMes context.published_selection, ) { Ok(()) => { + let replay_target = match context.fallback.state() { + FallbackState::PickerOpen { target } + | FallbackState::Armed { target, .. } => Some(target.clone()), + FallbackState::Idle => None, + }; + let niri_socket = context.niri_socket.clone(); notify_bridge_selection_refresh(context.bridge_streams); let _ = context.fallback.cancel_picker(); let _ = context.supervisor.cancel_active(ReasonCode::Allowed); if let Err(error) = std::thread::Builder::new() .name("d2b-clipd-paste-replay".to_owned()) - .spawn(|| { - std::thread::sleep(Duration::from_millis(20)); - if let Err(error) = d2b_clipd::virtual_keyboard::paste_ctrl_v() { + .spawn(move || { + if let Err(error) = replay_paste_after_focus( + niri_socket.as_deref(), + replay_target.as_ref(), + ) { log::warn!("d2b-clipd: host instant paste failed: {error}"); let mut notifier = DesktopNotifier; d2b_clipd::notifications::emit_user_visible_failure( @@ -2648,6 +2658,66 @@ fn handle_picker_message(message: PickerToDaemonMessage, context: &mut PickerMes } } +fn replay_paste_after_focus( + niri_socket: Option<&Path>, + target: Option<&FocusedWindowSnapshot>, +) -> Result<(), String> { + let socket = niri_socket.ok_or_else(|| "niri socket is unavailable".to_owned())?; + let target = target.ok_or_else(|| "paste target is unavailable".to_owned())?; + wait_for_target_focus( + target, + PASTE_FOCUS_RESTORE_TIMEOUT, + PASTE_FOCUS_POLL_INTERVAL, + || query_focused_window_snapshot(socket), + )?; + d2b_clipd::virtual_keyboard::paste_ctrl_v().map_err(|error| error.to_string()) +} + +fn wait_for_target_focus( + target: &FocusedWindowSnapshot, + timeout: Duration, + poll_interval: Duration, + mut query: F, +) -> Result<(), String> +where + F: FnMut() -> Result, String>, +{ + let deadline = Instant::now() + timeout; + loop { + if query()? + .as_ref() + .is_some_and(|focused| target.same_target(focused)) + { + return Ok(()); + } + if Instant::now() >= deadline { + return Err("focused destination was not restored before timeout".to_owned()); + } + std::thread::sleep(poll_interval); + } +} + +fn query_focused_window_snapshot(socket: &Path) -> Result, String> { + let mut client = NiriJsonClient::connect( + socket, + d2b_clipd::niri::DEFAULT_NIRI_MAX_LINE_BYTES, + Some(Duration::from_millis(250)), + ) + .map_err(|error| error.to_string())?; + client + .query_focused_window() + .map(|window| { + window.map(|window| FocusedWindowSnapshot { + id: window.id, + app_id: window.app_id, + title: window.title, + workspace_id: window.workspace_id, + output_label: window.output_label, + }) + }) + .map_err(|error| error.to_string()) +} + fn publish_selected_entry_to_host( data_control: &mut DataControlClient, bridge_selection: Option<&BridgeSelectionState>, @@ -3937,6 +4007,45 @@ mod tests { )); } + #[test] + fn paste_replay_waits_for_the_captured_destination_to_regain_focus() { + let target = FocusedWindowSnapshot { + id: Some(7), + app_id: Some("d2b.dev.firefox".to_owned()), + ..FocusedWindowSnapshot::default() + }; + let mut snapshots = VecDeque::from([ + None, + Some(FocusedWindowSnapshot { + id: Some(8), + ..FocusedWindowSnapshot::default() + }), + Some(target.clone()), + ]); + let mut queries = 0; + + wait_for_target_focus(&target, Duration::from_secs(1), Duration::ZERO, || { + queries += 1; + Ok(snapshots.pop_front().flatten()) + }) + .expect("target focus must be restored"); + + assert_eq!(queries, 3); + } + + #[test] + fn paste_replay_fails_closed_when_focus_is_not_restored() { + let target = FocusedWindowSnapshot { + id: Some(7), + ..FocusedWindowSnapshot::default() + }; + + let error = wait_for_target_focus(&target, Duration::ZERO, Duration::ZERO, || Ok(None)) + .expect_err("missing focus must fail"); + + assert_eq!(error, "focused destination was not restored before timeout"); + } + #[test] fn handle_arm_reports_typed_error_when_picker_missing() { let mut supervisor = PickerSupervisor::new(CommandPickerSpawner); diff --git a/packages/d2b-clipd/src/virtual_keyboard.rs b/packages/d2b-clipd/src/virtual_keyboard.rs index 2d6363f3d..f9ef58642 100644 --- a/packages/d2b-clipd/src/virtual_keyboard.rs +++ b/packages/d2b-clipd/src/virtual_keyboard.rs @@ -11,18 +11,19 @@ use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{ zwp_virtual_keyboard_manager_v1, zwp_virtual_keyboard_v1, }; +// The protocol carries Linux evdev codes. KEY_V keeps proxied clients correct +// even when their compositor-facing keyboard retains its existing full keymap. +const PASTE_KEY_CODE: u32 = 47; const PASTE_KEYMAP: &[u8] = b"xkb_keymap {\n\ xkb_keycodes \"d2b\" {\n\ minimum = 8;\n\ -maximum = 11;\n\ - = 9;\n\ - = 10;\n\ +maximum = 56;\n\ + = 55;\n\ };\n\ xkb_types \"d2b\" { include \"complete\" };\n\ xkb_compatibility \"d2b\" { include \"complete\" };\n\ xkb_symbols \"d2b\" {\n\ -key {[Control_L]};\n\ -key {[v, V]};\n\ +key {[v]};\n\ };\n\ };\n\0"; @@ -66,27 +67,27 @@ pub fn paste_ctrl_v() -> Result<(), VirtualKeyboardError> { .roundtrip(&mut VirtualKeyboardState) .map_err(|error| VirtualKeyboardError::Wayland(error.to_string()))?; - keyboard.key(0, 1, 1); keyboard.modifiers(4, 0, 0, 0); - connection - .flush() + event_queue + .roundtrip(&mut VirtualKeyboardState) .map_err(|error| VirtualKeyboardError::Wayland(error.to_string()))?; - std::thread::sleep(Duration::from_millis(10)); - keyboard.key(0, 2, 1); - connection - .flush() + keyboard.key(0, PASTE_KEY_CODE, 1); + event_queue + .roundtrip(&mut VirtualKeyboardState) .map_err(|error| VirtualKeyboardError::Wayland(error.to_string()))?; - std::thread::sleep(Duration::from_millis(6)); + std::thread::sleep(Duration::from_millis(2)); - keyboard.key(0, 2, 0); - connection - .flush() + keyboard.key(0, PASTE_KEY_CODE, 0); + event_queue + .roundtrip(&mut VirtualKeyboardState) .map_err(|error| VirtualKeyboardError::Wayland(error.to_string()))?; - std::thread::sleep(Duration::from_millis(6)); + std::thread::sleep(Duration::from_millis(2)); keyboard.modifiers(0, 0, 0, 0); - keyboard.key(0, 1, 0); + event_queue + .roundtrip(&mut VirtualKeyboardState) + .map_err(|error| VirtualKeyboardError::Wayland(error.to_string()))?; keyboard.destroy(); let _ = connection.flush(); @@ -127,8 +128,11 @@ mod tests { #[test] fn paste_keymap_contains_only_synthetic_control_v_keys() { let keymap = std::str::from_utf8(PASTE_KEYMAP).expect("utf8 keymap"); - assert!(keymap.contains("Control_L")); - assert!(keymap.contains("[v, V]")); + assert_eq!(PASTE_KEY_CODE, 47); + assert!(keymap.contains(" = 55")); + assert!(keymap.contains("[v]")); + assert!(!keymap.contains("Control_L")); + assert!(!keymap.contains("D2B2")); assert!(!keymap.contains("include \"evdev\"")); } diff --git a/packages/d2b-contract-tests/tests/policy_metrics.rs b/packages/d2b-contract-tests/tests/policy_metrics.rs index 32913b73b..809830ee9 100644 --- a/packages/d2b-contract-tests/tests/policy_metrics.rs +++ b/packages/d2b-contract-tests/tests/policy_metrics.rs @@ -134,6 +134,33 @@ const EXPECTED_METRICS: &[ExpectedMetric] = &[ buckets_expr: "&[]", bucket_values: None, }, + ExpectedMetric { + name: "d2b_daemon_shell_lifecycle_total", + kind: "Counter", + labels: &[ + "provider", + "component", + "operation", + "outcome", + "error_kind", + ], + buckets_expr: "&[]", + bucket_values: None, + }, + ExpectedMetric { + name: "d2b_daemon_workload_availability", + kind: "Gauge", + labels: &["provider", "component", "state"], + buckets_expr: "&[]", + bucket_values: None, + }, + ExpectedMetric { + name: "d2b_daemon_workload_lifecycle_total", + kind: "Counter", + labels: &["provider", "operation", "outcome"], + buckets_expr: "&[]", + bucket_values: None, + }, ]; const RETIRED_DOC_METRIC_COUNT: usize = EXPECTED_METRICS.len(); diff --git a/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs b/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs index bc9aaf69e..fbdfcc7fc 100644 --- a/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs +++ b/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs @@ -424,10 +424,108 @@ fn generated_unsafe_local_schemas_are_closed_and_argv_is_private() { assert!(public_schema.contains("\"executionPosture\"")); assert!(private_schema.contains("\"argv\"")); assert!(private_schema.contains("\"additionalProperties\": false")); + let private_schema: serde_json::Value = serde_json::from_str(&private_schema).unwrap(); + assert_eq!( + private_schema["properties"]["workloads"]["maxItems"], + serde_json::json!(256) + ); + assert_eq!( + private_schema["properties"]["localVmWorkloads"]["maxItems"], + serde_json::json!(256) + ); + assert_eq!( + private_schema["definitions"]["LocalVmConfiguredWorkload"]["properties"]["items"]["minItems"], + serde_json::json!(1) + ); assert!(helper_schema.contains("\"protocolVersion\"")); assert!(helper_schema.contains("\"terminalProtocolVersion\"")); } +#[test] +fn helper_shell_schema_is_correlated_bounded_and_authority_free() { + let helper_schema = read_repo_file("docs/reference/schemas/v2/unsafe-local-helper-wire.json"); + let helper_schema: serde_json::Value = serde_json::from_str(&helper_schema).unwrap(); + for root_field in [ + "daemonToHelper", + "helperToDaemon", + "protocolVersion", + "terminalProtocolVersion", + "terminalRequest", + "terminalResponse", + ] { + assert!( + helper_schema["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == root_field), + "helper schema must require {root_field}" + ); + } + + let definitions = helper_schema["definitions"].as_object().unwrap(); + let shell_request = serde_json::to_string(&definitions["HelperShellRequest"]).unwrap(); + for field in ["requestId", "operationId", "initialTerminalSize", "policy"] { + assert!( + shell_request.contains(field), + "helper shell request schema must use wire field {field}" + ); + } + let shell_policy = &definitions["HelperShellPolicy"]; + assert_eq!( + shell_policy["properties"]["maxSessions"]["minimum"].as_f64(), + Some(1.0) + ); + assert_eq!( + shell_policy["properties"]["maxSessions"]["maximum"].as_f64(), + Some(64.0) + ); + for required in ["defaultName", "maxSessions"] { + assert!( + shell_policy["required"] + .as_array() + .unwrap() + .iter() + .any(|field| field == required), + "trusted helper shell policy must require {required}" + ); + } + for forbidden in ["request_id", "operation_id", "initial_terminal_size"] { + assert!( + !shell_request.contains(forbidden), + "helper shell request schema must not expose Rust field {forbidden}" + ); + } + + let shell_and_terminal = definitions + .iter() + .filter(|(name, _)| { + name.starts_with("HelperShell") + || name.starts_with("HelperTerminal") + || *name == "HelperPersistentShellSnapshot" + }) + .map(|(_, definition)| definition) + .collect::>(); + let shell_and_terminal = serde_json::to_string(&shell_and_terminal).unwrap(); + for forbidden in [ + "\"uid\"", + "\"argv\"", + "\"environment\"", + "\"cwd\"", + "\"path\"", + "\"transcript\"", + "\"pid\"", + "\"unitName\"", + "\"compositor\"", + "\"session\"", + ] { + assert!( + !shell_and_terminal.contains(forbidden), + "helper shell/terminal schema must not contain {forbidden}" + ); + } +} + #[test] fn rendered_launcher_metadata_hides_argv_and_private_bundle_resolves_it() { let Some(dir) = env::var_os("D2B_FIXTURES").map(std::path::PathBuf::from) else { diff --git a/packages/d2b-contract-tests/tests/workload_observability_contract.rs b/packages/d2b-contract-tests/tests/workload_observability_contract.rs new file mode 100644 index 000000000..536134f4c --- /dev/null +++ b/packages/d2b-contract-tests/tests/workload_observability_contract.rs @@ -0,0 +1,85 @@ +#[test] +fn overview_dashboard_covers_workload_provider_signals() { + let dashboard: serde_json::Value = serde_json::from_str(include_str!( + "../../../nixos-modules/components/observability/dashboards/01-d2b-overview.json" + )) + .expect("overview dashboard is valid JSON"); + let workload_panels = dashboard["panels"] + .as_array() + .expect("dashboard panels") + .iter() + .filter(|panel| { + let rendered = serde_json::to_string(panel).expect("panel serializes"); + rendered.contains("d2b_daemon_workload_") + }) + .collect::>(); + assert_eq!(workload_panels.len(), 2); + let rendered = serde_json::to_string(&workload_panels).expect("workload panels serialize"); + assert!(rendered.contains("d2b_daemon_workload_availability")); + assert!(rendered.contains("d2b_daemon_workload_lifecycle_total")); + for forbidden in ["argv", "environment", "cwd", "process_id", "unit_name"] { + assert!(!rendered.contains(forbidden), "{forbidden}: {rendered}"); + } +} + +#[test] +fn overview_dashboard_covers_redacted_shell_lifecycle() { + let dashboard: serde_json::Value = serde_json::from_str(include_str!( + "../../../nixos-modules/components/observability/dashboards/01-d2b-overview.json" + )) + .expect("overview dashboard is valid JSON"); + let panel = dashboard["panels"] + .as_array() + .expect("dashboard panels") + .iter() + .find(|panel| { + serde_json::to_string(panel) + .expect("panel serializes") + .contains("d2b_daemon_shell_lifecycle_total") + }) + .expect("shell lifecycle panel"); + let rendered = format!( + "{} {}", + panel["targets"][0]["expr"].as_str().expect("shell query"), + panel["targets"][0]["legendFormat"] + .as_str() + .expect("shell legend") + ); + for label in ["provider", "operation", "outcome", "error_kind"] { + assert!(rendered.contains(label), "{label}: {rendered}"); + } + + for forbidden in [ + "uid", + "target", + "name", + "session", + "supervisor", + "terminal_bytes", + "argv", + "environment", + "cwd", + "path", + "pid", + "unit", + ] { + assert!(!rendered.contains(forbidden), "{forbidden}: {rendered}"); + } +} + +#[test] +fn runtime_emits_only_provider_neutral_shell_audit_events() { + let daemon = include_str!("../../d2bd/src/lib.rs"); + let audit = include_str!("../../d2bd/src/daemon_audit.rs"); + assert!( + !daemon.contains("DaemonEvent::GuestControlShellAttached"), + "runtime must not dual-emit legacy shell attach audit" + ); + assert!( + !daemon.contains("DaemonEvent::GuestControlShellDetached"), + "runtime must not dual-emit legacy shell detach audit" + ); + assert!(daemon.contains("DaemonEvent::ShellLifecycle")); + assert!(!audit.contains("GuestControlShellAttached")); + assert!(!audit.contains("GuestControlShellDetached")); +} diff --git a/packages/d2b-contracts/src/cli_output.rs b/packages/d2b-contracts/src/cli_output.rs index cf20fa581..df554c6d0 100644 --- a/packages/d2b-contracts/src/cli_output.rs +++ b/packages/d2b-contracts/src/cli_output.rs @@ -63,6 +63,16 @@ pub struct VmExecCreateOutputV1 { pub state: crate::guest_wire::ExecState, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LaunchOutputV1 { + pub command: String, + pub target: d2b_core::workload_identity::WorkloadTarget, + pub item_id: d2b_realm_core::ProtocolToken, + pub operation_id: d2b_realm_core::OperationId, + pub disposition: crate::public_wire::LauncherExecDisposition, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct VmExecListOutputV1 { diff --git a/packages/d2b-contracts/src/lib.rs b/packages/d2b-contracts/src/lib.rs index e1f8f029c..c6abcaeef 100644 --- a/packages/d2b-contracts/src/lib.rs +++ b/packages/d2b-contracts/src/lib.rs @@ -124,6 +124,7 @@ impl FeatureFlag { "export-broker-audit" => Some(KnownFeatureFlag::ExportBrokerAudit), "configured-launch-v1" => Some(KnownFeatureFlag::ConfiguredLaunchV1), "unsafe-local-provider-v1" => Some(KnownFeatureFlag::UnsafeLocalProviderV1), + "unsafe-local-shell-v1" => Some(KnownFeatureFlag::UnsafeLocalShellV1), _ => None, } } @@ -157,6 +158,7 @@ pub enum KnownFeatureFlag { ExportBrokerAudit, ConfiguredLaunchV1, UnsafeLocalProviderV1, + UnsafeLocalShellV1, } impl KnownFeatureFlag { @@ -168,6 +170,7 @@ impl KnownFeatureFlag { Self::ExportBrokerAudit => "export-broker-audit", Self::ConfiguredLaunchV1 => "configured-launch-v1", Self::UnsafeLocalProviderV1 => "unsafe-local-provider-v1", + Self::UnsafeLocalShellV1 => "unsafe-local-shell-v1", } } @@ -426,6 +429,17 @@ mod tests { ); } + #[test] + fn unsafe_local_shell_feature_is_known_without_changing_public_protocol() { + let feature = FeatureFlag::new("unsafe-local-shell-v1").expect("valid feature"); + assert_eq!(feature.known(), Some(KnownFeatureFlag::UnsafeLocalShellV1)); + assert_eq!(KnownFeatureFlag::UnsafeLocalShellV1.wire_value(), feature); + assert_eq!(PROTOCOL_VERSION, 3); + + let unknown = FeatureFlag::new("unsafe-local-shell-v2").expect("valid future feature"); + assert_eq!(unknown.known(), None); + } + #[test] fn frame_too_large_is_rejected() { let oversized = "x".repeat(MAX_FRAME_SIZE + 1); diff --git a/packages/d2b-contracts/src/public_wire.rs b/packages/d2b-contracts/src/public_wire.rs index d7f83dc59..22ceff907 100644 --- a/packages/d2b-contracts/src/public_wire.rs +++ b/packages/d2b-contracts/src/public_wire.rs @@ -121,6 +121,9 @@ pub enum PublicRequest { /// through the ADR 0032 orchestrator. #[serde(rename = "gateway display")] GatewayDisplay(GatewayDisplayOp), + /// Provider-neutral workload inventory, status, and configured launch. + #[serde(rename = "workload")] + Workload(WorkloadOp), /// Security-key proxy status: host proxy health, configured FIDO device /// selectors, current lease, and per-VM virtual-device state. /// `d2b usb security-key status` @@ -174,6 +177,8 @@ pub enum PublicResponse { Audio(AudioOpResponse), #[serde(rename = "gateway display")] GatewayDisplay(GatewayDisplayOpResponse), + #[serde(rename = "workload")] + Workload(WorkloadOpResponse), /// `d2b usb security-key status` response. #[serde(rename = "usb security-key status")] UsbSecurityKeyStatus(crate::security_key::SecurityKeyStatusResponse), @@ -3372,6 +3377,11 @@ mod tests { let decoded: PublicRequest = serde_json::from_value(value.clone()).expect("shell attach decodes"); assert_eq!(decoded, attach); + let mut policy_injection = value; + policy_injection["payload"]["args"]["policy"] = + serde_json::json!({"defaultName": "attacker", "maxSessions": 64}); + serde_json::from_value::(policy_injection) + .expect_err("public shell requests cannot inject helper policy"); let kill_without_name = serde_json::json!({ "kind": "shell", diff --git a/packages/d2b-contracts/src/unsafe_local_wire.rs b/packages/d2b-contracts/src/unsafe_local_wire.rs index 4ab5bff0d..951a58b97 100644 --- a/packages/d2b-contracts/src/unsafe_local_wire.rs +++ b/packages/d2b-contracts/src/unsafe_local_wire.rs @@ -3,18 +3,51 @@ //! The authenticated Unix peer credential is the execution identity. No frame //! carries a uid, environment, cwd, compositor path, or arbitrary public argv. -use crate::public_wire::ShellName; -use d2b_core::{configured_argv::ConfiguredArgv, workload_identity::WorkloadIdentity}; +use crate::{ + public_wire::{ + EXEC_MAX_CHUNK_BYTES, ShellCloseCause, ShellDetachResult, ShellKillResult, ShellListResult, + ShellName, ShellSessionState, + }, + terminal_wire::{ + TerminalCloseResult, TerminalControlResult, TerminalReadOutputChunk, TerminalSize, + TerminalStream, TerminalWaitResult, TerminalWriteStdinResult, + }, +}; +use d2b_core::{ + configured_argv::ConfiguredArgv, unsafe_local_workloads::MAX_UNSAFE_LOCAL_SHELL_SESSIONS, + workload_identity::WorkloadIdentity, +}; use d2b_realm_core::{ids::OperationId, token::ProtocolToken}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use schemars::{ + JsonSchema, + r#gen::SchemaGenerator, + schema::{InstanceType, Metadata, Schema, SchemaObject, SingleOrVec, StringValidation}, +}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use std::fmt; -pub const UNSAFE_LOCAL_HELPER_PROTOCOL_VERSION: u32 = 1; +pub const UNSAFE_LOCAL_HELPER_PROTOCOL_VERSION: u32 = 3; pub const UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION: u32 = 1; /// Every terminal-ready frame carries exactly one connected Unix stream fd. pub const UNSAFE_LOCAL_TERMINAL_FD_COUNT: usize = 1; pub const MAX_HELPER_FRAME_SIZE: usize = 256 * 1024; +/// Maximum length-prefixed JSON frame on one attached terminal stream. +pub const MAX_UNSAFE_LOCAL_TERMINAL_FRAME_SIZE: usize = 128 * 1024; +pub const UNSAFE_LOCAL_TERMINAL_LENGTH_PREFIX_BYTES: usize = 4; +/// Maximum decoded terminal input or output chunk. +pub const MAX_UNSAFE_LOCAL_TERMINAL_CHUNK_BYTES: u64 = EXEC_MAX_CHUNK_BYTES; +/// Maximum standard padded base64 envelope for one terminal chunk. +pub const MAX_UNSAFE_LOCAL_TERMINAL_BASE64_BYTES: usize = + (MAX_UNSAFE_LOCAL_TERMINAL_CHUNK_BYTES as usize).div_ceil(3) * 4; +/// Maximum retained output per persistent shell stream. +pub const MAX_UNSAFE_LOCAL_TERMINAL_OUTPUT_RING_BYTES: u64 = 8 * 1024 * 1024; +/// Maximum duration of one terminal long-poll. +pub const MAX_UNSAFE_LOCAL_TERMINAL_WAIT_TIMEOUT_MS: u64 = 1_000; +pub const MAX_UNSAFE_LOCAL_TERMINAL_ROWS: u32 = 65_535; +pub const MAX_UNSAFE_LOCAL_TERMINAL_COLS: u32 = 65_535; +pub const MAX_HELPER_SUPERVISOR_ID_BYTES: usize = 128; +const _: () = + assert!(MAX_UNSAFE_LOCAL_TERMINAL_BASE64_BYTES + 1024 < MAX_UNSAFE_LOCAL_TERMINAL_FRAME_SIZE); /// Value requested through `SO_SNDBUF` and `SO_RCVBUF` on both control peers. pub const HELPER_SOCKET_BUFFER_REQUEST_BYTES: usize = MAX_HELPER_FRAME_SIZE; /// Minimum value that `getsockopt` must report after Linux doubles the request. @@ -23,6 +56,46 @@ pub const MAX_HELPER_QUEUE_DEPTH: usize = 128; pub const MAX_HELPER_SNAPSHOT_SCOPES: usize = 1024; pub const MAX_COMPLETED_OPERATIONS_PER_UID: usize = 1024; pub const MAX_COMPLETED_OPERATION_AGE_SECS: u64 = 24 * 60 * 60; + +pub const fn unsafe_local_helper_protocol_supported(version: u32) -> bool { + version == UNSAFE_LOCAL_HELPER_PROTOCOL_VERSION +} + +pub fn encode_unsafe_local_terminal_frame(message: &T) -> Result, HelperFailureCode> +where + T: Serialize, +{ + let body = serde_json::to_vec(message).map_err(|_| HelperFailureCode::InvalidRequest)?; + if body.len() > MAX_UNSAFE_LOCAL_TERMINAL_FRAME_SIZE { + return Err(HelperFailureCode::InvalidRequest); + } + let mut frame = Vec::with_capacity(UNSAFE_LOCAL_TERMINAL_LENGTH_PREFIX_BYTES + body.len()); + frame.extend_from_slice(&(body.len() as u32).to_le_bytes()); + frame.extend_from_slice(&body); + Ok(frame) +} + +pub fn decode_unsafe_local_terminal_frame(frame: &[u8]) -> Result +where + T: DeserializeOwned, +{ + if frame.len() < UNSAFE_LOCAL_TERMINAL_LENGTH_PREFIX_BYTES { + return Err(HelperFailureCode::InvalidRequest); + } + let declared_length = u32::from_le_bytes( + frame[..UNSAFE_LOCAL_TERMINAL_LENGTH_PREFIX_BYTES] + .try_into() + .map_err(|_| HelperFailureCode::InvalidRequest)?, + ) as usize; + if declared_length > MAX_UNSAFE_LOCAL_TERMINAL_FRAME_SIZE + || frame.len() != UNSAFE_LOCAL_TERMINAL_LENGTH_PREFIX_BYTES + declared_length + { + return Err(HelperFailureCode::InvalidRequest); + } + serde_json::from_slice(&frame[UNSAFE_LOCAL_TERMINAL_LENGTH_PREFIX_BYTES..]) + .map_err(|_| HelperFailureCode::InvalidRequest) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct HelperHello { @@ -82,6 +155,83 @@ pub enum HelperScopeState { Degraded, } +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(transparent)] +pub struct HelperSupervisorId(String); + +impl HelperSupervisorId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let valid = !value.is_empty() + && value.len() <= MAX_HELPER_SUPERVISOR_ID_BYTES + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-') + }); + valid + .then_some(Self(value)) + .ok_or(HelperFailureCode::InvalidRequest) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for HelperSupervisorId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("HelperSupervisorId()") + } +} + +impl<'de> Deserialize<'de> for HelperSupervisorId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(|_| { + serde::de::Error::custom( + "helper supervisor id must be 1..=128 bytes of [A-Za-z0-9._:-]", + ) + }) + } +} + +impl JsonSchema for HelperSupervisorId { + fn schema_name() -> String { + "HelperSupervisorId".to_owned() + } + + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + bounded_string_schema( + 1, + MAX_HELPER_SUPERVISOR_ID_BYTES as u32, + "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", + "Opaque persistent-shell supervisor identity.", + ) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperPersistentShellSnapshot { + pub name: ShellName, + pub state: ShellSessionState, + pub attached: bool, + pub supervisor_id: HelperSupervisorId, +} + +impl fmt::Debug for HelperPersistentShellSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelperPersistentShellSnapshot") + .field("name", &"") + .field("state", &self.state) + .field("attached", &self.attached) + .field("supervisor_id", &"") + .finish() + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct HelperScopeSnapshot { @@ -89,6 +239,8 @@ pub struct HelperScopeSnapshot { pub workload: WorkloadIdentity, pub scope: ScopeIdentity, pub state: HelperScopeState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persistent_shell: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -98,7 +250,7 @@ pub struct HelperSnapshot { pub scopes: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct HelperLaunchRequest { pub request_id: u64, @@ -107,33 +259,137 @@ pub struct HelperLaunchRequest { pub item_id: ProtocolToken, pub argv: ConfiguredArgv, pub graphical: bool, + pub realm_accent_color: RealmAccentColor, +} + +impl fmt::Debug for HelperLaunchRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelperLaunchRequest") + .field("request_id", &self.request_id) + .field("operation_id", &self.operation_id) + .field("workload", &self.workload) + .field("item_id", &self.item_id) + .field("argv_count", &self.argv.as_slice().len()) + .field("graphical", &self.graphical) + .field("realm_accent_color", &self.realm_accent_color) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(transparent)] +pub struct RealmAccentColor(#[schemars(regex(pattern = "^#[0-9a-f]{6}$"))] String); + +impl RealmAccentColor { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let valid = value.len() == 7 + && value.starts_with('#') + && value[1..] + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)); + valid + .then_some(Self(value)) + .ok_or(HelperFailureCode::InvalidRequest) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for RealmAccentColor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RealmAccentColor()") + } +} + +impl<'de> Deserialize<'de> for RealmAccentColor { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::new(String::deserialize(deserializer)?) + .map_err(|_| serde::de::Error::custom("realm accent color must match ^#[0-9a-f]{6}$")) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperShellPolicy { + pub default_name: ShellName, + #[schemars(range(min = 1, max = 64))] + pub max_sessions: u16, +} + +impl fmt::Debug for HelperShellPolicy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelperShellPolicy") + .field("default_name", &"") + .field("max_sessions", &self.max_sessions) + .finish() + } +} + +impl HelperShellPolicy { + pub fn validate_bounds(&self) -> Result<(), HelperFailureCode> { + (1..=MAX_UNSAFE_LOCAL_SHELL_SESSIONS) + .contains(&self.max_sessions) + .then_some(()) + .ok_or(HelperFailureCode::InvalidRequest) + } } #[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -#[serde(tag = "op", content = "args", rename_all = "camelCase")] +#[serde( + tag = "op", + content = "args", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] pub enum HelperShellRequest { List { + #[schemars(rename = "requestId")] request_id: u64, + #[schemars(rename = "operationId")] + operation_id: OperationId, workload: WorkloadIdentity, + policy: HelperShellPolicy, }, Attach { + #[schemars(rename = "requestId")] request_id: u64, + #[schemars(rename = "operationId")] operation_id: OperationId, workload: WorkloadIdentity, + policy: HelperShellPolicy, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, - tty: bool, + #[serde(default)] + force: bool, + #[schemars( + rename = "initialTerminalSize", + schema_with = "bounded_terminal_size_schema" + )] + initial_terminal_size: TerminalSize, }, Detach { + #[schemars(rename = "requestId")] request_id: u64, + #[schemars(rename = "operationId")] operation_id: OperationId, workload: WorkloadIdentity, + policy: HelperShellPolicy, name: ShellName, }, Kill { + #[schemars(rename = "requestId")] request_id: u64, + #[schemars(rename = "operationId")] operation_id: OperationId, workload: WorkloadIdentity, + policy: HelperShellPolicy, name: ShellName, }, } @@ -143,54 +399,105 @@ impl fmt::Debug for HelperShellRequest { match self { Self::List { request_id, + operation_id, workload, + policy, } => f .debug_struct("HelperShellRequest::List") .field("request_id", request_id) + .field("operation_id", operation_id) .field("workload", workload) + .field("policy", policy) .finish(), Self::Attach { request_id, operation_id, workload, + policy, name, - tty, + force, + initial_terminal_size, } => f .debug_struct("HelperShellRequest::Attach") .field("request_id", request_id) .field("operation_id", operation_id) .field("workload", workload) + .field("policy", policy) .field("name", &name.as_ref().map(|_| "")) - .field("tty", tty) + .field("force", force) + .field("initial_terminal_size", initial_terminal_size) .finish(), Self::Detach { request_id, operation_id, workload, + policy, .. } => f .debug_struct("HelperShellRequest::Detach") .field("request_id", request_id) .field("operation_id", operation_id) .field("workload", workload) + .field("policy", policy) .field("name", &"") .finish(), Self::Kill { request_id, operation_id, workload, + policy, .. } => f .debug_struct("HelperShellRequest::Kill") .field("request_id", request_id) .field("operation_id", operation_id) .field("workload", workload) + .field("policy", policy) .field("name", &"") .finish(), } } } +impl HelperShellRequest { + pub fn request_id(&self) -> u64 { + match self { + Self::List { request_id, .. } + | Self::Attach { request_id, .. } + | Self::Detach { request_id, .. } + | Self::Kill { request_id, .. } => *request_id, + } + } + + pub fn operation_id(&self) -> &OperationId { + match self { + Self::List { operation_id, .. } + | Self::Attach { operation_id, .. } + | Self::Detach { operation_id, .. } + | Self::Kill { operation_id, .. } => operation_id, + } + } + + pub fn validate_bounds(&self) -> Result<(), HelperFailureCode> { + let policy = match self { + Self::List { policy, .. } + | Self::Attach { policy, .. } + | Self::Detach { policy, .. } + | Self::Kill { policy, .. } => policy, + }; + policy.validate_bounds()?; + match self { + Self::Attach { + initial_terminal_size, + .. + } if !terminal_size_valid(*initial_terminal_size) => { + Err(HelperFailureCode::InvalidTerminalSize) + } + _ => Ok(()), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] pub enum HelperFailureCode { @@ -208,6 +515,12 @@ pub enum HelperFailureCode { ProxyUnavailable, FirstClientTimeout, ShellUnavailable, + ShellNotFound, + ShellAlreadyAttached, + TerminalOutputGap, + TerminalOffsetMismatch, + TerminalClosed, + InvalidTerminalSize, Internal, } @@ -229,7 +542,7 @@ pub struct HelperOperationResult { pub scope: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct HelperTerminalReady { pub request_id: u64, @@ -237,6 +550,20 @@ pub struct HelperTerminalReady { pub terminal_protocol_version: u32, pub transport: HelperTerminalTransport, pub scope: ScopeIdentity, + pub result: HelperShellAttachResult, +} + +impl fmt::Debug for HelperTerminalReady { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelperTerminalReady") + .field("request_id", &self.request_id) + .field("operation_id", &self.operation_id) + .field("terminal_protocol_version", &self.terminal_protocol_version) + .field("transport", &self.transport) + .field("scope", &self.scope) + .field("result", &self.result) + .finish() + } } /// Transport represented by the single fd attached to a terminal-ready frame. @@ -259,6 +586,390 @@ pub struct HelperOperationRejected { pub code: HelperFailureCode, } +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperShellAttachResult { + pub resolved_name: ShellName, + pub state: ShellSessionState, + #[serde(default)] + pub force_evicted: bool, +} + +impl fmt::Debug for HelperShellAttachResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelperShellAttachResult") + .field("resolved_name", &"") + .field("state", &self.state) + .field("force_evicted", &self.force_evicted) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperShellListResponse { + pub request_id: u64, + pub operation_id: OperationId, + pub result: ShellListResult, +} + +impl fmt::Debug for HelperShellListResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelperShellListResponse") + .field("request_id", &self.request_id) + .field("operation_id", &self.operation_id) + .field("result", &self.result) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperShellDetachResponse { + pub request_id: u64, + pub operation_id: OperationId, + pub result: ShellDetachResult, +} + +impl fmt::Debug for HelperShellDetachResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelperShellDetachResponse") + .field("request_id", &self.request_id) + .field("operation_id", &self.operation_id) + .field("result", &self.result) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperShellKillResponse { + pub request_id: u64, + pub operation_id: OperationId, + pub result: ShellKillResult, +} + +impl fmt::Debug for HelperShellKillResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelperShellKillResponse") + .field("request_id", &self.request_id) + .field("operation_id", &self.operation_id) + .field("result", &self.result) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde( + tag = "op", + content = "result", + rename_all = "camelCase", + deny_unknown_fields +)] +pub enum HelperShellResponse { + List(HelperShellListResponse), + Detach(HelperShellDetachResponse), + Kill(HelperShellKillResponse), +} + +impl HelperShellResponse { + pub fn request_id(&self) -> u64 { + match self { + Self::List(response) => response.request_id, + Self::Detach(response) => response.request_id, + Self::Kill(response) => response.request_id, + } + } + + pub fn operation_id(&self) -> &OperationId { + match self { + Self::List(response) => &response.operation_id, + Self::Detach(response) => &response.operation_id, + Self::Kill(response) => &response.operation_id, + } + } +} + +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct HelperTerminalChunkBase64(String); + +impl HelperTerminalChunkBase64 { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + valid_base64_chunk(&value) + .then_some(Self(value)) + .ok_or(HelperFailureCode::InvalidRequest) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn decoded_len(&self) -> usize { + decoded_base64_len(&self.0).unwrap_or(0) + } +} + +impl fmt::Debug for HelperTerminalChunkBase64 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelperTerminalChunkBase64") + .field("encoded_len", &self.0.len()) + .field("decoded_len", &self.decoded_len()) + .finish() + } +} + +impl<'de> Deserialize<'de> for HelperTerminalChunkBase64 { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(|_| { + serde::de::Error::custom("terminal chunk must be bounded standard padded base64") + }) + } +} + +impl JsonSchema for HelperTerminalChunkBase64 { + fn schema_name() -> String { + "HelperTerminalChunkBase64".to_owned() + } + + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + bounded_string_schema( + 0, + MAX_UNSAFE_LOCAL_TERMINAL_BASE64_BYTES as u32, + "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$", + "Bounded standard padded base64 terminal bytes.", + ) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperTerminalWriteStdin { + pub request_id: u64, + pub offset: u64, + pub chunk_base64: HelperTerminalChunkBase64, + #[serde(default)] + pub eof: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperTerminalReadOutput { + pub request_id: u64, + pub stream: TerminalStream, + pub cursor: u64, + #[schemars(range(min = 1, max = 65536))] + pub max_len: u64, + #[serde(default)] + pub wait: bool, + #[serde(default)] + #[schemars(range(max = 1000))] + pub timeout_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperTerminalResize { + pub request_id: u64, + pub control_sequence: u64, + #[schemars(range(min = 1, max = 65535))] + pub rows: u32, + #[schemars(range(min = 1, max = 65535))] + pub cols: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperTerminalWait { + pub request_id: u64, + #[serde(default)] + #[schemars(range(max = 1000))] + pub timeout_ms: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperTerminalControl { + pub request_id: u64, + pub control_sequence: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde( + tag = "op", + content = "args", + rename_all = "camelCase", + deny_unknown_fields +)] +pub enum HelperTerminalRequest { + WriteStdin(HelperTerminalWriteStdin), + ReadOutput(HelperTerminalReadOutput), + Resize(HelperTerminalResize), + Wait(HelperTerminalWait), + CloseStdin(HelperTerminalControl), + CloseAttachment(HelperTerminalControl), +} + +impl HelperTerminalRequest { + pub fn request_id(&self) -> u64 { + match self { + Self::WriteStdin(request) => request.request_id, + Self::ReadOutput(request) => request.request_id, + Self::Resize(request) => request.request_id, + Self::Wait(request) => request.request_id, + Self::CloseStdin(request) | Self::CloseAttachment(request) => request.request_id, + } + } + + pub fn validate_bounds(&self) -> Result<(), HelperFailureCode> { + match self { + Self::ReadOutput(request) + if request.max_len == 0 + || request.max_len > MAX_UNSAFE_LOCAL_TERMINAL_CHUNK_BYTES + || request.timeout_ms > MAX_UNSAFE_LOCAL_TERMINAL_WAIT_TIMEOUT_MS => + { + Err(HelperFailureCode::InvalidRequest) + } + Self::Resize(request) + if !terminal_size_valid(TerminalSize { + rows: request.rows, + cols: request.cols, + }) => + { + Err(HelperFailureCode::InvalidTerminalSize) + } + Self::Wait(request) + if request.timeout_ms > MAX_UNSAFE_LOCAL_TERMINAL_WAIT_TIMEOUT_MS => + { + Err(HelperFailureCode::InvalidRequest) + } + _ => Ok(()), + } + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperTerminalReadOutputResult { + pub data_base64: HelperTerminalChunkBase64, + pub next_cursor: u64, + #[serde(default)] + pub eof: bool, + #[serde(default)] + pub dropped_bytes: u64, + #[serde(default)] + pub truncated: bool, + #[serde(default)] + pub timed_out: bool, +} + +impl fmt::Debug for HelperTerminalReadOutputResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HelperTerminalReadOutputResult") + .field("data_base64_len", &self.data_base64.as_str().len()) + .field("next_cursor", &self.next_cursor) + .field("eof", &self.eof) + .field("dropped_bytes", &self.dropped_bytes) + .field("truncated", &self.truncated) + .field("timed_out", &self.timed_out) + .finish() + } +} + +impl TryFrom for HelperTerminalReadOutputResult { + type Error = HelperFailureCode; + + fn try_from(value: TerminalReadOutputChunk) -> Result { + Ok(Self { + data_base64: HelperTerminalChunkBase64::new(value.data_base64)?, + next_cursor: value.next_offset, + eof: value.eof, + dropped_bytes: value.dropped_bytes, + truncated: value.truncated, + timed_out: value.timed_out, + }) + } +} + +impl From for TerminalReadOutputChunk { + fn from(value: HelperTerminalReadOutputResult) -> Self { + Self { + data_base64: value.data_base64.0, + next_offset: value.next_cursor, + eof: value.eof, + dropped_bytes: value.dropped_bytes, + truncated: value.truncated, + timed_out: value.timed_out, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperTerminalOperationResult { + pub request_id: u64, + pub result: T, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperTerminalControlResponse { + pub request_id: u64, + pub control_sequence: u64, + pub result: T, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperTerminalAttachmentClosed { + pub detached: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cause: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct HelperTerminalRejected { + pub request_id: u64, + pub code: HelperFailureCode, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde( + tag = "op", + content = "result", + rename_all = "camelCase", + deny_unknown_fields +)] +pub enum HelperTerminalResponse { + WriteStdin(HelperTerminalOperationResult), + ReadOutput(HelperTerminalOperationResult), + Resize(HelperTerminalControlResponse), + Wait(HelperTerminalOperationResult), + CloseStdin(HelperTerminalControlResponse), + CloseAttachment(HelperTerminalControlResponse), + Rejected(HelperTerminalRejected), +} + +impl HelperTerminalResponse { + pub fn request_id(&self) -> u64 { + match self { + Self::WriteStdin(response) => response.request_id, + Self::ReadOutput(response) => response.request_id, + Self::Resize(response) => response.request_id, + Self::Wait(response) => response.request_id, + Self::CloseStdin(response) => response.request_id, + Self::CloseAttachment(response) => response.request_id, + Self::Rejected(response) => response.request_id, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(tag = "type", content = "payload", rename_all = "camelCase")] pub enum DaemonToUnsafeLocalHelper { @@ -276,6 +987,7 @@ pub enum UnsafeLocalHelperToDaemon { Heartbeat(HelperHeartbeat), Operation(HelperOperationResult), TerminalReady(HelperTerminalReady), + Shell(HelperShellResponse), Rejected(HelperOperationRejected), } @@ -286,76 +998,569 @@ pub struct UnsafeLocalHelperWireSchema { pub terminal_protocol_version: u32, pub daemon_to_helper: DaemonToUnsafeLocalHelper, pub helper_to_daemon: UnsafeLocalHelperToDaemon, + pub terminal_request: HelperTerminalRequest, + pub terminal_response: HelperTerminalResponse, +} + +fn terminal_size_valid(size: TerminalSize) -> bool { + (1..=MAX_UNSAFE_LOCAL_TERMINAL_ROWS).contains(&size.rows) + && (1..=MAX_UNSAFE_LOCAL_TERMINAL_COLS).contains(&size.cols) +} + +#[derive(JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[allow(dead_code)] +struct BoundedTerminalSizeSchema { + #[schemars(range(min = 1, max = 65535))] + rows: u32, + #[schemars(range(min = 1, max = 65535))] + cols: u32, +} + +fn bounded_terminal_size_schema(r#gen: &mut SchemaGenerator) -> Schema { + BoundedTerminalSizeSchema::json_schema(r#gen) +} + +fn decoded_base64_len(value: &str) -> Option { + if !value.len().is_multiple_of(4) { + return None; + } + let padding = value.bytes().rev().take_while(|byte| *byte == b'=').count(); + if padding > 2 { + return None; + } + value + .len() + .checked_div(4)? + .checked_mul(3)? + .checked_sub(padding) +} + +fn valid_base64_chunk(value: &str) -> bool { + if value.len() > MAX_UNSAFE_LOCAL_TERMINAL_BASE64_BYTES { + return false; + } + let padding_start = value.find('=').unwrap_or(value.len()); + if value[..padding_start] + .bytes() + .any(|byte| !(byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/'))) + || value[padding_start..].bytes().any(|byte| byte != b'=') + { + return false; + } + decoded_base64_len(value) + .is_some_and(|len| len <= MAX_UNSAFE_LOCAL_TERMINAL_CHUNK_BYTES as usize) +} + +fn bounded_string_schema( + min_length: u32, + max_length: u32, + pattern: &str, + description: &str, +) -> Schema { + let mut object = SchemaObject { + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), + string: Some(Box::new(StringValidation { + min_length: Some(min_length), + max_length: Some(max_length), + pattern: Some(pattern.to_owned()), + })), + ..Default::default() + }; + object.metadata = Some(Box::new(Metadata { + description: Some(description.to_owned()), + ..Default::default() + })); + Schema::Object(object) } #[cfg(test)] mod tests { use super::*; + use crate::public_wire::{ShellKillResult, ShellListEntry, ShellSessionState}; + use serde::de::DeserializeOwned; + + fn workload() -> WorkloadIdentity { + serde_json::from_value(serde_json::json!({ + "workloadId": "tools", + "realmId": "host", + "realmPath": ["host"], + "canonicalTarget": "tools.host.d2b" + })) + .unwrap() + } + + fn operation(value: &str) -> OperationId { + OperationId::parse(value).unwrap() + } + + fn shell_name(value: &str) -> ShellName { + ShellName::new(value).unwrap() + } + + fn shell_policy() -> HelperShellPolicy { + HelperShellPolicy { + default_name: shell_name("primary"), + max_sessions: 8, + } + } + + fn round_trip(value: &T) + where + T: Serialize + DeserializeOwned + PartialEq + fmt::Debug, + { + let encoded = serde_json::to_vec(value).unwrap(); + let decoded: T = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(&decoded, value); + } + + fn terminal_round_trip(value: &T) + where + T: Serialize + DeserializeOwned + PartialEq + fmt::Debug, + { + let frame = encode_unsafe_local_terminal_frame(value).unwrap(); + let decoded: T = decode_unsafe_local_terminal_frame(&frame).unwrap(); + assert_eq!(&decoded, value); + } + + #[test] + fn management_request_variants_round_trip_and_correlate() { + let requests = [ + HelperShellRequest::List { + request_id: 1, + operation_id: operation("op-list"), + workload: workload(), + policy: shell_policy(), + }, + HelperShellRequest::Attach { + request_id: 2, + operation_id: operation("op-attach"), + workload: workload(), + policy: shell_policy(), + name: Some(shell_name("primary")), + force: true, + initial_terminal_size: TerminalSize { rows: 24, cols: 80 }, + }, + HelperShellRequest::Detach { + request_id: 3, + operation_id: operation("op-detach"), + workload: workload(), + policy: shell_policy(), + name: shell_name("primary"), + }, + HelperShellRequest::Kill { + request_id: 4, + operation_id: operation("op-kill"), + workload: workload(), + policy: shell_policy(), + name: shell_name("primary"), + }, + ]; + + for (index, request) in requests.iter().enumerate() { + round_trip(request); + let encoded = serde_json::to_string(request).unwrap(); + assert!(!encoded.contains("request_id")); + assert!(!encoded.contains("operation_id")); + assert!(!encoded.contains("initial_terminal_size")); + assert_eq!(request.request_id(), index as u64 + 1); + assert_eq!( + request.operation_id().as_str(), + ["op-list", "op-attach", "op-detach", "op-kill"][index] + ); + assert_eq!(request.validate_bounds(), Ok(())); + } + } #[test] - fn argv_is_bounded_and_debug_redacted() { - let canary = "private-canary-argv"; - let argv = ConfiguredArgv::new(vec!["firefox".to_owned(), canary.to_owned()]).unwrap(); - let debug = format!("{argv:?}"); - assert!(!debug.contains(canary)); - assert!(!debug.contains("firefox")); - assert!(debug.contains("argc")); - assert!(ConfiguredArgv::new(Vec::new()).is_err()); - assert!(ConfiguredArgv::new(vec!["x\0y".to_owned()]).is_err()); + fn management_response_variants_round_trip_and_correlate() { + let responses = [ + HelperShellResponse::List(HelperShellListResponse { + request_id: 1, + operation_id: operation("op-list"), + result: ShellListResult { + default_name: shell_name("primary"), + sessions: vec![ShellListEntry { + name: shell_name("primary"), + state: ShellSessionState::Detached, + attached: false, + is_default: true, + }], + }, + }), + HelperShellResponse::Detach(HelperShellDetachResponse { + request_id: 2, + operation_id: operation("op-detach"), + result: ShellDetachResult { + resolved_name: shell_name("primary"), + detached: true, + cause: Some(ShellCloseCause::EvictedByAdminDetach), + }, + }), + HelperShellResponse::Kill(HelperShellKillResponse { + request_id: 3, + operation_id: operation("op-kill"), + result: ShellKillResult { + name: shell_name("primary"), + killed: true, + state: ShellSessionState::Killed, + }, + }), + ]; + + for (index, response) in responses.iter().enumerate() { + round_trip(response); + assert_eq!(response.request_id(), index as u64 + 1); + assert_eq!( + response.operation_id().as_str(), + ["op-list", "op-detach", "op-kill"][index] + ); + } } #[test] - fn helper_frames_reject_uid_environment_and_cwd_fields() { - let frame = r#"{ + fn terminal_request_variants_round_trip_and_correlate() { + let requests = [ + HelperTerminalRequest::WriteStdin(HelperTerminalWriteStdin { + request_id: 1, + offset: 0, + chunk_base64: HelperTerminalChunkBase64::new("aGVsbG8=").unwrap(), + eof: false, + }), + HelperTerminalRequest::ReadOutput(HelperTerminalReadOutput { + request_id: 2, + stream: TerminalStream::Stdout, + cursor: 0, + max_len: 4096, + wait: true, + timeout_ms: 250, + }), + HelperTerminalRequest::Resize(HelperTerminalResize { + request_id: 3, + control_sequence: 7, + rows: 40, + cols: 120, + }), + HelperTerminalRequest::Wait(HelperTerminalWait { + request_id: 4, + timeout_ms: 500, + }), + HelperTerminalRequest::CloseStdin(HelperTerminalControl { + request_id: 5, + control_sequence: 8, + }), + HelperTerminalRequest::CloseAttachment(HelperTerminalControl { + request_id: 6, + control_sequence: 9, + }), + ]; + + for (index, request) in requests.iter().enumerate() { + round_trip(request); + terminal_round_trip(request); + assert_eq!(request.request_id(), index as u64 + 1); + assert_eq!(request.validate_bounds(), Ok(())); + let encoded = serde_json::to_vec(request).unwrap(); + assert!(encoded.len() <= MAX_UNSAFE_LOCAL_TERMINAL_FRAME_SIZE); + assert!(!encoded.windows(11).any(|window| window == b"\"session\":")); + } + } + + #[test] + fn terminal_response_variants_round_trip_and_correlate() { + let responses = [ + HelperTerminalResponse::WriteStdin(HelperTerminalOperationResult { + request_id: 1, + result: TerminalWriteStdinResult { + accepted_len: 5, + next_offset: 5, + backpressured: false, + stdin_closed: false, + }, + }), + HelperTerminalResponse::ReadOutput(HelperTerminalOperationResult { + request_id: 2, + result: HelperTerminalReadOutputResult { + data_base64: HelperTerminalChunkBase64::new("aGVsbG8=").unwrap(), + next_cursor: 5, + eof: false, + dropped_bytes: 0, + truncated: false, + timed_out: false, + }, + }), + HelperTerminalResponse::Resize(HelperTerminalControlResponse { + request_id: 3, + control_sequence: 7, + result: TerminalControlResult { delivered: true }, + }), + HelperTerminalResponse::Wait(HelperTerminalOperationResult { + request_id: 4, + result: TerminalWaitResult { + running: true, + terminal_status: None, + }, + }), + HelperTerminalResponse::CloseStdin(HelperTerminalControlResponse { + request_id: 5, + control_sequence: 8, + result: TerminalCloseResult { stdin_closed: true }, + }), + HelperTerminalResponse::CloseAttachment(HelperTerminalControlResponse { + request_id: 6, + control_sequence: 9, + result: HelperTerminalAttachmentClosed { + detached: true, + cause: Some(ShellCloseCause::ClientDetach), + }, + }), + HelperTerminalResponse::Rejected(HelperTerminalRejected { + request_id: 7, + code: HelperFailureCode::TerminalOffsetMismatch, + }), + ]; + + for (index, response) in responses.iter().enumerate() { + round_trip(response); + terminal_round_trip(response); + assert_eq!(response.request_id(), index as u64 + 1); + } + + let shared = TerminalReadOutputChunk { + data_base64: "aGVsbG8=".to_owned(), + next_offset: 5, + eof: false, + dropped_bytes: 0, + truncated: false, + timed_out: false, + }; + let helper = HelperTerminalReadOutputResult::try_from(shared.clone()).unwrap(); + assert_eq!(TerminalReadOutputChunk::from(helper), shared); + } + + #[test] + fn helper_frames_reject_unknown_and_forbidden_fields() { + let hello = r#"{ "type":"hello", - "payload":{"protocolVersion":1,"generation":1,"features":[],"uid":1000} - }"#; - assert!(serde_json::from_str::(frame).is_err()); - - let launch = r#"{ - "requestId":1, - "operationId":"op-1", - "workload":{ - "workloadId":"tools", - "realmId":"host", - "realmPath":["host"], - "canonicalTarget":"tools.host.d2b" - }, - "itemId":"browser", - "argv":["firefox"], - "graphical":true, - "cwd":"/tmp" + "payload":{"protocolVersion":2,"generation":1,"features":[],"uid":1000} }"#; - assert!(serde_json::from_str::(launch).is_err()); + assert!(serde_json::from_str::(hello).is_err()); + + let shell = serde_json::json!({ + "op": "list", + "args": { + "requestId": 1, + "operationId": "op-list", + "workload": serde_json::to_value(workload()).unwrap(), + "cwd": "/forbidden" + } + }); + assert!(serde_json::from_value::(shell).is_err()); + + let terminal = serde_json::json!({ + "op": "wait", + "args": {"requestId": 1, "timeoutMs": 1, "session": "forbidden"} + }); + assert!(serde_json::from_value::(terminal).is_err()); + + let response = serde_json::json!({ + "op": "rejected", + "result": { + "requestId": 1, + "code": "terminal-closed", + "message": "forbidden" + } + }); + assert!(serde_json::from_value::(response).is_err()); } #[test] - fn scope_identity_debug_hides_invocation_id() { - let canary = "private-canary-invocation"; - let scope = ScopeIdentity { - invocation_id: canary.to_owned(), - kind: HelperScopeKind::LauncherApp, + fn terminal_and_geometry_bounds_are_closed() { + assert_eq!(MAX_UNSAFE_LOCAL_TERMINAL_CHUNK_BYTES, EXEC_MAX_CHUNK_BYTES); + assert_eq!(MAX_UNSAFE_LOCAL_TERMINAL_OUTPUT_RING_BYTES, 8 * 1024 * 1024); + assert_eq!(UNSAFE_LOCAL_TERMINAL_LENGTH_PREFIX_BYTES, 4); + + let maximum = format!( + "{}==", + "A".repeat(MAX_UNSAFE_LOCAL_TERMINAL_BASE64_BYTES - 2) + ); + assert!(HelperTerminalChunkBase64::new(maximum).is_ok()); + let oversized = "A".repeat(MAX_UNSAFE_LOCAL_TERMINAL_BASE64_BYTES + 4); + assert!(HelperTerminalChunkBase64::new(oversized).is_err()); + assert!(HelperTerminalChunkBase64::new("not padded").is_err()); + + let oversized_read = HelperTerminalRequest::ReadOutput(HelperTerminalReadOutput { + request_id: 1, + stream: TerminalStream::Stdout, + cursor: 0, + max_len: MAX_UNSAFE_LOCAL_TERMINAL_CHUNK_BYTES + 1, + wait: true, + timeout_ms: MAX_UNSAFE_LOCAL_TERMINAL_WAIT_TIMEOUT_MS + 1, + }); + assert_eq!( + oversized_read.validate_bounds(), + Err(HelperFailureCode::InvalidRequest) + ); + let zero_read = HelperTerminalRequest::ReadOutput(HelperTerminalReadOutput { + request_id: 1, + stream: TerminalStream::Stdout, + cursor: 0, + max_len: 0, + wait: false, + timeout_ms: 0, + }); + assert_eq!( + zero_read.validate_bounds(), + Err(HelperFailureCode::InvalidRequest) + ); + + let invalid_resize = HelperTerminalRequest::Resize(HelperTerminalResize { + request_id: 1, + control_sequence: 1, + rows: 0, + cols: 80, + }); + assert_eq!( + invalid_resize.validate_bounds(), + Err(HelperFailureCode::InvalidTerminalSize) + ); + + assert_eq!( + decode_unsafe_local_terminal_frame::(&[0, 0, 0]), + Err(HelperFailureCode::InvalidRequest) + ); + let oversized_prefix = ((MAX_UNSAFE_LOCAL_TERMINAL_FRAME_SIZE + 1) as u32).to_le_bytes(); + assert_eq!( + decode_unsafe_local_terminal_frame::(&oversized_prefix), + Err(HelperFailureCode::InvalidRequest) + ); + let valid = + encode_unsafe_local_terminal_frame(&HelperTerminalRequest::Wait(HelperTerminalWait { + request_id: 1, + timeout_ms: 1, + })) + .unwrap(); + let mut trailing = valid; + trailing.push(0); + assert_eq!( + decode_unsafe_local_terminal_frame::(&trailing), + Err(HelperFailureCode::InvalidRequest) + ); + + let invalid_policy = HelperShellPolicy { + default_name: shell_name("primary"), + max_sessions: 0, }; - assert!(!format!("{scope:?}").contains(canary)); + assert_eq!( + invalid_policy.validate_bounds(), + Err(HelperFailureCode::InvalidRequest) + ); } #[test] - fn shell_request_debug_hides_shell_name() { - let canary = "private-shell-name-canary"; + fn debug_redacts_names_terminal_bytes_and_supervisor_identity() { + let shell_canary = "private-shell-name-canary"; + let policy = HelperShellPolicy { + default_name: shell_name(shell_canary), + max_sessions: 8, + }; + assert!(!format!("{policy:?}").contains(shell_canary)); let request = HelperShellRequest::Attach { request_id: 1, - operation_id: OperationId::parse("op-1").unwrap(), - workload: serde_json::from_value(serde_json::json!({ - "workloadId": "tools", - "realmId": "host", - "realmPath": ["host"], - "canonicalTarget": "tools.host.d2b" - })) - .unwrap(), - name: Some(ShellName::new(canary).unwrap()), - tty: true, + operation_id: operation("op-1"), + workload: workload(), + policy, + name: Some(shell_name(shell_canary)), + force: true, + initial_terminal_size: TerminalSize { rows: 24, cols: 80 }, + }; + assert!(!format!("{request:?}").contains(shell_canary)); + + let bytes_canary = "cHJpdmF0ZS10ZXJtaW5hbC1jYW5hcnk="; + let output = HelperTerminalReadOutputResult { + data_base64: HelperTerminalChunkBase64::new(bytes_canary).unwrap(), + next_cursor: 24, + eof: false, + dropped_bytes: 0, + truncated: false, + timed_out: false, }; - assert!(!format!("{request:?}").contains(canary)); + assert!(!format!("{output:?}").contains(bytes_canary)); + + let supervisor_canary = "private-supervisor-canary"; + let snapshot = HelperPersistentShellSnapshot { + name: shell_name(shell_canary), + state: ShellSessionState::Detached, + attached: false, + supervisor_id: HelperSupervisorId::new(supervisor_canary).unwrap(), + }; + let debug = format!("{snapshot:?}"); + assert!(!debug.contains(shell_canary)); + assert!(!debug.contains(supervisor_canary)); + assert!(HelperSupervisorId::new("x".repeat(MAX_HELPER_SUPERVISOR_ID_BYTES + 1)).is_err()); + } + + #[test] + fn generated_shell_and_terminal_schema_excludes_authority_fields() { + let schema = serde_json::to_value(schemars::schema_for!(UnsafeLocalHelperWireSchema)) + .expect("schema serializes"); + let definitions = schema["definitions"].as_object().unwrap(); + let selected = definitions + .iter() + .filter(|(name, _)| { + name.starts_with("HelperShell") + || name.starts_with("HelperTerminal") + || *name == "HelperPersistentShellSnapshot" + }) + .map(|(_, definition)| definition) + .collect::>(); + let selected = serde_json::to_string(&selected).unwrap(); + for forbidden in [ + "\"uid\"", + "\"argv\"", + "\"environment\"", + "\"cwd\"", + "\"path\"", + "\"transcript\"", + "\"pid\"", + "\"unitName\"", + "\"compositor\"", + "\"session\"", + ] { + assert!( + !selected.contains(forbidden), + "private shell/terminal schema contains forbidden field {forbidden}" + ); + } + } + + #[test] + fn older_helper_versions_are_rejected_while_terminal_v1_is_preserved() { + assert_eq!(UNSAFE_LOCAL_HELPER_PROTOCOL_VERSION, 3); + assert!(!unsafe_local_helper_protocol_supported(1)); + assert!(!unsafe_local_helper_protocol_supported(2)); + assert!(unsafe_local_helper_protocol_supported(3)); + assert_eq!(UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION, 1); + } + + #[test] + fn realm_accent_color_is_strict_and_canonical() { + let color = RealmAccentColor::new("#cc3344").unwrap(); + assert_eq!(color.as_str(), "#cc3344"); + for invalid in [ + "cc3344", + "#CC3344", + "#123", + "#1234567", + "#12345g", + "#123456\n", + ] { + assert!(RealmAccentColor::new(invalid).is_err(), "{invalid:?}"); + assert!( + serde_json::from_value::(serde_json::json!(invalid)).is_err(), + "{invalid:?}" + ); + } } #[test] @@ -368,21 +1573,23 @@ mod tests { ); let ready = HelperTerminalReady { request_id: 1, - operation_id: OperationId::parse("op-1").unwrap(), + operation_id: operation("op-1"), terminal_protocol_version: UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION, transport: HelperTerminalTransport::ConnectedUnixStream, scope: ScopeIdentity { invocation_id: "opaque".to_owned(), kind: HelperScopeKind::PersistentShell, }, + result: HelperShellAttachResult { + resolved_name: shell_name("primary"), + state: ShellSessionState::Attached, + force_evicted: false, + }, }; + round_trip(&ready); let json = serde_json::to_string(&ready).unwrap(); assert!(json.contains("\"transport\":\"connected-unix-stream\"")); - assert!( - serde_json::from_str::( - r#"{"requestId":1,"operationId":"op-1","terminalProtocolVersion":1,"transport":"unix-datagram","scope":{"invocationId":"opaque","kind":"persistent-shell"}}"# - ) - .is_err() - ); + let invalid = json.replace("connected-unix-stream", "unix-datagram"); + assert!(serde_json::from_str::(&invalid).is_err()); } } diff --git a/packages/d2b-core/src/bundle.rs b/packages/d2b-core/src/bundle.rs index bd8e0593e..c4927e3ef 100644 --- a/packages/d2b-core/src/bundle.rs +++ b/packages/d2b-core/src/bundle.rs @@ -8,9 +8,9 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct Bundle { - /// Version of the bundle format; bundleVersion 10 adds provider-neutral - /// launcher metadata and configured unsafe-local workload metadata while - /// artifact schemaVersion stays v2. + /// Version of the bundle format; bundleVersion 11 extends the private + /// configured-item artifact to local VM workloads while artifact + /// schemaVersion stays v2. pub bundle_version: u32, /// Schema version directory used to validate all artifacts in this bundle. pub schema_version: String, diff --git a/packages/d2b-core/src/privileges.rs b/packages/d2b-core/src/privileges.rs index 14762b4c1..4200aadf0 100644 --- a/packages/d2b-core/src/privileges.rs +++ b/packages/d2b-core/src/privileges.rs @@ -456,6 +456,16 @@ pub const PUBLIC_OPERATION_AUTHZ: &[OperationAuthzRow] = &[ BrokerRequirement::No, AuditMode::Yes, ), + row( + "launch", + "workload/configured launch", + "per-workload/per-realm", + &["d2b-launcher", "d2b-admin"], + true, + SecretAccess::None, + BrokerRequirement::No, + AuditMode::Yes, + ), row( "exec", "VM/process", diff --git a/packages/d2b-core/src/unsafe_local_workloads.rs b/packages/d2b-core/src/unsafe_local_workloads.rs index 802517955..eca797fee 100644 --- a/packages/d2b-core/src/unsafe_local_workloads.rs +++ b/packages/d2b-core/src/unsafe_local_workloads.rs @@ -8,6 +8,9 @@ use std::collections::BTreeSet; pub const UNSAFE_LOCAL_WORKLOADS_SCHEMA_VERSION: &str = "v2"; pub const MAX_UNSAFE_LOCAL_WORKLOADS: usize = 256; +pub const MAX_LOCAL_VM_CONFIGURED_WORKLOADS: usize = 256; +pub const MAX_PRIVATE_CONFIGURED_WORKLOADS: usize = + MAX_UNSAFE_LOCAL_WORKLOADS + MAX_LOCAL_VM_CONFIGURED_WORKLOADS; pub const MAX_LAUNCHER_ITEMS_PER_WORKLOAD: usize = 64; pub const MAX_UNSAFE_LOCAL_SHELL_SESSIONS: u16 = 64; @@ -15,7 +18,14 @@ pub const MAX_UNSAFE_LOCAL_SHELL_SESSIONS: u16 = 64; #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UnsafeLocalWorkloadsJson { pub schema_version: String, + #[schemars(length(max = 256))] pub workloads: Vec, + /// Configured launcher items for local VM workloads. They share this + /// private, bundle-hashed artifact so argv never enters public launcher + /// metadata or the public request protocol. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(length(max = 256))] + pub local_vm_workloads: Vec, } impl UnsafeLocalWorkloadsJson { @@ -30,6 +40,16 @@ impl UnsafeLocalWorkloadsJson { "unsafe-local workload count exceeds {MAX_UNSAFE_LOCAL_WORKLOADS}" )); } + if self.local_vm_workloads.len() > MAX_LOCAL_VM_CONFIGURED_WORKLOADS { + return Err(format!( + "local-vm configured workload count exceeds {MAX_LOCAL_VM_CONFIGURED_WORKLOADS}" + )); + } + if self.workloads.len() + self.local_vm_workloads.len() > MAX_PRIVATE_CONFIGURED_WORKLOADS { + return Err(format!( + "private configured workload count exceeds {MAX_PRIVATE_CONFIGURED_WORKLOADS}" + )); + } let mut targets = BTreeSet::new(); for workload in &self.workloads { let target = workload.identity.canonical_target.to_canonical(); @@ -38,16 +58,43 @@ impl UnsafeLocalWorkloadsJson { } workload.validate()?; } + for workload in &self.local_vm_workloads { + let target = workload.identity.canonical_target.to_canonical(); + if !targets.insert(target.clone()) { + return Err(format!("duplicate configured workload target {target}")); + } + workload.validate()?; + } Ok(()) } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LocalVmConfiguredWorkload { + pub identity: WorkloadIdentity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_item_id: Option, + #[schemars(length(min = 1, max = 64))] + pub items: Vec, +} + +impl LocalVmConfiguredWorkload { + pub fn validate(&self) -> Result<(), String> { + if self.identity.runtime_kind.as_ref().map(|id| id.as_str()) != Some("nixos") { + return Err("local-vm configured workload must use nixos runtimeKind".to_owned()); + } + validate_items(&self.items, self.default_item_id.as_ref(), true) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UnsafeLocalWorkload { pub identity: WorkloadIdentity, #[serde(default, skip_serializing_if = "Option::is_none")] pub default_item_id: Option, + #[schemars(length(min = 1, max = 64))] pub items: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub shell: Option, @@ -66,39 +113,52 @@ impl UnsafeLocalWorkload { .to_owned(), ); } - if self.items.is_empty() { - return Err("unsafe-local workload must declare at least one launcher item".to_owned()); - } - if self.items.len() > MAX_LAUNCHER_ITEMS_PER_WORKLOAD { - return Err(format!( - "unsafe-local launcher item count exceeds {MAX_LAUNCHER_ITEMS_PER_WORKLOAD}" - )); - } - let mut ids = BTreeSet::new(); - for item in &self.items { - if !ids.insert(item.id()) { - return Err(format!( - "duplicate unsafe-local launcher item id {}", - item.id().as_str() - )); - } - if matches!(item, UnsafeLocalLauncherItem::Shell(_)) && self.shell.is_none() { - return Err("shell launcher item requires shell policy".to_owned()); - } + validate_items( + &self.items, + self.default_item_id.as_ref(), + self.shell.is_some(), + )?; + if let Some(shell) = &self.shell { + shell.validate()?; } - if let Some(default_item_id) = &self.default_item_id - && !ids.contains(default_item_id) - { + Ok(()) + } +} + +fn validate_items( + items: &[UnsafeLocalLauncherItem], + default_item_id: Option<&ProtocolToken>, + shell_enabled: bool, +) -> Result<(), String> { + if items.is_empty() { + return Err("configured workload must declare at least one launcher item".to_owned()); + } + if items.len() > MAX_LAUNCHER_ITEMS_PER_WORKLOAD { + return Err(format!( + "configured launcher item count exceeds {MAX_LAUNCHER_ITEMS_PER_WORKLOAD}" + )); + } + let mut ids = BTreeSet::new(); + for item in items { + if !ids.insert(item.id()) { return Err(format!( - "defaultItem {} does not name a declared launcher item", - default_item_id.as_str() + "duplicate configured launcher item id {}", + item.id().as_str() )); } - if let Some(shell) = &self.shell { - shell.validate()?; + if matches!(item, UnsafeLocalLauncherItem::Shell(_)) && !shell_enabled { + return Err("shell launcher item requires shell policy".to_owned()); } - Ok(()) } + if let Some(default_item_id) = default_item_id + && !ids.contains(default_item_id) + { + return Err(format!( + "defaultItem {} does not name a declared launcher item", + default_item_id.as_str() + )); + } + Ok(()) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -216,6 +276,18 @@ mod tests { } } + fn valid_local_vm_workload() -> LocalVmConfiguredWorkload { + let mut identity = identity(); + identity.runtime_kind = Some(crate::contract_id::ContractId::parse("nixos").unwrap()); + identity.provider_id = + Some(crate::contract_id::ContractId::parse("local-cloud-hypervisor").unwrap()); + LocalVmConfiguredWorkload { + identity, + default_item_id: Some(ProtocolToken::parse("browser").unwrap()), + items: vec![exec_item()], + } + } + #[test] fn artifact_validates_default_and_redacts_argv_debug() { let artifact = UnsafeLocalWorkloadsJson { @@ -226,6 +298,7 @@ mod tests { items: vec![exec_item()], shell: None, }], + local_vm_workloads: Vec::new(), }; artifact.validate().unwrap(); assert!(!format!("{artifact:?}").contains("firefox")); @@ -256,28 +329,85 @@ mod tests { assert!(!format!("{shell:?}").contains(canary)); } + #[test] + fn first_class_local_vm_workload_does_not_require_legacy_name() { + let mut workload = valid_local_vm_workload(); + workload.identity.legacy_vm_name = None; + + workload.validate().unwrap(); + } + #[test] fn artifact_rejects_wrong_schema_version() { let artifact = UnsafeLocalWorkloadsJson { schema_version: "v1".to_owned(), workloads: vec![valid_workload()], + local_vm_workloads: Vec::new(), }; assert!(artifact.validate().is_err()); } #[test] - fn artifact_rejects_workload_and_item_count_overflow() { - let artifact = UnsafeLocalWorkloadsJson { + fn artifact_enforces_separate_workload_and_item_bounds() { + let unsafe_overflow = UnsafeLocalWorkloadsJson { schema_version: "v2".to_owned(), workloads: vec![valid_workload(); MAX_UNSAFE_LOCAL_WORKLOADS + 1], + local_vm_workloads: Vec::new(), }; - assert!(artifact.validate().is_err()); + assert_eq!( + unsafe_overflow.validate().unwrap_err(), + format!("unsafe-local workload count exceeds {MAX_UNSAFE_LOCAL_WORKLOADS}") + ); + + let local_vm_overflow = UnsafeLocalWorkloadsJson { + schema_version: "v2".to_owned(), + workloads: Vec::new(), + local_vm_workloads: vec![ + valid_local_vm_workload(); + MAX_LOCAL_VM_CONFIGURED_WORKLOADS + 1 + ], + }; + assert_eq!( + local_vm_overflow.validate().unwrap_err(), + format!( + "local-vm configured workload count exceeds {MAX_LOCAL_VM_CONFIGURED_WORKLOADS}" + ) + ); let mut workload = valid_workload(); workload.items = vec![exec_item(); MAX_LAUNCHER_ITEMS_PER_WORKLOAD + 1]; assert!(workload.validate().is_err()); } + #[test] + fn schema_exposes_private_artifact_allocation_bounds() { + let schema = serde_json::to_value(schemars::schema_for!(UnsafeLocalWorkloadsJson)).unwrap(); + assert_eq!( + schema["properties"]["workloads"]["maxItems"], + MAX_UNSAFE_LOCAL_WORKLOADS + ); + assert_eq!( + schema["properties"]["localVmWorkloads"]["maxItems"], + MAX_LOCAL_VM_CONFIGURED_WORKLOADS + ); + assert_eq!( + schema["definitions"]["UnsafeLocalWorkload"]["properties"]["items"]["minItems"], + 1 + ); + assert_eq!( + schema["definitions"]["UnsafeLocalWorkload"]["properties"]["items"]["maxItems"], + MAX_LAUNCHER_ITEMS_PER_WORKLOAD + ); + assert_eq!( + schema["definitions"]["LocalVmConfiguredWorkload"]["properties"]["items"]["minItems"], + 1 + ); + assert_eq!( + schema["definitions"]["LocalVmConfiguredWorkload"]["properties"]["items"]["maxItems"], + MAX_LAUNCHER_ITEMS_PER_WORKLOAD + ); + } + #[test] fn artifact_rejects_shell_quota_overflow() { let mut workload = valid_workload(); @@ -307,4 +437,30 @@ mod tests { workload.default_item_id = Some(ProtocolToken::parse("missing").unwrap()); assert!(workload.validate().is_err()); } + + #[test] + fn local_vm_items_share_private_artifact_without_public_argv() { + let mut local_identity = identity(); + local_identity.legacy_vm_name = + Some(crate::contract_id::ContractId::parse("corp-vm").unwrap()); + local_identity.runtime_kind = Some(crate::contract_id::ContractId::parse("nixos").unwrap()); + local_identity.provider_id = + Some(crate::contract_id::ContractId::parse("local-cloud-hypervisor").unwrap()); + let artifact = UnsafeLocalWorkloadsJson { + schema_version: "v2".to_owned(), + workloads: Vec::new(), + local_vm_workloads: vec![LocalVmConfiguredWorkload { + identity: local_identity, + default_item_id: Some(ProtocolToken::parse("browser").unwrap()), + items: vec![exec_item()], + }], + }; + artifact.validate().unwrap(); + let json = serde_json::to_value(&artifact).unwrap(); + assert_eq!( + json["localVmWorkloads"][0]["items"][0]["argv"][0], + "firefox" + ); + assert!(!format!("{artifact:?}").contains("firefox")); + } } diff --git a/packages/d2b-core/tests/bundle_resolver_tamper.rs b/packages/d2b-core/tests/bundle_resolver_tamper.rs index 83ef45ae8..6865f5036 100644 --- a/packages/d2b-core/tests/bundle_resolver_tamper.rs +++ b/packages/d2b-core/tests/bundle_resolver_tamper.rs @@ -429,7 +429,7 @@ fn bundle_json_with_full_hashes( fn unsafe_local_bundle_pre_hash() -> Vec { serde_json::to_vec(&serde_json::json!({ "artifactHashes": null, - "bundleVersion": 10, + "bundleVersion": 11, "schemaVersion": "v2", "publicManifestPath": "vms.json", "hostPath": "host.json", @@ -511,7 +511,7 @@ fn loads_hashed_unsafe_local_workloads_artifact() { let bundle_path = write_unsafe_local_bundle(dir.path(), &policy); let resolver = BundleResolver::load_with_policy(&bundle_path, &policy).expect("unsafe-local bundle loads"); - assert_eq!(resolver.bundle.bundle_version, 10); + assert_eq!(resolver.bundle.bundle_version, 11); assert!(resolver.realm_workloads_launcher_v2.is_some()); assert!( resolver diff --git a/packages/d2b-guestd/src/detached_registry.rs b/packages/d2b-guestd/src/detached_registry.rs index 455f4a4a8..5f66a8cc6 100644 --- a/packages/d2b-guestd/src/detached_registry.rs +++ b/packages/d2b-guestd/src/detached_registry.rs @@ -20,6 +20,7 @@ use std::time::Duration; use async_trait::async_trait; use sha2::{Digest, Sha256}; +use tokio::sync::watch; use crate::detached::{ ManagedUnit, ManagedUnitKind, RunnerUnitPaths, TransientUnitManager, UnitIdentity, @@ -203,6 +204,9 @@ struct SlotEntry { active_counted: bool, /// In-flight `ExecLogs` reads (GC defers unlink while > 0). read_guards: u32, + /// Completion channel for callers coalesced onto a deterministic-id create. + /// Re-adopted records have no local creator and therefore no channel. + create_resolution: Option>, } impl SlotEntry { @@ -217,6 +221,21 @@ impl SlotEntry { } } +#[derive(Debug, Clone)] +enum CreateResolution { + Creating, + Finished(Result), +} + +enum CreateAdmission { + Reserved { + slot: u32, + resolution: watch::Sender, + }, + ReplayVisible, + ReplayCreating(watch::Receiver), +} + /// Per-slot unit liveness resolved against systemd. A query error is its /// own variant — it is NEVER collapsed into `Absent`, so a transient /// `systemctl` failure cannot trigger destructive reconciliation. @@ -289,6 +308,13 @@ impl RegistryState { .map(|(slot, _)| *slot) } + fn find_any_by_id(&self, exec_id: &str) -> Option { + self.slots + .iter() + .find(|(_, entry)| entry.record.exec_id == exec_id) + .map(|(slot, _)| *slot) + } + fn push_tombstone(&mut self, exec_id: String) { if self.tombstones.len() >= DETACHED_RETAINED_PER_VM { self.tombstones.pop_front(); @@ -400,6 +426,27 @@ impl DetachedRegistry { boot_id: &str, command: ValidatedCommand, caps: DetachedCaps, + ) -> Result<(String, ExecSnapshot), ExecError> { + self.create_inner(boot_id, command, caps, None).await + } + + pub async fn create_with_exec_id( + &self, + boot_id: &str, + command: ValidatedCommand, + caps: DetachedCaps, + exec_id: String, + ) -> Result<(String, ExecSnapshot), ExecError> { + self.create_inner(boot_id, command, caps, Some(exec_id)) + .await + } + + async fn create_inner( + &self, + boot_id: &str, + command: ValidatedCommand, + caps: DetachedCaps, + requested_exec_id: Option, ) -> Result<(String, ExecSnapshot), ExecError> { Self::assert_quota_invariant(); if boot_id != self.config.boot_id { @@ -407,53 +454,161 @@ impl DetachedRegistry { } let argv_sha256 = argv_hash(&command); - let exec_id = self.ids.next_exec_id()?; + let has_requested_exec_id = requested_exec_id.is_some(); + let exec_id = if let Some(exec_id) = requested_exec_id { + if !is_valid_exec_id(&exec_id) { + return Err(ExecError::InvalidArgv); + } + exec_id + } else { + self.ids.next_exec_id()? + }; let now = self.clock.now_ms(); - // Step 1: reserve slot + active + quota under the Creating guard. - let slot = { + // Step 1: replay or reserve slot + active + quota atomically under the + // Creating guard. The recheck here prevents duplicate reservations. + let admission = { let mut state = self.lock(); - if state.active >= DETACHED_ACTIVE_PER_VM as u32 { - return Err(ExecError::ExecCapacityExceeded); + if has_requested_exec_id { + if state.is_tombstoned(&exec_id) { + return Err(ExecError::ExecExpired); + } + if let Some(existing_slot) = state.find_any_by_id(&exec_id) { + let existing = state + .slots + .get(&existing_slot) + .expect("slot found by requested exec id"); + if existing.record.argv_sha256 != argv_sha256 { + return Err(ExecError::InvalidArgv); + } + if existing.creating { + let receiver = existing + .create_resolution + .as_ref() + .ok_or(ExecError::Internal)? + .clone(); + CreateAdmission::ReplayCreating(receiver) + } else if existing.dispatch_hold { + // A restart-recovered dispatch is deliberately hidden + // until the reaper resolves it. It is not a teardown. + return Err(ExecError::Internal); + } else { + CreateAdmission::ReplayVisible + } + } else { + self.reserve_create(&mut state, &exec_id, &argv_sha256, &caps, now)? + } + } else { + self.reserve_create(&mut state, &exec_id, &argv_sha256, &caps, now)? } - let Some(slot) = state.free_slot() else { - return Err(ExecError::ExecCapacityExceeded); - }; - let reserve = caps.reserved_bytes(); - if state.reserved_log_bytes.saturating_add(reserve) > DETACHED_LOG_QUOTA_BYTES { - return Err(ExecError::RetainedLogQuotaExceeded); + }; + + let (slot, resolution) = match admission { + CreateAdmission::ReplayVisible => { + let snapshot = self.inspect(&exec_id, boot_id).await?; + return Ok((exec_id, snapshot)); } - let record = DurableRecord { - exec_id: exec_id.clone(), - slot, - boot_id: self.config.boot_id.clone(), - create_time_unix: now, - dispatch_deadline_unix: now.saturating_add(DISPATCH_DEADLINE_MS), - argv_sha256: argv_sha256.clone(), - state: RecordState::Dispatching, - exit_code: None, - term_signal: None, - lost: false, - terminal_time_unix: None, - }; - state.reserved_log_bytes = state.reserved_log_bytes.saturating_add(reserve); - state.active = state.active.saturating_add(1); - state.slots.insert( - slot, - SlotEntry { - record, - caps: caps.clone(), - creating: true, - dispatch_hold: false, - generation: 0, - active_counted: true, - read_guards: 0, - }, - ); - slot + CreateAdmission::ReplayCreating(receiver) => { + return self.await_create_replay(exec_id, receiver).await; + } + CreateAdmission::Reserved { slot, resolution } => (slot, resolution), }; - let spec = match build_spec(&command, &caps, &self.config, slot) { + let result = self + .execute_reserved_create(slot, &exec_id, &command, &caps) + .await; + resolution.send_replace(CreateResolution::Finished( + result + .as_ref() + .map(|(_, snapshot)| *snapshot) + .map_err(|e| *e), + )); + result + } + + fn reserve_create( + &self, + state: &mut RegistryState, + exec_id: &str, + argv_sha256: &str, + caps: &DetachedCaps, + now: u64, + ) -> Result { + if state.active >= DETACHED_ACTIVE_PER_VM as u32 { + return Err(ExecError::ExecCapacityExceeded); + } + let Some(slot) = state.free_slot() else { + return Err(ExecError::ExecCapacityExceeded); + }; + let reserve = caps.reserved_bytes(); + if state.reserved_log_bytes.saturating_add(reserve) > DETACHED_LOG_QUOTA_BYTES { + return Err(ExecError::RetainedLogQuotaExceeded); + } + let record = DurableRecord { + exec_id: exec_id.to_owned(), + slot, + boot_id: self.config.boot_id.clone(), + create_time_unix: now, + dispatch_deadline_unix: now.saturating_add(DISPATCH_DEADLINE_MS), + argv_sha256: argv_sha256.to_owned(), + state: RecordState::Dispatching, + exit_code: None, + term_signal: None, + lost: false, + terminal_time_unix: None, + }; + let (resolution, receiver) = watch::channel(CreateResolution::Creating); + state.reserved_log_bytes = state.reserved_log_bytes.saturating_add(reserve); + state.active = state.active.saturating_add(1); + state.slots.insert( + slot, + SlotEntry { + record, + caps: caps.clone(), + creating: true, + dispatch_hold: false, + generation: 0, + active_counted: true, + read_guards: 0, + create_resolution: Some(receiver), + }, + ); + Ok(CreateAdmission::Reserved { slot, resolution }) + } + + async fn await_create_replay( + &self, + exec_id: String, + mut receiver: watch::Receiver, + ) -> Result<(String, ExecSnapshot), ExecError> { + let wait = async { + loop { + let resolution = receiver.borrow().clone(); + if let CreateResolution::Finished(result) = resolution { + return result; + } + receiver.changed().await.map_err(|_| ExecError::Internal)?; + } + }; + match tokio::time::timeout( + Duration::from_millis(CREATE_TIMEOUT_MS + STATUS_POLL_INTERVAL_MS), + wait, + ) + .await + { + Ok(result) => result.map(|snapshot| (exec_id, snapshot)), + Err(_) => Err(ExecError::Internal), + } + } + + async fn execute_reserved_create( + &self, + slot: u32, + exec_id: &str, + command: &ValidatedCommand, + caps: &DetachedCaps, + ) -> Result<(String, ExecSnapshot), ExecError> { + let spec = match build_spec(command, caps, &self.config, slot) { Ok(spec) => spec, Err(error) => { self.abort_create(slot).await; @@ -479,7 +634,7 @@ impl DetachedRegistry { } // Step 5: await the runner's first phase marker, bounded by CREATE_TIMEOUT. - self.await_create_resolution(slot, &exec_id).await + self.await_create_resolution(slot, exec_id).await } fn persist_dispatch(&self, slot: u32, spec: &ExecSpec) -> Result<(), ExecError> { @@ -603,6 +758,7 @@ impl DetachedRegistry { } entry.creating = false; entry.dispatch_hold = false; + entry.create_resolution = None; } let record = state.slots.get(&slot).map(|e| e.record.clone()); drop(state); @@ -633,6 +789,7 @@ impl DetachedRegistry { } entry.creating = false; entry.dispatch_hold = false; + entry.create_resolution = None; let record = entry.record.clone(); state.release_active(slot); record @@ -1504,6 +1661,7 @@ impl DetachedRegistry { generation: 0, active_counted, read_guards: 0, + create_resolution: None, }, ); let _ = self @@ -2459,6 +2617,9 @@ mod tests { fn stopped(&self, slot: u32) -> bool { self.inner.lock().unwrap().stopped.contains(&slot) } + fn start_count(&self) -> usize { + self.inner.lock().unwrap().started.len() + } fn set_fail_list(&self, fail: bool) { self.inner.lock().unwrap().fail_list = fail; } @@ -2782,13 +2943,125 @@ mod tests { .expect("create"); assert_eq!(id, format!("{:032x}", 1)); assert_eq!(snapshot.state, ExecState::Running); - // Now listable. let list = h.registry.list("boot-A").await.unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].exec_id, id); assert_eq!(list[0].slot, 0); } + #[tokio::test] + async fn requested_exec_id_replays_without_duplicate_spawn() { + let h = harness(); + h.units.set_live(0, true); + h.store.set_status(0, StatusPhase::Started); + let requested = "0123456789abcdef0123456789abcdef".to_owned(); + let (first_id, first) = h + .registry + .create_with_exec_id( + "boot-A", + command(), + DetachedCaps::standard(0), + requested.clone(), + ) + .await + .unwrap(); + let (second_id, second) = h + .registry + .create_with_exec_id( + "boot-A", + command(), + DetachedCaps::standard(0), + requested.clone(), + ) + .await + .unwrap(); + + assert_eq!(first_id, requested); + assert_eq!(second_id, first_id); + assert_eq!(second.state, first.state); + assert_eq!(h.registry.list("boot-A").await.unwrap().len(), 1); + assert_eq!(h.units.start_count(), 1); + assert_eq!( + h.registry + .create_with_exec_id( + "boot-A", + command_with_program("/bin/false"), + DetachedCaps::standard(0), + requested, + ) + .await, + Err(ExecError::InvalidArgv) + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_requested_exec_id_coalesces_while_creating() { + let Harness { + registry, + store, + units, + now: _now, + events: _events, + } = harness(); + units.set_live(0, true); + let gate = Arc::new(ReadGate::default()); + store.install_status_gate(Arc::clone(&gate)); + let registry = Arc::new(registry); + let requested = "fedcba9876543210fedcba9876543210".to_owned(); + let first_registry = Arc::clone(®istry); + let first_requested = requested.clone(); + let first = tokio::spawn(async move { + first_registry + .create_with_exec_id( + "boot-A", + command(), + DetachedCaps::standard(0), + first_requested, + ) + .await + }); + gate.wait_until_entered(); + + assert_eq!( + registry + .create_with_exec_id( + "boot-A", + command_with_program("/bin/false"), + DetachedCaps::standard(0), + requested.clone(), + ) + .await, + Err(ExecError::InvalidArgv) + ); + + let second_registry = Arc::clone(®istry); + let second_requested = requested.clone(); + let second = tokio::spawn(async move { + second_registry + .create_with_exec_id( + "boot-A", + command(), + DetachedCaps::standard(0), + second_requested, + ) + .await + }); + tokio::task::yield_now().await; + assert!( + !second.is_finished(), + "same-id replay must wait for the creating request" + ); + + store.set_status(0, StatusPhase::Started); + gate.release(); + let first = first.await.unwrap().unwrap(); + let second = second.await.unwrap().unwrap(); + assert_eq!(first.0, requested); + assert_eq!(second, first); + assert_eq!(units.start_count(), 1); + assert_eq!(registry.list("boot-A").await.unwrap().len(), 1); + } + #[test] fn workload_systemd_run_args_mirror_login_session_and_never_root() { let config = test_registry_config(); diff --git a/packages/d2b-guestd/src/service.rs b/packages/d2b-guestd/src/service.rs index fac2a8b4c..b784133fa 100644 --- a/packages/d2b-guestd/src/service.rs +++ b/packages/d2b-guestd/src/service.rs @@ -2082,6 +2082,7 @@ impl GuestControlService { &self, input: ExecCreateInput, guest_boot_id: &str, + requested_exec_id: Option, ) -> ttrpc::Result { let Some(registry) = self.detached.as_ref() else { let mut response = pb::ExecCreateResponse::new(); @@ -2100,10 +2101,16 @@ impl GuestControlService { } }; - match registry - .create(guest_boot_id, command, registry.default_caps()) - .await - { + let created = if let Some(exec_id) = requested_exec_id { + registry + .create_with_exec_id(guest_boot_id, command, registry.default_caps(), exec_id) + .await + } else { + registry + .create(guest_boot_id, command, registry.default_caps()) + .await + }; + match created { Ok((exec_id, snapshot)) => { let mut response = pb::ExecCreateResponse::new(); response.exec_id = Some(exec_id); @@ -2708,7 +2715,14 @@ impl GuestControl for GuestControlService { response.error = MessageField::some(guest_error_kind(ExecError::UnsupportedMode)); return Ok(response); } - return self.exec_create_detached(input, &guest_boot_id).await; + let requested_exec_id = request + .metadata + .as_ref() + .and_then(|metadata| metadata.request_id.strip_prefix("workload-launch:")) + .map(str::to_owned); + return self + .exec_create_detached(input, &guest_boot_id, requested_exec_id) + .await; } // Interactive TTY exec: tty=true && !detached routes to the PTY-backed, diff --git a/packages/d2b-priv-broker/src/ops/disk_init.rs b/packages/d2b-priv-broker/src/ops/disk_init.rs index 0831e4c0f..e3c5075ac 100644 --- a/packages/d2b-priv-broker/src/ops/disk_init.rs +++ b/packages/d2b-priv-broker/src/ops/disk_init.rs @@ -894,18 +894,22 @@ mod tests { use std::fs; use std::os::unix::fs::{MetadataExt, PermissionsExt}; use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_SCRATCH_ID: AtomicU64 = AtomicU64::new(0); fn scratch_root() -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "d2b-disk-init-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - fs::create_dir_all(&dir).unwrap(); - dir + for _ in 0..32 { + let id = NEXT_SCRATCH_ID.fetch_add(1, Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("d2b-disk-init-{}-{id}", std::process::id())); + match fs::create_dir(&dir) { + Ok(()) => return dir, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("failed to create disk-init test directory: {error}"), + } + } + panic!("failed to reserve a unique disk-init test directory") } fn test_spec(path: PathBuf, size_bytes: u64, if_absent: bool) -> ResolvedDiskInitOp { @@ -1208,12 +1212,10 @@ mod tests { let scratch = scratch_root(); let target = scratch.join("var.img"); let file = create_regular_image(&target, 4096, 0o600); - drop(file); - let spec = test_spec(target.clone(), 4096, true); let stderr = format!("permission denied {}", "x".repeat(MKFS_STDERR_LIMIT * 2)); let tool = failing_mkfs_tool(&scratch, &stderr); - let err = validate_or_repair_existing_with(&spec, &tool) + let err = run_mkfs_ext4_on_fd_with(&file, &target, &tool) .expect_err("failing mkfs must surface stderr"); let rendered = err.to_string(); assert!(rendered.contains("exit=Some(9)")); diff --git a/packages/d2b-unsafe-local-helper/Cargo.toml b/packages/d2b-unsafe-local-helper/Cargo.toml index 960ebb770..3a3b0e48a 100644 --- a/packages/d2b-unsafe-local-helper/Cargo.toml +++ b/packages/d2b-unsafe-local-helper/Cargo.toml @@ -14,10 +14,13 @@ clap = { version = "4.5", features = ["derive"] } d2b-contracts = { path = "../d2b-contracts", version = "0.0.0-bootstrap" } d2b-core = { path = "../d2b-core", version = "0.0.0-bootstrap" } d2b-realm-core = { path = "../d2b-realm-core", version = "0.0.0-bootstrap" } +d2b-wayland-proxy = { path = "../d2b-wayland-proxy", version = "0.1.0" } getrandom = "0.2" nix = { version = "0.29", features = ["fs", "poll", "socket", "uio", "user", "signal", "process"] } +rustix = { workspace = true } serde.workspace = true serde_json.workspace = true +sha2 = "0.10" socket2 = "0.5" uzers = "0.12" zbus = { version = "5.16", features = ["blocking-api"] } diff --git a/packages/d2b-unsafe-local-helper/src/environment.rs b/packages/d2b-unsafe-local-helper/src/environment.rs index 3aa8bcefd..a09902b28 100644 --- a/packages/d2b-unsafe-local-helper/src/environment.rs +++ b/packages/d2b-unsafe-local-helper/src/environment.rs @@ -5,6 +5,8 @@ use std::path::{Path, PathBuf}; pub const MAX_MANAGER_ENVIRONMENT_ENTRIES: usize = 4096; pub const MAX_MANAGER_ENVIRONMENT_BYTES: usize = 256 * 1024; +pub const PERSISTENT_SHELL_TERM: &str = "xterm-256color"; +pub const PERSISTENT_SHELL_COLORTERM: &str = "truecolor"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EnvironmentError { @@ -13,8 +15,10 @@ pub enum EnvironmentError { InvalidEntry, DuplicateKey, PathMissing, + RuntimeDirectoryInvalid, ExecutableUnavailable, ProxyUnavailable, + WaylandUnavailable, } #[derive(Clone, PartialEq, Eq)] @@ -73,7 +77,7 @@ impl ManagerEnvironment { let mut entries = self.entries.clone(); if graphical { let display = proxy_wayland_display.ok_or(EnvironmentError::ProxyUnavailable)?; - if display.is_empty() || display.contains('\0') || display.contains('/') { + if !valid_proxy_display(display) { return Err(EnvironmentError::ProxyUnavailable); } entries.remove("DISPLAY"); @@ -82,6 +86,16 @@ impl ManagerEnvironment { Ok(entries) } + pub fn persistent_shell_entries(&self) -> BTreeMap { + let mut entries = self.entries.clone(); + entries.insert("TERM".to_owned(), PERSISTENT_SHELL_TERM.to_owned()); + entries.insert( + "COLORTERM".to_owned(), + PERSISTENT_SHELL_COLORTERM.to_owned(), + ); + entries + } + pub fn path(&self) -> Result<&str, EnvironmentError> { self.entries .get("PATH") @@ -98,10 +112,31 @@ impl ManagerEnvironment { .unwrap_or_else(|| passwd_home.join(".local/state")) } + pub fn runtime_directory(&self) -> Result { + self.entries + .get("XDG_RUNTIME_DIR") + .filter(|value| value.starts_with('/') && !value.contains('\0')) + .map(PathBuf::from) + .ok_or(EnvironmentError::RuntimeDirectoryInvalid) + } + + pub fn wayland_display(&self) -> Result<&str, EnvironmentError> { + self.entries + .get("WAYLAND_DISPLAY") + .map(String::as_str) + .filter(|value| { + !value.is_empty() + && !value.contains('\0') + && !value.split('/').any(|component| component == "..") + }) + .ok_or(EnvironmentError::WaylandUnavailable) + } + pub fn resolve_program(&self, program: &str) -> Result { if program.is_empty() || program.contains('\0') { return Err(EnvironmentError::ExecutableUnavailable); } + if program.contains('/') { let path = PathBuf::from(program); return executable_file(&path) @@ -123,6 +158,22 @@ impl ManagerEnvironment { } } +pub(crate) fn valid_proxy_display(display: &str) -> bool { + let Some((directory, socket)) = display.split_once('/') else { + return false; + }; + socket == "wayland.sock" + && !directory.contains('/') + && directory + .strip_prefix("d2b-unsafe-local-") + .is_some_and(|suffix| { + suffix.len() == 32 + && suffix + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + fn valid_key(key: &str) -> bool { let mut bytes = key.bytes(); matches!(bytes.next(), Some(first) if first == b'_' || first.is_ascii_alphabetic()) @@ -175,13 +226,81 @@ mod tests { Err(EnvironmentError::ProxyUnavailable) ); let child = environment - .child_entries(true, Some("d2b-wayland-proxy-1")) + .child_entries( + true, + Some("d2b-unsafe-local-00112233445566778899aabbccddeeff/wayland.sock"), + ) .unwrap(); assert!(!child.contains_key("DISPLAY")); assert_eq!( child.get("WAYLAND_DISPLAY").map(String::as_str), - Some("d2b-wayland-proxy-1") + Some("d2b-unsafe-local-00112233445566778899aabbccddeeff/wayland.sock") + ); + for invalid in [ + "wayland.sock", + "/absolute/wayland.sock", + "../wayland.sock", + "d2b-unsafe-local-00112233445566778899aabbccddeeff/../wayland.sock", + "d2b-unsafe-local-00112233445566778899aabbccddeefg/wayland.sock", + "d2b-unsafe-local-00112233445566778899aabbccddeeff/other.sock", + ] { + assert_eq!( + environment.child_entries(true, Some(invalid)), + Err(EnvironmentError::ProxyUnavailable), + "{invalid:?}" + ); + } + } + + #[test] + fn persistent_shell_environment_overrides_non_terminal_manager_values_only() { + let environment = ManagerEnvironment::parse(vec![ + "PATH=/run/current-system/sw/bin".to_owned(), + "PRIVATE_VALUE=preserved".to_owned(), + "TERM=dumb".to_owned(), + "COLORTERM=manager-value".to_owned(), + ]) + .unwrap(); + + let child = environment.persistent_shell_entries(); + assert_eq!( + child.get("TERM").map(String::as_str), + Some(PERSISTENT_SHELL_TERM) + ); + assert_eq!( + child.get("COLORTERM").map(String::as_str), + Some(PERSISTENT_SHELL_COLORTERM) + ); + assert_eq!( + child.get("PRIVATE_VALUE").map(String::as_str), + Some("preserved") + ); + assert_eq!( + child.get("PATH").map(String::as_str), + Some("/run/current-system/sw/bin") + ); + assert_eq!(child.len(), 4); + } + + #[test] + fn wayland_display_accepts_socket_basename_and_rejects_invalid_values() { + let with_display = |display: Option<&str>| ManagerEnvironment { + entries: display + .map(|value| BTreeMap::from([("WAYLAND_DISPLAY".to_owned(), value.to_owned())])) + .unwrap_or_default(), + encoded_bytes: 0, + }; + + assert_eq!( + with_display(Some("wayland-1")).wayland_display(), + Ok("wayland-1") ); + for display in [None, Some(""), Some("wayland\0-1"), Some("../wayland-1")] { + assert_eq!( + with_display(display).wayland_display(), + Err(EnvironmentError::WaylandUnavailable) + ); + } } #[test] diff --git a/packages/d2b-unsafe-local-helper/src/lib.rs b/packages/d2b-unsafe-local-helper/src/lib.rs index 965524f77..079b07701 100644 --- a/packages/d2b-unsafe-local-helper/src/lib.rs +++ b/packages/d2b-unsafe-local-helper/src/lib.rs @@ -1,4 +1,21 @@ pub mod environment; +mod output_ring; pub mod protocol; pub mod runtime; +pub mod shell_runtime; +mod shell_socket; +mod shell_supervisor; +mod supervisor_protocol; pub mod systemd; +mod tty_exec; + +pub fn run_tty_exec(args: &[String]) -> i32 { + tty_exec::run(args) +} + +#[derive(Debug)] +pub struct ShellSupervisorRunError; + +pub fn run_shell_supervisor() -> Result<(), ShellSupervisorRunError> { + shell_supervisor::run_shell_supervisor().map_err(|_| ShellSupervisorRunError) +} diff --git a/packages/d2b-unsafe-local-helper/src/main.rs b/packages/d2b-unsafe-local-helper/src/main.rs index 54bb8c0ae..d5a0f0e43 100644 --- a/packages/d2b-unsafe-local-helper/src/main.rs +++ b/packages/d2b-unsafe-local-helper/src/main.rs @@ -13,22 +13,39 @@ struct Cli { socket: PathBuf, #[arg(long, default_value = "d2bd")] daemon_user: String, + #[arg(long, value_name = "NIX_STORE_BINARY")] + wayland_proxy: Option, } #[derive(Debug, Subcommand)] enum Command { #[command(hide = true)] ScopeSupervisor, + #[command(hide = true)] + ShellSupervisor, } fn main() { + let raw = std::env::args().skip(1).collect::>(); + if raw.first().map(String::as_str) == Some("--tty-exec") { + std::process::exit(d2b_unsafe_local_helper::run_tty_exec(&raw[1..])); + } let cli = Cli::parse(); let result = match cli.command { - Some(Command::ScopeSupervisor) => { - run_scope_supervisor().map_err(|_| "scope runtime failed") + Some(Command::ScopeSupervisor) => run_scope_supervisor().map_err(|error| { + eprintln!("scope runtime failed: {error:?}"); + "scope runtime failed" + }), + Some(Command::ShellSupervisor) => { + d2b_unsafe_local_helper::run_shell_supervisor().map_err(|_| "shell runtime failed") } - None => ScopeRuntime::new(SystemdUserScopeManager::new()) - .map_err(|_| "helper runtime unavailable") + None => cli + .wayland_proxy + .ok_or("immutable Wayland proxy path is required") + .and_then(|proxy| { + ScopeRuntime::new(SystemdUserScopeManager::new(), proxy) + .map_err(|_| "helper runtime unavailable") + }) .and_then(|runtime| { HelperClient::new(cli.socket, &cli.daemon_user, runtime) .map_err(|_| "helper registration failed") diff --git a/packages/d2b-unsafe-local-helper/src/output_ring.rs b/packages/d2b-unsafe-local-helper/src/output_ring.rs new file mode 100644 index 000000000..ec32fc3c7 --- /dev/null +++ b/packages/d2b-unsafe-local-helper/src/output_ring.rs @@ -0,0 +1,213 @@ +use std::collections::VecDeque; +use std::fmt; +use std::sync::{Condvar, Mutex}; +use std::time::{Duration, Instant}; + +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct RingRead { + pub data: Vec, + pub next_cursor: u64, + pub eof: bool, + pub dropped_bytes: u64, + pub truncated: bool, + pub timed_out: bool, +} + +struct RingState { + bytes: VecDeque, + start_cursor: u64, + end_cursor: u64, + eof: bool, +} + +pub(crate) struct OutputRing { + capacity: usize, + state: Mutex, + changed: Condvar, +} + +impl fmt::Debug for RingRead { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RingRead") + .field("data_len", &self.data.len()) + .field("next_cursor", &self.next_cursor) + .field("eof", &self.eof) + .field("dropped_bytes", &self.dropped_bytes) + .field("truncated", &self.truncated) + .field("timed_out", &self.timed_out) + .finish() + } +} + +impl fmt::Debug for OutputRing { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OutputRing") + .field("capacity", &self.capacity) + .finish_non_exhaustive() + } +} + +impl OutputRing { + pub(crate) fn new(capacity: usize) -> Option { + (capacity > 0).then(|| Self { + capacity, + state: Mutex::new(RingState { + bytes: VecDeque::with_capacity(capacity), + start_cursor: 0, + end_cursor: 0, + eof: false, + }), + changed: Condvar::new(), + }) + } + + pub(crate) fn append(&self, input: &[u8]) { + let Ok(mut state) = self.state.lock() else { + return; + }; + for byte in input { + if state.bytes.len() == self.capacity { + state.bytes.pop_front(); + state.start_cursor = state.start_cursor.saturating_add(1); + } + state.bytes.push_back(*byte); + state.end_cursor = state.end_cursor.saturating_add(1); + } + self.changed.notify_all(); + } + + pub(crate) fn close(&self) { + if let Ok(mut state) = self.state.lock() { + state.eof = true; + self.changed.notify_all(); + } + } + + pub(crate) fn read( + &self, + cursor: u64, + max_len: usize, + wait: bool, + timeout: Duration, + ) -> RingRead { + let deadline = Instant::now() + timeout; + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut timed_out = false; + loop { + let effective_cursor = cursor.max(state.start_cursor); + if effective_cursor < state.end_cursor || state.eof || !wait { + break; + } + let now = Instant::now(); + if now >= deadline { + timed_out = true; + break; + } + let remaining = deadline.saturating_duration_since(now); + let (next, result) = self + .changed + .wait_timeout(state, remaining) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state = next; + if result.timed_out() { + timed_out = true; + break; + } + } + + let effective_cursor = cursor.max(state.start_cursor); + let dropped_bytes = state.start_cursor.saturating_sub(cursor); + let available = state.end_cursor.saturating_sub(effective_cursor) as usize; + let take = available.min(max_len); + let skip = effective_cursor.saturating_sub(state.start_cursor) as usize; + let data = state + .bytes + .iter() + .skip(skip) + .take(take) + .copied() + .collect::>(); + let data_is_empty = data.is_empty(); + let next_cursor = effective_cursor.saturating_add(data.len() as u64); + RingRead { + data, + next_cursor, + eof: state.eof && next_cursor >= state.end_cursor, + dropped_bytes, + truncated: dropped_bytes > 0 || available > take, + timed_out: timed_out && data_is_empty && !state.eof, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn cursor_reads_wrap_and_report_exact_gap() { + let ring = OutputRing::new(5).unwrap(); + ring.append(b"abc"); + assert_eq!( + ring.read(0, 2, false, Duration::ZERO), + RingRead { + data: b"ab".to_vec(), + next_cursor: 2, + eof: false, + dropped_bytes: 0, + truncated: true, + timed_out: false, + } + ); + + ring.append(b"defgh"); + let wrapped = ring.read(0, 16, false, Duration::ZERO); + assert_eq!(wrapped.data, b"defgh"); + assert_eq!(wrapped.next_cursor, 8); + assert_eq!(wrapped.dropped_bytes, 3); + assert!(wrapped.truncated); + } + + #[test] + fn wait_wakes_for_output_then_eof() { + let ring = Arc::new(OutputRing::new(32).unwrap()); + let producer = Arc::clone(&ring); + let writer = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(20)); + producer.append(b"ready"); + producer.close(); + }); + let read = ring.read(0, 32, true, Duration::from_secs(1)); + assert_eq!(read.data, b"ready"); + assert!(!read.timed_out); + let eof = ring.read(read.next_cursor, 32, true, Duration::from_secs(1)); + writer.join().unwrap(); + assert!(eof.data.is_empty()); + assert!(eof.eof); + assert!(!eof.timed_out); + } + + #[test] + fn wait_timeout_is_bounded_and_empty() { + let ring = OutputRing::new(8).unwrap(); + let started = Instant::now(); + let read = ring.read(0, 8, true, Duration::from_millis(10)); + assert!(read.timed_out); + assert!(read.data.is_empty()); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[test] + fn debug_never_exposes_ring_bytes() { + let canary = b"private-terminal-canary"; + let ring = OutputRing::new(64).unwrap(); + ring.append(canary); + let read = ring.read(0, 64, false, Duration::ZERO); + assert!(!format!("{ring:?}").contains("private-terminal-canary")); + assert!(!format!("{read:?}").contains("private-terminal-canary")); + } +} diff --git a/packages/d2b-unsafe-local-helper/src/protocol.rs b/packages/d2b-unsafe-local-helper/src/protocol.rs index 61614b389..0589853c3 100644 --- a/packages/d2b-unsafe-local-helper/src/protocol.rs +++ b/packages/d2b-unsafe-local-helper/src/protocol.rs @@ -6,19 +6,21 @@ use d2b_contracts::unsafe_local_wire::{ HelperHeartbeat, HelperHello, HelperOperationRejected, MAX_HELPER_FRAME_SIZE, MAX_HELPER_QUEUE_DEPTH, MIN_EFFECTIVE_HELPER_SOCKET_BUFFER_BYTES, UNSAFE_LOCAL_HELPER_PROTOCOL_VERSION, UnsafeLocalHelperToDaemon, + unsafe_local_helper_protocol_supported, }; use d2b_realm_core::ids::OperationId; use nix::cmsg_space; use nix::libc; use nix::poll::{PollFd, PollFlags, PollTimeout, poll}; use nix::sys::socket::{ - ControlMessageOwned, MsgFlags, UnixAddr, getsockopt, recvmsg, send, sockopt::PeerCredentials, + ControlMessage, ControlMessageOwned, MsgFlags, UnixAddr, getsockopt, recvmsg, send, sendmsg, + sockopt::PeerCredentials, }; use nix::unistd; use socket2::{Domain, SockAddr, Socket, Type}; use std::fmt; -use std::io::{IoSliceMut, Read, Write}; -use std::os::fd::{AsFd, AsRawFd, RawFd}; +use std::io::{IoSlice, IoSliceMut, Read, Write}; +use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -45,6 +47,17 @@ pub struct HelperClient { runtime: Arc>, } +struct QueuedResponse { + frame: UnsafeLocalHelperToDaemon, + fd: Option, +} + +impl QueuedResponse { + fn ordinary(frame: UnsafeLocalHelperToDaemon) -> Self { + Self { frame, fd: None } + } +} + impl fmt::Debug for HelperClient { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("HelperClient") @@ -103,7 +116,7 @@ impl HelperClient { let accepted: DaemonToUnsafeLocalHelper = receive_frame(&socket, &mut receive_buffer)?; match accepted { DaemonToUnsafeLocalHelper::HelloAccepted(accepted) - if accepted.protocol_version == UNSAFE_LOCAL_HELPER_PROTOCOL_VERSION + if unsafe_local_helper_protocol_supported(accepted.protocol_version) && accepted.generation == generation => {} DaemonToUnsafeLocalHelper::HelloAccepted(_) => { return Err(ProtocolError::GenerationMismatch); @@ -125,13 +138,13 @@ impl HelperClient { .set_nonblocking(true) .map_err(|_| ProtocolError::ConnectFailed)?; let (responses_tx, responses_rx) = - mpsc::sync_channel::(MAX_HELPER_QUEUE_DEPTH); + mpsc::sync_channel::(MAX_HELPER_QUEUE_DEPTH); let response_wakeup_write = Arc::new(response_wakeup_write); let active = Arc::new(AtomicUsize::new(0)); loop { while let Ok(response) = responses_rx.try_recv() { - send_frame(&socket, &response)?; + send_queued_response(&socket, response)?; } match wait_for_control_or_response(&socket, &response_wakeup_read)? { ControlEvent::Response => continue, @@ -174,10 +187,11 @@ impl HelperClient { let response = match runtime.launch(request) { Ok(result) => UnsafeLocalHelperToDaemon::Operation(result), Err(error) => { + eprintln!("unsafe-local launch failed: {error:?}"); rejection(request_id, operation_id, failure_code(error)) } }; - if responses.send(response).is_ok() { + if responses.send(QueuedResponse::ordinary(response)).is_ok() { let _ = wake_response_loop(&response_wakeup); } active.fetch_sub(1, Ordering::AcqRel); @@ -185,16 +199,42 @@ impl HelperClient { .map_err(|_| ProtocolError::RuntimeUnavailable)?; } DaemonToUnsafeLocalHelper::Shell(request) => { - if let Some((request_id, operation_id)) = shell_operation_identity(request) { + if active.fetch_add(1, Ordering::AcqRel) >= MAX_HELPER_QUEUE_DEPTH { + active.fetch_sub(1, Ordering::AcqRel); + let request_id = request.request_id(); + let operation_id = request.operation_id().clone(); send_frame( &socket, - &rejection( - request_id, - operation_id, - HelperFailureCode::ShellUnavailable, - ), + &rejection(request_id, operation_id, HelperFailureCode::QueueFull), )?; + continue; } + let runtime = Arc::clone(&self.runtime); + let responses = responses_tx.clone(); + let response_wakeup = Arc::clone(&response_wakeup_write); + let active = Arc::clone(&active); + std::thread::Builder::new() + .name("d2b-unsafe-local-shell-operation".to_owned()) + .spawn(move || { + let request_id = request.request_id(); + let operation_id = request.operation_id().clone(); + let queued = match runtime.shell(request) { + Ok(dispatch) => QueuedResponse { + frame: dispatch.response, + fd: dispatch.terminal_fd, + }, + Err(error) => QueuedResponse::ordinary(rejection( + request_id, + operation_id, + failure_code(error), + )), + }; + if responses.send(queued).is_ok() { + let _ = wake_response_loop(&response_wakeup); + } + active.fetch_sub(1, Ordering::AcqRel); + }) + .map_err(|_| ProtocolError::RuntimeUnavailable)?; } DaemonToUnsafeLocalHelper::HelloAccepted(_) => { return Err(ProtocolError::ProtocolMismatch); @@ -337,6 +377,48 @@ pub fn send_frame(socket: &Socket, frame: &T) -> Result<(), Ok(()) } +fn send_queued_response(socket: &Socket, response: QueuedResponse) -> Result<(), ProtocolError> { + match (&response.frame, response.fd) { + (UnsafeLocalHelperToDaemon::TerminalReady(_), Some(fd)) => { + send_frame_with_fd(socket, &response.frame, &fd) + } + (UnsafeLocalHelperToDaemon::TerminalReady(_), None) | (_, Some(_)) => { + Err(ProtocolError::InvalidFrame) + } + (_, None) => send_frame(socket, &response.frame), + } +} + +fn send_frame_with_fd( + socket: &Socket, + frame: &T, + fd: &OwnedFd, +) -> Result<(), ProtocolError> { + let payload = serde_json::to_vec(frame).map_err(|_| ProtocolError::InvalidFrame)?; + if payload.len() > MAX_HELPER_FRAME_SIZE { + return Err(ProtocolError::FrameTooLarge); + } + let length = u32::try_from(payload.len()).map_err(|_| ProtocolError::FrameTooLarge)?; + let mut encoded = Vec::with_capacity(payload.len() + 4); + encoded.extend_from_slice(&length.to_le_bytes()); + encoded.extend_from_slice(&payload); + let iov = [IoSlice::new(&encoded)]; + let raw = [fd.as_raw_fd()]; + let control = [ControlMessage::ScmRights(&raw)]; + let sent = sendmsg::( + socket.as_raw_fd(), + &iov, + &control, + MsgFlags::MSG_NOSIGNAL, + None, + ) + .map_err(|_| ProtocolError::ConnectFailed)?; + if sent != encoded.len() { + return Err(ProtocolError::InvalidFrame); + } + Ok(()) +} + pub fn receive_frame( socket: &Socket, encoded: &mut [u8], @@ -411,49 +493,40 @@ fn rejection( fn failure_code(error: RuntimeError) -> HelperFailureCode { match error { - RuntimeError::InvalidIdentity => HelperFailureCode::InvalidRequest, + RuntimeError::InvalidRequest | RuntimeError::InvalidIdentity => { + HelperFailureCode::InvalidRequest + } RuntimeError::UserManagerUnavailable => HelperFailureCode::UserManagerUnavailable, RuntimeError::EnvironmentInvalid | RuntimeError::LedgerInvalid => { HelperFailureCode::EnvironmentInvalid } RuntimeError::ExecutableUnavailable => HelperFailureCode::ExecutableUnavailable, RuntimeError::ProxyUnavailable => HelperFailureCode::ProxyUnavailable, + RuntimeError::WaylandUnavailable => HelperFailureCode::WaylandUnavailable, + RuntimeError::FirstClientTimeout => HelperFailureCode::FirstClientTimeout, RuntimeError::ScopeCreateFailed => HelperFailureCode::ScopeCreateFailed, RuntimeError::ScopeIdentityMismatch => HelperFailureCode::ScopeIdentityMismatch, + RuntimeError::OperationIdConflict => HelperFailureCode::OperationIdConflict, + RuntimeError::OperationInProgress | RuntimeError::QuotaExceeded => { + HelperFailureCode::QueueFull + } + RuntimeError::ShellUnavailable => HelperFailureCode::ShellUnavailable, + RuntimeError::ShellNotFound => HelperFailureCode::ShellNotFound, + RuntimeError::ShellAlreadyAttached => HelperFailureCode::ShellAlreadyAttached, + RuntimeError::TerminalClosed => HelperFailureCode::TerminalClosed, RuntimeError::Timeout => HelperFailureCode::Timeout, RuntimeError::Internal => HelperFailureCode::Internal, } } -fn shell_operation_identity( - request: d2b_contracts::unsafe_local_wire::HelperShellRequest, -) -> Option<(u64, OperationId)> { - use d2b_contracts::unsafe_local_wire::HelperShellRequest; - match request { - HelperShellRequest::List { .. } => None, - HelperShellRequest::Attach { - request_id, - operation_id, - .. - } - | HelperShellRequest::Detach { - request_id, - operation_id, - .. - } - | HelperShellRequest::Kill { - request_id, - operation_id, - .. - } => Some((request_id, operation_id)), - } -} - #[cfg(test)] mod tests { use super::*; - use nix::sys::socket::{AddressFamily, SockFlag, SockType, socketpair}; - use std::os::fd::OwnedFd; + use nix::fcntl::{FcntlArg, FdFlag, fcntl}; + use nix::sys::socket::{ + AddressFamily, ControlMessageOwned, SockFlag, SockType, recvmsg, socketpair, + }; + use std::os::fd::{AsRawFd, OwnedFd}; #[test] fn socket_buffers_meet_frozen_effective_minimum() { @@ -490,6 +563,30 @@ mod tests { Socket::from(fd) } + fn terminal_ready_frame() -> UnsafeLocalHelperToDaemon { + use d2b_contracts::public_wire::{ShellName, ShellSessionState}; + use d2b_contracts::unsafe_local_wire::{ + HelperScopeKind, HelperShellAttachResult, HelperTerminalReady, HelperTerminalTransport, + ScopeIdentity, UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION, + }; + + UnsafeLocalHelperToDaemon::TerminalReady(HelperTerminalReady { + request_id: 1, + operation_id: OperationId::parse("op-terminal").unwrap(), + terminal_protocol_version: UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION, + transport: HelperTerminalTransport::ConnectedUnixStream, + scope: ScopeIdentity { + invocation_id: "opaque".to_owned(), + kind: HelperScopeKind::PersistentShell, + }, + result: HelperShellAttachResult { + resolved_name: ShellName::new("host").unwrap(), + state: ShellSessionState::Attached, + force_evicted: false, + }, + }) + } + #[test] fn completed_operation_wakes_idle_control_loop() { let (control, _peer) = socketpair( @@ -511,4 +608,136 @@ mod tests { ControlEvent::Response ); } + + #[test] + fn terminal_ready_queue_sends_exactly_one_cloexec_fd() { + let (sender, receiver) = socketpair( + AddressFamily::Unix, + SockType::SeqPacket, + None, + SockFlag::SOCK_CLOEXEC, + ) + .unwrap(); + let sender = socket_from_owned(sender); + let (payload_read, _payload_write) = + rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).unwrap(); + send_queued_response( + &sender, + QueuedResponse { + frame: terminal_ready_frame(), + fd: Some(payload_read), + }, + ) + .unwrap(); + + let mut encoded = vec![0u8; MAX_HELPER_FRAME_SIZE + 5]; + let mut iov = [IoSliceMut::new(&mut encoded)]; + let mut control = cmsg_space!([RawFd; 2]); + let message = recvmsg::( + receiver.as_raw_fd(), + &mut iov, + Some(&mut control), + MsgFlags::MSG_CMSG_CLOEXEC, + ) + .unwrap(); + let mut received = Vec::new(); + for cmsg in message.cmsgs().unwrap() { + if let ControlMessageOwned::ScmRights(fds) = cmsg { + received.extend(fds); + } + } + assert_eq!(received.len(), 1); + let flags = FdFlag::from_bits_truncate(fcntl(received[0], FcntlArg::F_GETFD).unwrap()); + assert!(flags.contains(FdFlag::FD_CLOEXEC)); + unistd::close(received[0]).unwrap(); + } + + #[test] + fn ordinary_queue_response_sends_zero_fds() { + let (sender, receiver) = socketpair( + AddressFamily::Unix, + SockType::SeqPacket, + None, + SockFlag::SOCK_CLOEXEC, + ) + .unwrap(); + let sender = socket_from_owned(sender); + send_queued_response( + &sender, + QueuedResponse::ordinary(UnsafeLocalHelperToDaemon::Heartbeat(HelperHeartbeat { + generation: 1, + sequence: 2, + })), + ) + .unwrap(); + let mut encoded = vec![0u8; MAX_HELPER_FRAME_SIZE + 5]; + let mut iov = [IoSliceMut::new(&mut encoded)]; + let mut control = cmsg_space!([RawFd; 2]); + let message = recvmsg::( + receiver.as_raw_fd(), + &mut iov, + Some(&mut control), + MsgFlags::MSG_CMSG_CLOEXEC, + ) + .unwrap(); + let rights = message + .cmsgs() + .unwrap() + .filter_map(|cmsg| match cmsg { + ControlMessageOwned::ScmRights(fds) => Some(fds.len()), + _ => None, + }) + .sum::(); + assert_eq!(rights, 0); + } + + #[test] + fn queue_rejects_terminal_ready_without_its_one_fd() { + let (sender, _receiver) = socketpair( + AddressFamily::Unix, + SockType::SeqPacket, + None, + SockFlag::SOCK_CLOEXEC, + ) + .unwrap(); + let sender = socket_from_owned(sender); + assert_eq!( + send_queued_response(&sender, QueuedResponse::ordinary(terminal_ready_frame())), + Err(ProtocolError::InvalidFrame) + ); + } + + #[test] + fn failed_fd_send_drops_queue_ownership() { + let (sender, receiver) = socketpair( + AddressFamily::Unix, + SockType::SeqPacket, + None, + SockFlag::SOCK_CLOEXEC, + ) + .unwrap(); + let sender = socket_from_owned(sender); + drop(receiver); + let (payload_read, _payload_write) = + rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).unwrap(); + let raw = payload_read.as_raw_fd(); + let proc_path = std::path::PathBuf::from(format!("/proc/self/fd/{raw}")); + let original_identity = std::fs::read_link(&proc_path).unwrap(); + let result = send_queued_response( + &sender, + QueuedResponse { + frame: terminal_ready_frame(), + fd: Some(payload_read), + }, + ); + assert_eq!(result, Err(ProtocolError::ConnectFailed)); + match std::fs::read_link(&proc_path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(current_identity) => assert_ne!( + current_identity, original_identity, + "failed send retained ownership of the original fd" + ), + Err(error) => panic!("failed to inspect released fd: {error}"), + } + } } diff --git a/packages/d2b-unsafe-local-helper/src/runtime.rs b/packages/d2b-unsafe-local-helper/src/runtime.rs index 83b3ee8de..fcdb27523 100644 --- a/packages/d2b-unsafe-local-helper/src/runtime.rs +++ b/packages/d2b-unsafe-local-helper/src/runtime.rs @@ -1,18 +1,32 @@ use crate::environment::EnvironmentError; +use crate::shell_socket::validate_runtime_directory; use crate::systemd::{ScopeError, ScopeInspection, UserScopeManager, VerifiedScope}; +use d2b_contracts::public_wire::ShellName; use d2b_contracts::unsafe_local_wire::{ HelperLaunchRequest, HelperOperationDisposition, HelperOperationResult, HelperScopeKind, - HelperScopeSnapshot, HelperScopeState, HelperSnapshot, MAX_HELPER_SNAPSHOT_SCOPES, + HelperScopeSnapshot, HelperScopeState, HelperShellRequest, HelperShellResponse, HelperSnapshot, + HelperSupervisorId, MAX_COMPLETED_OPERATION_AGE_SECS, MAX_COMPLETED_OPERATIONS_PER_UID, + MAX_HELPER_SNAPSHOT_SCOPES, RealmAccentColor, }; use d2b_core::workload_identity::WorkloadIdentity; -use d2b_realm_core::ids::OperationId; +use d2b_realm_core::{WorkloadProviderKind, ids::OperationId}; +use d2b_wayland_proxy::readiness::{ + ProxyReadinessEvent, ProxyReadinessStage, ProxyReadinessState, READINESS_PROTOCOL_VERSION, +}; use nix::libc; +use nix::sys::socket::{getsockopt, sockopt::PeerCredentials}; +use nix::sys::wait::{WaitStatus, waitpid}; +use nix::unistd::Pid; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashSet}; +use std::ffi::OsString; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::{Read, Write}; +use std::os::unix::fs::DirBuilderExt; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::{Arc, Mutex, mpsc}; @@ -20,19 +34,33 @@ use std::time::{Duration, Instant}; use uzers::os::unix::UserExt; use uzers::{get_current_uid, get_user_by_uid}; -pub const SUPERVISOR_START_TIMEOUT: Duration = Duration::from_secs(5); +pub const SUPERVISOR_START_TIMEOUT: Duration = Duration::from_secs(25); pub const SNAPSHOT_RECONCILE_TIMEOUT: Duration = Duration::from_secs(20); const MAX_LEDGER_BYTES: u64 = 1024 * 1024; +const PROXY_READY_TIMEOUT: Duration = Duration::from_secs(5); +const FIRST_CLIENT_TIMEOUT: Duration = Duration::from_secs(10); +const READINESS_POLL_INTERVAL: Duration = Duration::from_millis(10); +const MAX_READINESS_EVENT_BYTES: usize = 4096; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RuntimeError { + InvalidRequest, InvalidIdentity, UserManagerUnavailable, EnvironmentInvalid, ExecutableUnavailable, ProxyUnavailable, + WaylandUnavailable, + FirstClientTimeout, ScopeCreateFailed, ScopeIdentityMismatch, + OperationIdConflict, + OperationInProgress, + QuotaExceeded, + ShellUnavailable, + ShellNotFound, + ShellAlreadyAttached, + TerminalClosed, Timeout, LedgerInvalid, Internal, @@ -45,6 +73,7 @@ impl From for RuntimeError { Self::ExecutableUnavailable } EnvironmentError::ProxyUnavailable => Self::ProxyUnavailable, + EnvironmentError::WaylandUnavailable => Self::WaylandUnavailable, _ => Self::EnvironmentInvalid, } } @@ -58,20 +87,26 @@ impl From for RuntimeError { ScopeError::Timeout => Self::Timeout, ScopeError::CreateFailed => Self::ScopeCreateFailed, ScopeError::IdentityMismatch => Self::ScopeIdentityMismatch, - ScopeError::QueryFailed | ScopeError::StopFailed => Self::Internal, + ScopeError::NotFound | ScopeError::QueryFailed | ScopeError::StopFailed => { + Self::Internal + } } } } #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PersistedScope { - operation_id: OperationId, - workload: WorkloadIdentity, - unit_name: String, - invocation_id: String, - control_group: String, - kind: HelperScopeKind, +pub(crate) struct PersistedScope { + pub(crate) operation_id: OperationId, + #[serde(default)] + pub(crate) fingerprint: Option<[u8; 32]>, + pub(crate) workload: WorkloadIdentity, + pub(crate) unit_name: String, + pub(crate) invocation_id: String, + pub(crate) control_group: String, + pub(crate) kind: HelperScopeKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) persistent_shell: Option, } impl fmt::Debug for PersistedScope { @@ -83,12 +118,29 @@ impl fmt::Debug for PersistedScope { .field("invocation_id", &"") .field("control_group", &"") .field("kind", &self.kind) + .field("has_persistent_shell", &self.persistent_shell.is_some()) + .finish() + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PersistedShellMetadata { + pub(crate) name: ShellName, + pub(crate) supervisor_id: HelperSupervisorId, +} + +impl fmt::Debug for PersistedShellMetadata { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PersistedShellMetadata") + .field("name", &"") + .field("supervisor_id", &"") .finish() } } impl PersistedScope { - fn verified(&self) -> VerifiedScope { + pub(crate) fn verified(&self) -> VerifiedScope { VerifiedScope { unit_name: self.unit_name.clone(), invocation_id: self.invocation_id.clone(), @@ -100,9 +152,9 @@ impl PersistedScope { #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PersistedScopeLedger { - schema_version: u32, - scopes: Vec, +pub(crate) struct PersistedScopeLedger { + pub(crate) schema_version: u32, + pub(crate) scopes: Vec, } impl fmt::Debug for PersistedScopeLedger { @@ -115,10 +167,244 @@ impl fmt::Debug for PersistedScopeLedger { } pub struct ScopeRuntime { - manager: Arc, - ledger_path: PathBuf, - ledger: Mutex, - user_home: PathBuf, + pub(crate) manager: Arc, + pub(crate) ledger_path: PathBuf, + pub(crate) ledger: Mutex, + pub(crate) user_home: PathBuf, + pub(crate) shell_home: PathBuf, + pub(crate) uid: u32, + pub(crate) executable: PathBuf, + pub(crate) wayland_proxy_binary: Option, +} + +pub(crate) struct RuntimeLedger { + pub(crate) persisted: PersistedScopeLedger, + pub(crate) reservations: BTreeMap, + pub(crate) shell_name_reservations: BTreeMap, + pub(crate) completed_shell_operations: BTreeMap, + next_owner: u64, +} + +impl Default for RuntimeLedger { + fn default() -> Self { + Self::from_persisted(PersistedScopeLedger { + schema_version: 1, + scopes: Vec::new(), + }) + } +} + +#[derive(Clone, Copy)] +pub(crate) struct LaunchReservation { + pub(crate) fingerprint: [u8; 32], + pub(crate) owner: u64, +} + +enum LaunchBegin { + Started(LaunchReservation), + AlreadyCommitted(Box), +} + +#[derive(Clone)] +pub(crate) struct CompletedShellOperation { + pub(crate) fingerprint: [u8; 32], + pub(crate) completed_at: Instant, + pub(crate) response: Option, +} + +pub(crate) enum ShellOperationBegin { + Started(LaunchReservation), + ExistingScope(Box), + Replayed(Option), +} + +impl RuntimeLedger { + pub(crate) fn from_persisted(persisted: PersistedScopeLedger) -> Self { + Self { + persisted, + reservations: BTreeMap::new(), + shell_name_reservations: BTreeMap::new(), + completed_shell_operations: BTreeMap::new(), + next_owner: 0, + } + } + + fn begin( + &mut self, + operation_id: &OperationId, + fingerprint: [u8; 32], + ) -> Result { + let operation_key = operation_id.to_string(); + if let Some(scope) = self + .persisted + .scopes + .iter() + .find(|scope| scope.operation_id == *operation_id) + { + return if scope.fingerprint == Some(fingerprint) { + Ok(LaunchBegin::AlreadyCommitted(Box::new(scope.clone()))) + } else { + Err(RuntimeError::OperationIdConflict) + }; + } + if let Some(reservation) = self.reservations.get(&operation_key) { + return if reservation.fingerprint == fingerprint { + Err(RuntimeError::OperationInProgress) + } else { + Err(RuntimeError::OperationIdConflict) + }; + } + if self + .persisted + .scopes + .len() + .saturating_add(self.reservations.len()) + >= MAX_HELPER_SNAPSHOT_SCOPES + { + return Err(RuntimeError::LedgerInvalid); + } + self.next_owner = self.next_owner.wrapping_add(1); + if self.next_owner == 0 { + self.next_owner = 1; + } + let reservation = LaunchReservation { + fingerprint, + owner: self.next_owner, + }; + self.reservations.insert(operation_key, reservation); + Ok(LaunchBegin::Started(reservation)) + } + + fn owns(&self, operation_id: &OperationId, reservation: LaunchReservation) -> bool { + self.reservations + .get(operation_id.as_str()) + .is_some_and(|active| active.owner == reservation.owner) + } + + fn clear(&mut self, operation_id: &OperationId, reservation: LaunchReservation) { + if self.owns(operation_id, reservation) { + self.reservations.remove(operation_id.as_str()); + } + } + + pub(crate) fn begin_shell_operation( + &mut self, + operation_id: &OperationId, + fingerprint: [u8; 32], + ) -> Result { + self.expire_completed_shell_operations(); + if let Some(scope) = self + .persisted + .scopes + .iter() + .find(|scope| scope.operation_id == *operation_id) + { + return if scope.fingerprint == Some(fingerprint) { + Ok(ShellOperationBegin::ExistingScope(Box::new(scope.clone()))) + } else { + Err(RuntimeError::OperationIdConflict) + }; + } + if let Some(completed) = self.completed_shell_operations.get(operation_id.as_str()) { + return if completed.fingerprint == fingerprint { + Ok(ShellOperationBegin::Replayed(completed.response.clone())) + } else { + Err(RuntimeError::OperationIdConflict) + }; + } + if let Some(reservation) = self.reservations.get(operation_id.as_str()) { + return if reservation.fingerprint == fingerprint { + Err(RuntimeError::OperationInProgress) + } else { + Err(RuntimeError::OperationIdConflict) + }; + } + self.next_owner = self.next_owner.wrapping_add(1); + if self.next_owner == 0 { + self.next_owner = 1; + } + let reservation = LaunchReservation { + fingerprint, + owner: self.next_owner, + }; + self.reservations + .insert(operation_id.to_string(), reservation); + Ok(ShellOperationBegin::Started(reservation)) + } + + pub(crate) fn reserve_shell_name( + &mut self, + key: String, + reservation: LaunchReservation, + ) -> Result<(), RuntimeError> { + if self.shell_name_reservations.contains_key(&key) { + return Err(RuntimeError::OperationInProgress); + } + self.shell_name_reservations.insert(key, reservation.owner); + Ok(()) + } + + pub(crate) fn clear_shell_operation( + &mut self, + operation_id: &OperationId, + reservation: LaunchReservation, + name_key: Option<&str>, + ) { + self.clear(operation_id, reservation); + if let Some(name_key) = name_key + && self.shell_name_reservations.get(name_key) == Some(&reservation.owner) + { + self.shell_name_reservations.remove(name_key); + } + } + + pub(crate) fn complete_shell_operation( + &mut self, + operation_id: &OperationId, + reservation: LaunchReservation, + name_key: Option<&str>, + response: Option, + ) -> Result<(), RuntimeError> { + if !self.owns(operation_id, reservation) { + return Err(RuntimeError::OperationIdConflict); + } + self.clear_shell_operation(operation_id, reservation, name_key); + self.remember_completed_shell_operation(operation_id, reservation.fingerprint, response); + Ok(()) + } + + pub(crate) fn remember_completed_shell_operation( + &mut self, + operation_id: &OperationId, + fingerprint: [u8; 32], + response: Option, + ) { + while self.completed_shell_operations.len() >= MAX_COMPLETED_OPERATIONS_PER_UID { + let Some(oldest) = self + .completed_shell_operations + .iter() + .min_by_key(|(_, operation)| operation.completed_at) + .map(|(key, _)| key.clone()) + else { + break; + }; + self.completed_shell_operations.remove(&oldest); + } + self.completed_shell_operations.insert( + operation_id.to_string(), + CompletedShellOperation { + fingerprint, + completed_at: Instant::now(), + response, + }, + ); + } + + fn expire_completed_shell_operations(&mut self) { + let maximum_age = Duration::from_secs(MAX_COMPLETED_OPERATION_AGE_SECS); + self.completed_shell_operations + .retain(|_, operation| operation.completed_at.elapsed() <= maximum_age); + } } impl fmt::Debug for ScopeRuntime { @@ -126,12 +412,19 @@ impl fmt::Debug for ScopeRuntime { f.debug_struct("ScopeRuntime") .field("ledger_path", &"") .field("user_home", &"") + .field("shell_home", &"") + .field("uid", &"") + .field("executable", &"") + .field( + "wayland_proxy_configured", + &self.wayland_proxy_binary.is_some(), + ) .finish_non_exhaustive() } } impl ScopeRuntime { - pub fn new(manager: M) -> Result { + pub fn new(manager: M, wayland_proxy_binary: PathBuf) -> Result { let uid = get_current_uid(); if uid == 0 { return Err(RuntimeError::InvalidIdentity); @@ -142,7 +435,15 @@ impl ScopeRuntime { return Err(RuntimeError::InvalidIdentity); } let ledger_path = user_home.join(".local/state/d2b/unsafe-local-scopes.json"); - Self::with_paths(manager, user_home, ledger_path) + let executable = std::env::current_exe().map_err(|_| RuntimeError::Internal)?; + validate_immutable_proxy_binary(&wayland_proxy_binary)?; + Self::with_paths_executable_and_proxy( + manager, + user_home, + ledger_path, + executable, + Some(wayland_proxy_binary), + ) } pub fn with_paths( @@ -150,28 +451,145 @@ impl ScopeRuntime { user_home: PathBuf, ledger_path: PathBuf, ) -> Result { - let ledger = load_ledger(&ledger_path)?; + let executable = std::env::current_exe().map_err(|_| RuntimeError::Internal)?; + Self::with_paths_and_executable(manager, user_home, ledger_path, executable) + } + + pub fn with_paths_and_executable( + manager: M, + user_home: PathBuf, + ledger_path: PathBuf, + executable: PathBuf, + ) -> Result { + Self::with_paths_executable_and_proxy(manager, user_home, ledger_path, executable, None) + } + + pub(crate) fn with_paths_executable_and_proxy( + manager: M, + user_home: PathBuf, + ledger_path: PathBuf, + executable: PathBuf, + wayland_proxy_binary: Option, + ) -> Result { + let uid = get_current_uid(); + if uid == 0 { + return Err(RuntimeError::InvalidIdentity); + } + let user = get_user_by_uid(uid).ok_or(RuntimeError::InvalidIdentity)?; + let shell_home = user.home_dir().to_path_buf(); + if !shell_home.is_absolute() || !executable.is_absolute() { + return Err(RuntimeError::InvalidIdentity); + } + let ledger = RuntimeLedger::from_persisted(load_ledger(&ledger_path)?); Ok(Self { manager: Arc::new(manager), ledger_path, ledger: Mutex::new(ledger), user_home, + shell_home, + uid, + executable, + wayland_proxy_binary, }) } + pub fn shell( + &self, + request: HelperShellRequest, + ) -> Result { + crate::shell_runtime::dispatch(self, request) + } + pub fn launch( &self, request: HelperLaunchRequest, ) -> Result { - let environment = self.manager.manager_environment()?; + let fingerprint = launch_fingerprint(&request)?; + let reservation = match self + .ledger + .lock() + .map_err(|_| RuntimeError::Internal)? + .begin(&request.operation_id, fingerprint)? + { + LaunchBegin::Started(reservation) => reservation, + LaunchBegin::AlreadyCommitted(scope) => { + return Ok(HelperOperationResult { + request_id: request.request_id, + operation_id: request.operation_id, + disposition: HelperOperationDisposition::AlreadyCommitted, + scope: Some(scope.verified().wire_identity()), + }); + } + }; + let operation_id = request.operation_id.clone(); + let result = self.launch_reserved(request, fingerprint, reservation); + if result.is_err() + && let Ok(mut ledger) = self.ledger.lock() + { + ledger.clear(&operation_id, reservation); + } + result + } + + fn launch_reserved( + &self, + request: HelperLaunchRequest, + fingerprint: [u8; 32], + reservation: LaunchReservation, + ) -> Result { + let environment = self.manager.manager_environment().map_err(|error| { + eprintln!("unsafe-local launch setup failed: manager-environment ({error:?})"); + RuntimeError::from(error) + })?; let argv = request.argv.as_slice(); - let program = environment.resolve_program(&argv[0])?; - let child_environment = environment.child_entries(request.graphical, None)?; + let program = environment.resolve_program(&argv[0]).map_err(|error| { + eprintln!("unsafe-local launch setup failed: executable"); + RuntimeError::from(error) + })?; + let graphical = if request.graphical { + let runtime_directory = environment.runtime_directory().map_err(|error| { + eprintln!("unsafe-local launch setup failed: runtime-directory"); + RuntimeError::from(error) + })?; + validate_runtime_directory(&runtime_directory, self.uid).map_err(|_| { + eprintln!("unsafe-local launch setup failed: runtime-directory-validation"); + RuntimeError::EnvironmentInvalid + })?; + let wayland_proxy_binary = self.wayland_proxy_binary.clone().ok_or_else(|| { + eprintln!("unsafe-local launch setup failed: proxy-binary"); + RuntimeError::ProxyUnavailable + })?; + Some( + GraphicalSupervisorSpec::new( + wayland_proxy_binary, + runtime_directory, + environment.wayland_display()?.to_owned(), + request.workload.target().clone(), + request.realm_accent_color.clone(), + self.uid, + ) + .inspect_err(|error| { + eprintln!("unsafe-local launch setup failed: graphical-spec ({error:?})"); + })?, + ) + } else { + None + }; + let child_environment = environment + .child_entries( + request.graphical, + graphical.as_ref().map(|g| g.display.as_str()), + ) + .map_err(|error| { + eprintln!("unsafe-local launch setup failed: child-environment"); + RuntimeError::from(error) + })?; let spec = SupervisorSpec { program, args: argv[1..].to_vec(), environment: child_environment, cwd: self.user_home.clone(), + graphical, }; let mut supervisor = BlockedSupervisor::spawn(&spec)?; let supervisor_pid = supervisor.id(); @@ -187,36 +605,22 @@ impl ScopeRuntime { }; let persisted = PersistedScope { operation_id: request.operation_id.clone(), + fingerprint: Some(fingerprint), workload: request.workload, unit_name: scope.unit_name.clone(), invocation_id: scope.invocation_id.clone(), control_group: scope.control_group.clone(), kind: scope.kind, + persistent_shell: None, }; - let persist_result = match self.ledger.lock() { - Ok(mut ledger) => { - ledger - .scopes - .retain(|entry| entry.operation_id != persisted.operation_id); - ledger.scopes.push(persisted); - if ledger.scopes.len() > MAX_HELPER_SNAPSHOT_SCOPES { - Err(RuntimeError::LedgerInvalid) - } else { - persist_ledger(&self.ledger_path, &ledger) - } - } - Err(_) => Err(RuntimeError::Internal), - }; - if let Err(error) = persist_result { + if let Err(error) = supervisor.release_and_wait_started() { supervisor.abort(); self.stop_failed_scope(&scope); - self.remove_scope_record(&request.operation_id); return Err(error); } - if let Err(error) = supervisor.release_and_wait_started() { + if let Err(error) = self.commit_scope(&persisted, reservation) { supervisor.abort(); self.stop_failed_scope(&scope); - self.remove_scope_record(&request.operation_id); return Err(error); } supervisor.reap_in_background(); @@ -229,19 +633,29 @@ impl ScopeRuntime { }) } - fn stop_failed_scope(&self, scope: &VerifiedScope) { - let _ = self.manager.terminate_scope(scope, libc::SIGKILL); - let _ = self.manager.stop_scope(scope); + fn commit_scope( + &self, + persisted: &PersistedScope, + reservation: LaunchReservation, + ) -> Result<(), RuntimeError> { + let mut ledger = self.ledger.lock().map_err(|_| RuntimeError::Internal)?; + if !ledger.owns(&persisted.operation_id, reservation) { + return Err(RuntimeError::OperationIdConflict); + } + let mut candidate = ledger.persisted.clone(); + candidate.scopes.push(persisted.clone()); + if candidate.scopes.len() > MAX_HELPER_SNAPSHOT_SCOPES { + return Err(RuntimeError::LedgerInvalid); + } + persist_ledger(&self.ledger_path, &candidate)?; + ledger.persisted = candidate; + ledger.clear(&persisted.operation_id, reservation); + Ok(()) } - fn remove_scope_record(&self, operation_id: &OperationId) { - let Ok(mut ledger) = self.ledger.lock() else { - return; - }; - ledger - .scopes - .retain(|entry| &entry.operation_id != operation_id); - let _ = persist_ledger(&self.ledger_path, &ledger); + pub(crate) fn stop_failed_scope(&self, scope: &VerifiedScope) { + let _ = self.manager.terminate_scope(scope, libc::SIGKILL); + let _ = self.manager.stop_scope(scope); } pub fn snapshot(&self, generation: u64) -> Result { @@ -249,6 +663,7 @@ impl ScopeRuntime { .ledger .lock() .map_err(|_| RuntimeError::Internal)? + .persisted .scopes .clone(); if entries.len() > MAX_HELPER_SNAPSHOT_SCOPES { @@ -258,7 +673,7 @@ impl ScopeRuntime { let mut scopes = Vec::with_capacity(entries.len()); let deadline = Instant::now() + SNAPSHOT_RECONCILE_TIMEOUT; for entry in entries { - let state = if Instant::now() >= deadline { + let manager_state = if Instant::now() >= deadline { HelperScopeState::Degraded } else { let verified = entry.verified(); @@ -270,18 +685,68 @@ impl ScopeRuntime { _ => HelperScopeState::Degraded, } }; + let (state, persistent_shell) = if entry.persistent_shell.is_some() { + crate::shell_runtime::snapshot_shell(self, &entry, manager_state) + } else { + (manager_state, None) + }; let scope = entry.verified().wire_identity(); scopes.push(HelperScopeSnapshot { operation_id: entry.operation_id, workload: entry.workload, scope, state, + persistent_shell, }); } Ok(HelperSnapshot { generation, scopes }) } } +fn launch_fingerprint(request: &HelperLaunchRequest) -> Result<[u8; 32], RuntimeError> { + let encoded = serde_json::to_vec(&( + &request.workload, + &request.item_id, + &request.argv, + request.graphical, + &request.realm_accent_color, + )) + .map_err(|_| RuntimeError::Internal)?; + Ok(Sha256::digest(encoded).into()) +} + +fn validate_immutable_proxy_binary(path: &Path) -> Result<(), RuntimeError> { + if !path.is_absolute() + || !path.starts_with("/nix/store") + || path.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::CurDir + ) + }) + { + return Err(RuntimeError::ProxyUnavailable); + } + let metadata = fs::symlink_metadata(path).map_err(|_| RuntimeError::ProxyUnavailable)?; + if !metadata.file_type().is_file() + || metadata.file_type().is_symlink() + || metadata.permissions().mode() & 0o111 == 0 + { + return Err(RuntimeError::ProxyUnavailable); + } + Ok(()) +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(DIGITS[(byte >> 4) as usize] as char); + output.push(DIGITS[(byte & 0x0f) as usize] as char); + } + output +} + #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SupervisorSpec { @@ -289,6 +754,86 @@ pub struct SupervisorSpec { args: Vec, environment: BTreeMap, cwd: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + graphical: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct GraphicalSupervisorSpec { + proxy_binary: PathBuf, + runtime_directory: PathBuf, + display: String, + upstream_display: String, + target: d2b_core::workload_identity::WorkloadTarget, + realm_accent_color: RealmAccentColor, + uid: u32, + first_client_timeout_ms: u64, +} + +impl GraphicalSupervisorSpec { + fn new( + proxy_binary: PathBuf, + runtime_directory: PathBuf, + upstream_display: String, + target: d2b_core::workload_identity::WorkloadTarget, + realm_accent_color: RealmAccentColor, + uid: u32, + ) -> Result { + let mut random = [0u8; 16]; + getrandom::getrandom(&mut random).map_err(|_| RuntimeError::Internal)?; + let display = format!("d2b-unsafe-local-{}/wayland.sock", hex(&random)); + let spec = Self { + proxy_binary, + runtime_directory, + display, + upstream_display, + target, + realm_accent_color, + uid, + first_client_timeout_ms: FIRST_CLIENT_TIMEOUT.as_millis() as u64, + }; + spec.validate()?; + Ok(spec) + } + + fn validate(&self) -> Result<(), RuntimeError> { + if !self.proxy_binary.is_absolute() + || !self.runtime_directory.is_absolute() + || self.upstream_display.is_empty() + || self.upstream_display.contains('\0') + || self + .upstream_display + .split('/') + .any(|component| component == "..") + || self.uid == 0 + || self.first_client_timeout_ms == 0 + || self.first_client_timeout_ms > FIRST_CLIENT_TIMEOUT.as_millis() as u64 + { + return Err(RuntimeError::EnvironmentInvalid); + } + crate::environment::valid_proxy_display(&self.display) + .then_some(()) + .ok_or(RuntimeError::ProxyUnavailable) + } + + fn private_directory(&self) -> Result { + self.validate()?; + let directory = self + .display + .split_once('/') + .map(|(directory, _)| directory) + .ok_or(RuntimeError::EnvironmentInvalid)?; + Ok(self.runtime_directory.join(directory)) + } + + fn wayland_socket(&self) -> Result { + Ok(self.runtime_directory.join(&self.display)) + } + + fn readiness_socket(&self) -> Result { + Ok(self.private_directory()?.join("readiness.sock")) + } } impl fmt::Debug for SupervisorSpec { @@ -298,6 +843,7 @@ impl fmt::Debug for SupervisorSpec { .field("arg_count", &self.args.len()) .field("environment_count", &self.environment.len()) .field("cwd", &"") + .field("graphical", &self.graphical.is_some()) .finish() } } @@ -320,7 +866,7 @@ impl BlockedSupervisor { .env_clear() .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) + .stderr(Stdio::inherit()) .spawn() .map_err(|_| RuntimeError::ScopeCreateFailed)?; let mut stdin = child.stdin.take().ok_or(RuntimeError::Internal)?; @@ -419,7 +965,21 @@ pub fn run_scope_supervisor() -> Result<(), RuntimeError> { return Err(RuntimeError::Internal); } - let mut child = Command::new(&spec.program) + run_supervisor_spec(spec, &mut std::io::stdout()) +} + +fn run_supervisor_spec( + spec: SupervisorSpec, + started_ack: &mut impl Write, +) -> Result<(), RuntimeError> { + match spec.graphical.clone() { + Some(graphical) => run_graphical_supervisor(&spec, &graphical, started_ack), + None => run_plain_supervisor(&spec, started_ack), + } +} + +fn spawn_app(spec: &SupervisorSpec) -> Result { + Command::new(&spec.program) .args(&spec.args) .env_clear() .envs(&spec.environment) @@ -428,17 +988,382 @@ pub fn run_scope_supervisor() -> Result<(), RuntimeError> { .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .map_err(|_| RuntimeError::ExecutableUnavailable)?; - std::io::stdout() + .map_err(|_| RuntimeError::ExecutableUnavailable) +} + +fn run_plain_supervisor( + spec: &SupervisorSpec, + started_ack: &mut impl Write, +) -> Result<(), RuntimeError> { + let mut child = spawn_app(spec)?; + if started_ack .write_all(&[1]) - .map_err(|_| RuntimeError::Internal)?; - std::io::stdout() - .flush() - .map_err(|_| RuntimeError::Internal)?; + .and_then(|()| started_ack.flush()) + .is_err() + { + terminate_and_reap(&mut child); + return Err(RuntimeError::Internal); + } child.wait().map_err(|_| RuntimeError::Internal)?; Ok(()) } +fn run_graphical_supervisor( + spec: &SupervisorSpec, + graphical: &GraphicalSupervisorSpec, + started_ack: &mut impl Write, +) -> Result<(), RuntimeError> { + graphical.validate()?; + validate_runtime_directory(&graphical.runtime_directory, graphical.uid) + .map_err(|_| RuntimeError::EnvironmentInvalid)?; + let runtime = PrivateGraphicalRuntime::prepare(graphical)?; + let mut proxy = spawn_proxy(graphical)?; + let mut app = None; + let startup = (|| { + let mut readiness = ReadinessChannel::new(runtime.readiness_listener(), graphical.uid)?; + readiness.expect( + ProxyReadinessStage::Upstream, + graphical, + Instant::now() + PROXY_READY_TIMEOUT, + &mut proxy, + None, + )?; + readiness.expect( + ProxyReadinessStage::Listener, + graphical, + Instant::now() + PROXY_READY_TIMEOUT, + &mut proxy, + None, + )?; + app = Some(spawn_app(spec)?); + readiness.expect( + ProxyReadinessStage::FirstClient, + graphical, + Instant::now() + Duration::from_millis(graphical.first_client_timeout_ms), + &mut proxy, + app.as_mut(), + )?; + started_ack + .write_all(&[1]) + .and_then(|()| started_ack.flush()) + .map_err(|_| RuntimeError::Internal) + })(); + if let Err(error) = startup { + if let Some(child) = app.as_mut() { + terminate_and_reap(child); + } + terminate_and_reap(&mut proxy); + return Err(error); + } + + wait_for_graphical_exit(app.as_mut().ok_or(RuntimeError::Internal)?, &mut proxy) +} + +fn spawn_proxy(graphical: &GraphicalSupervisorSpec) -> Result { + Command::new(&graphical.proxy_binary) + .args(proxy_arguments(graphical)?) + .env_clear() + .env("XDG_RUNTIME_DIR", &graphical.runtime_directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| RuntimeError::ProxyUnavailable) +} + +fn proxy_arguments(graphical: &GraphicalSupervisorSpec) -> Result, RuntimeError> { + Ok(vec![ + "--listen".into(), + graphical.wayland_socket()?.into_os_string(), + "--connect".into(), + graphical.upstream_display.clone().into(), + "--target".into(), + graphical.target.to_canonical().into(), + "--provider-kind".into(), + "unsafe-local".into(), + "--border-enable".into(), + "--border-color-active".into(), + graphical.realm_accent_color.as_str().into(), + "--readiness-socket".into(), + graphical.readiness_socket()?.into_os_string(), + "--first-client-timeout-ms".into(), + graphical.first_client_timeout_ms.to_string().into(), + "--clipd-bridge-user-uid".into(), + graphical.uid.to_string().into(), + ]) +} + +fn terminate_and_reap(child: &mut Child) { + if child.try_wait().ok().flatten().is_none() { + let _ = child.kill(); + } + let _ = child.wait(); +} + +fn wait_for_graphical_exit(app: &mut Child, proxy: &mut Child) -> Result<(), RuntimeError> { + let app_pid = Pid::from_raw(i32::try_from(app.id()).map_err(|_| RuntimeError::Internal)?); + let proxy_pid = Pid::from_raw(i32::try_from(proxy.id()).map_err(|_| RuntimeError::Internal)?); + loop { + match waitpid(None, None) { + Ok(WaitStatus::Exited(pid, _) | WaitStatus::Signaled(pid, _, _)) if pid == app_pid => { + terminate_and_reap(proxy); + return Ok(()); + } + Ok(WaitStatus::Exited(pid, _) | WaitStatus::Signaled(pid, _, _)) + if pid == proxy_pid => + { + terminate_and_reap(app); + return Ok(()); + } + Ok( + WaitStatus::StillAlive + | WaitStatus::Continued(_) + | WaitStatus::Stopped(_, _) + | WaitStatus::PtraceEvent(_, _, _) + | WaitStatus::PtraceSyscall(_), + ) => {} + Ok(_) => return Err(RuntimeError::Internal), + Err(nix::errno::Errno::EINTR) => {} + Err(_) => return Err(RuntimeError::Internal), + } + } +} + +struct PrivateGraphicalRuntime { + _directory: PrivateGraphicalDirectory, + readiness_listener: UnixListener, +} + +impl PrivateGraphicalRuntime { + fn prepare(spec: &GraphicalSupervisorSpec) -> Result { + let directory = PrivateGraphicalDirectory::prepare(spec)?; + let readiness_path = spec.readiness_socket()?; + let readiness_listener = + UnixListener::bind(&readiness_path).map_err(|_| RuntimeError::ProxyUnavailable)?; + fs::set_permissions(&readiness_path, fs::Permissions::from_mode(0o600)) + .map_err(|_| RuntimeError::ProxyUnavailable)?; + let metadata = + fs::symlink_metadata(&readiness_path).map_err(|_| RuntimeError::ProxyUnavailable)?; + use std::os::unix::fs::{FileTypeExt, MetadataExt}; + if !metadata.file_type().is_socket() + || metadata.uid() != spec.uid + || metadata.permissions().mode() & 0o7777 != 0o600 + { + return Err(RuntimeError::ProxyUnavailable); + } + let flags = rustix::io::fcntl_getfd(&readiness_listener) + .map_err(|_| RuntimeError::ProxyUnavailable)?; + rustix::io::fcntl_setfd(&readiness_listener, flags | rustix::io::FdFlags::CLOEXEC) + .map_err(|_| RuntimeError::ProxyUnavailable)?; + readiness_listener + .set_nonblocking(true) + .map_err(|_| RuntimeError::ProxyUnavailable)?; + Ok(Self { + _directory: directory, + readiness_listener, + }) + } + + fn readiness_listener(&self) -> &UnixListener { + &self.readiness_listener + } +} + +struct PrivateGraphicalDirectory { + path: PathBuf, +} + +impl PrivateGraphicalDirectory { + fn prepare(spec: &GraphicalSupervisorSpec) -> Result { + let directory = spec.private_directory()?; + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + builder + .create(&directory) + .map_err(|_| RuntimeError::ProxyUnavailable)?; + let result = (|| { + let metadata = + fs::symlink_metadata(&directory).map_err(|_| RuntimeError::ProxyUnavailable)?; + use std::os::unix::fs::MetadataExt; + if !metadata.file_type().is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != spec.uid + || metadata.permissions().mode() & 0o7777 != 0o700 + { + return Err(RuntimeError::ProxyUnavailable); + } + Ok(()) + })(); + match result { + Ok(()) => Ok(Self { path: directory }), + Err(error) => { + let _ = fs::remove_dir_all(directory); + Err(error) + } + } + } +} + +impl Drop for PrivateGraphicalDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +struct ReadinessChannel<'a> { + listener: &'a UnixListener, + stream: Option, + buffered: Vec, + expected_uid: u32, +} + +impl<'a> ReadinessChannel<'a> { + fn new(listener: &'a UnixListener, expected_uid: u32) -> Result { + Ok(Self { + listener, + stream: None, + buffered: Vec::new(), + expected_uid, + }) + } + + fn expect( + &mut self, + expected_stage: ProxyReadinessStage, + spec: &GraphicalSupervisorSpec, + deadline: Instant, + proxy: &mut Child, + mut app: Option<&mut Child>, + ) -> Result<(), RuntimeError> { + loop { + if let Some(app) = app.as_deref_mut() + && app + .try_wait() + .map_err(|_| RuntimeError::Internal)? + .is_some() + { + return Err(RuntimeError::FirstClientTimeout); + } + if proxy + .try_wait() + .map_err(|_| RuntimeError::Internal)? + .is_some() + { + return Err(stage_failure(expected_stage)); + } + if Instant::now() >= deadline { + return Err(stage_failure(expected_stage)); + } + if self.stream.is_none() { + match self.listener.accept() { + Ok((stream, _)) => { + let peer = getsockopt(&stream, PeerCredentials) + .map_err(|_| RuntimeError::ProxyUnavailable)?; + if peer.uid() != self.expected_uid { + return Err(RuntimeError::ProxyUnavailable); + } + let flags = rustix::io::fcntl_getfd(&stream) + .map_err(|_| RuntimeError::ProxyUnavailable)?; + rustix::io::fcntl_setfd(&stream, flags | rustix::io::FdFlags::CLOEXEC) + .map_err(|_| RuntimeError::ProxyUnavailable)?; + stream + .set_nonblocking(true) + .map_err(|_| RuntimeError::ProxyUnavailable)?; + self.stream = Some(stream); + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock + ) => {} + Err(_) => return Err(RuntimeError::ProxyUnavailable), + } + } + if let Some(event) = self.read_event()? { + validate_readiness_event(&event, expected_stage, spec)?; + return Ok(()); + } + std::thread::sleep( + READINESS_POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now())), + ); + } + } + + fn read_event(&mut self) -> Result, RuntimeError> { + if let Some(event) = decode_buffered_event(&mut self.buffered)? { + return Ok(Some(event)); + } + let Some(stream) = self.stream.as_mut() else { + return Ok(None); + }; + let mut chunk = [0u8; 1024]; + loop { + match stream.read(&mut chunk) { + Ok(0) => return Err(RuntimeError::ProxyUnavailable), + Ok(read) => { + self.buffered.extend_from_slice(&chunk[..read]); + if self.buffered.len() > MAX_READINESS_EVENT_BYTES { + return Err(RuntimeError::ProxyUnavailable); + } + if let Some(event) = decode_buffered_event(&mut self.buffered)? { + return Ok(Some(event)); + } + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::WouldBlock + ) => + { + return Ok(None); + } + Err(_) => return Err(RuntimeError::ProxyUnavailable), + } + } + } +} + +fn decode_buffered_event( + buffered: &mut Vec, +) -> Result, RuntimeError> { + let Some(newline) = buffered.iter().position(|byte| *byte == b'\n') else { + return Ok(None); + }; + let frame = buffered.drain(..=newline).collect::>(); + let body = &frame[..frame.len() - 1]; + if body.is_empty() { + return Err(RuntimeError::ProxyUnavailable); + } + serde_json::from_slice(body) + .map(Some) + .map_err(|_| RuntimeError::ProxyUnavailable) +} + +fn validate_readiness_event( + event: &ProxyReadinessEvent, + expected_stage: ProxyReadinessStage, + spec: &GraphicalSupervisorSpec, +) -> Result<(), RuntimeError> { + if event.protocol_version != READINESS_PROTOCOL_VERSION + || event.target != spec.target + || event.provider_kind != WorkloadProviderKind::UnsafeLocal + || event.stage != expected_stage + || event.state != ProxyReadinessState::Ready + || event.failure.is_some() + { + return Err(stage_failure(expected_stage)); + } + Ok(()) +} + +fn stage_failure(stage: ProxyReadinessStage) -> RuntimeError { + match stage { + ProxyReadinessStage::Upstream => RuntimeError::WaylandUnavailable, + ProxyReadinessStage::Listener => RuntimeError::ProxyUnavailable, + ProxyReadinessStage::FirstClient => RuntimeError::FirstClientTimeout, + } +} + fn load_ledger(path: &Path) -> Result { match fs::metadata(path) { Ok(metadata) if metadata.len() > MAX_LEDGER_BYTES => { @@ -456,13 +1381,58 @@ fn load_ledger(path: &Path) -> Result { let encoded = fs::read(path).map_err(|_| RuntimeError::LedgerInvalid)?; let ledger: PersistedScopeLedger = serde_json::from_slice(&encoded).map_err(|_| RuntimeError::LedgerInvalid)?; - if ledger.schema_version != 1 || ledger.scopes.len() > MAX_HELPER_SNAPSHOT_SCOPES { + let unique_operations = ledger + .scopes + .iter() + .map(|scope| scope.operation_id.to_string()) + .collect::>(); + let shell_keys = ledger + .scopes + .iter() + .filter_map(|scope| { + scope.persistent_shell.as_ref().map(|shell| { + format!( + "{}\u{1f}{}", + scope.workload.target().to_canonical(), + shell.name.as_str() + ) + }) + }) + .collect::>(); + let supervisor_ids = ledger + .scopes + .iter() + .filter_map(|scope| { + scope + .persistent_shell + .as_ref() + .map(|shell| shell.supervisor_id.as_str().to_owned()) + }) + .collect::>(); + let shell_count = ledger + .scopes + .iter() + .filter(|scope| scope.persistent_shell.is_some()) + .count(); + let shell_metadata_valid = ledger.scopes.iter().all(|scope| { + (scope.kind == HelperScopeKind::PersistentShell) == scope.persistent_shell.is_some() + }); + if ledger.schema_version != 1 + || ledger.scopes.len() > MAX_HELPER_SNAPSHOT_SCOPES + || unique_operations.len() != ledger.scopes.len() + || shell_keys.len() != shell_count + || supervisor_ids.len() != shell_count + || !shell_metadata_valid + { return Err(RuntimeError::LedgerInvalid); } Ok(ledger) } -fn persist_ledger(path: &Path, ledger: &PersistedScopeLedger) -> Result<(), RuntimeError> { +pub(crate) fn persist_ledger( + path: &Path, + ledger: &PersistedScopeLedger, +) -> Result<(), RuntimeError> { let parent = path.parent().ok_or(RuntimeError::LedgerInvalid)?; fs::create_dir_all(parent).map_err(|_| RuntimeError::LedgerInvalid)?; fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) @@ -493,13 +1463,197 @@ fn persist_ledger(path: &Path, ledger: &PersistedScopeLedger) -> Result<(), Runt #[cfg(test)] mod tests { use super::*; - use d2b_contracts::unsafe_local_wire::ScopeIdentity; + use d2b_contracts::unsafe_local_wire::{HelperLaunchRequest, ScopeIdentity}; + use d2b_core::configured_argv::ConfiguredArgv; + use d2b_core::workload_identity::WorkloadTarget; + use d2b_realm_core::token::ProtocolToken; + use nix::unistd::Uid; + use std::sync::{Arc, Barrier}; + + struct Scratch(PathBuf); + + impl Scratch { + fn new() -> Self { + let mut random = [0u8; 8]; + getrandom::getrandom(&mut random).unwrap(); + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .unwrap(); + let path = root.join(format!(".d2bt-{}", hex(&random))); + fs::create_dir(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + Self(path) + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn graphical_spec(runtime_directory: PathBuf) -> GraphicalSupervisorSpec { + GraphicalSupervisorSpec::new( + PathBuf::from("/nix/store/fake-proxy/bin/d2b-wayland-proxy"), + runtime_directory, + "wayland-1".to_owned(), + WorkloadTarget::parse("tools.host.d2b").unwrap(), + RealmAccentColor::new("#cc3344").unwrap(), + Uid::current().as_raw(), + ) + .unwrap() + } + + fn ready(spec: &GraphicalSupervisorSpec, stage: ProxyReadinessStage) -> ProxyReadinessEvent { + ProxyReadinessEvent { + protocol_version: READINESS_PROTOCOL_VERSION, + target: spec.target.clone(), + provider_kind: WorkloadProviderKind::UnsafeLocal, + stage, + state: ProxyReadinessState::Ready, + failure: None, + } + } + + fn read_test_event(channel: &mut ReadinessChannel<'_>) -> ProxyReadinessEvent { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(event) = channel.read_event().unwrap() { + return event; + } + assert!(Instant::now() < deadline, "readiness event timed out"); + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn launch(operation_id: &str, arg: &str) -> HelperLaunchRequest { + HelperLaunchRequest { + request_id: 1, + operation_id: OperationId::parse(operation_id).unwrap(), + workload: serde_json::from_value(serde_json::json!({ + "workloadId": "tools", + "realmId": "host", + "realmPath": ["host"], + "canonicalTarget": "tools.host.d2b" + })) + .unwrap(), + item_id: ProtocolToken::parse("browser").unwrap(), + argv: ConfiguredArgv::new(vec![arg.to_owned()]).unwrap(), + graphical: false, + realm_accent_color: d2b_contracts::unsafe_local_wire::RealmAccentColor::new("#336699") + .unwrap(), + } + } + + #[test] + fn concurrent_reservation_allows_only_one_launch_owner() { + const CONTENDERS: usize = 16; + let ledger = Arc::new(Mutex::new(RuntimeLedger::default())); + let barrier = Arc::new(Barrier::new(CONTENDERS)); + let request = launch("op-concurrent", "program"); + let fingerprint = launch_fingerprint(&request).unwrap(); + let operation_id = request.operation_id; + let mut threads = Vec::new(); + for _ in 0..CONTENDERS { + let ledger = Arc::clone(&ledger); + let barrier = Arc::clone(&barrier); + let operation_id = operation_id.clone(); + threads.push(std::thread::spawn(move || { + barrier.wait(); + ledger.lock().unwrap().begin(&operation_id, fingerprint) + })); + } + let results = threads + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect::>(); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Ok(LaunchBegin::Started(_)))) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(RuntimeError::OperationInProgress))) + .count(), + CONTENDERS - 1 + ); + assert_eq!(ledger.lock().unwrap().reservations.len(), 1); + } + + #[test] + fn reservation_rejects_changed_fingerprint_and_replays_committed_scope() { + let first = launch("op-fingerprint", "first"); + let first_fingerprint = launch_fingerprint(&first).unwrap(); + let mut ledger = RuntimeLedger::default(); + assert!(matches!( + ledger.begin(&first.operation_id, first_fingerprint), + Ok(LaunchBegin::Started(_)) + )); + let changed = launch("op-fingerprint", "changed"); + assert!(matches!( + ledger.begin(&changed.operation_id, launch_fingerprint(&changed).unwrap()), + Err(RuntimeError::OperationIdConflict) + )); + + ledger.reservations.clear(); + ledger.persisted.scopes.push(PersistedScope { + operation_id: first.operation_id.clone(), + fingerprint: Some(first_fingerprint), + workload: first.workload, + unit_name: "app-d2b.scope".to_owned(), + invocation_id: "00112233445566778899aabbccddeeff".to_owned(), + control_group: "/user.slice/app-d2b.scope".to_owned(), + kind: HelperScopeKind::LauncherApp, + persistent_shell: None, + }); + assert!(matches!( + ledger.begin(&first.operation_id, first_fingerprint), + Ok(LaunchBegin::AlreadyCommitted(_)) + )); + assert!(matches!( + ledger.begin(&changed.operation_id, launch_fingerprint(&changed).unwrap()), + Err(RuntimeError::OperationIdConflict) + )); + } + + #[test] + fn failed_launch_clears_only_its_own_reservation() { + let request = launch("op-owned", "program"); + let fingerprint = launch_fingerprint(&request).unwrap(); + let mut ledger = RuntimeLedger::default(); + let reservation = match ledger.begin(&request.operation_id, fingerprint).unwrap() { + LaunchBegin::Started(reservation) => reservation, + LaunchBegin::AlreadyCommitted(_) => panic!("new operation was already committed"), + }; + ledger.clear( + &request.operation_id, + LaunchReservation { + fingerprint, + owner: reservation.owner.wrapping_add(1), + }, + ); + assert!(matches!( + ledger.begin(&request.operation_id, fingerprint), + Err(RuntimeError::OperationInProgress) + )); + ledger.clear(&request.operation_id, reservation); + assert!(matches!( + ledger.begin(&request.operation_id, fingerprint), + Ok(LaunchBegin::Started(_)) + )); + } #[test] fn persisted_scope_debug_hides_scope_identifiers() { let canary = "scope-private-canary"; let persisted = PersistedScope { operation_id: OperationId::parse("op-1").unwrap(), + fingerprint: None, workload: serde_json::from_value(serde_json::json!({ "workloadId": "tools", "realmId": "host", @@ -510,7 +1664,11 @@ mod tests { unit_name: canary.to_owned(), invocation_id: canary.to_owned(), control_group: format!("/{canary}"), - kind: HelperScopeKind::LauncherApp, + kind: HelperScopeKind::PersistentShell, + persistent_shell: Some(PersistedShellMetadata { + name: ShellName::new(canary).unwrap(), + supervisor_id: HelperSupervisorId::new(canary).unwrap(), + }), }; assert!(!format!("{persisted:?}").contains(canary)); } @@ -539,8 +1697,332 @@ mod tests { args: vec![canary.to_owned()], environment: BTreeMap::from([("PRIVATE".to_owned(), canary.to_owned())]), cwd: PathBuf::from(format!("/{canary}")), + graphical: None, }; assert!(!format!("{spec:?}").contains(canary)); + assert!(!format!("{:?}", launch("op-debug", canary)).contains(canary)); + } + + #[test] + fn graphical_spec_paths_and_proxy_arguments_are_strict_and_argv_free() { + let scratch = Scratch::new(); + let mut spec = graphical_spec(scratch.0.clone()); + let app_canary = "private-app-argv-canary"; + let args = proxy_arguments(&spec).unwrap(); + let rendered = args + .iter() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join("\u{1f}"); + assert!(!rendered.contains(app_canary)); + for required in [ + "--connect", + "--target", + "--provider-kind", + "unsafe-local", + "--border-enable", + "--border-color-active", + "#cc3344", + "--readiness-socket", + "--first-client-timeout-ms", + "--clipd-bridge-user-uid", + ] { + assert!(rendered.contains(required), "{required}"); + } + assert!(spec.display.ends_with("/wayland.sock")); + assert_eq!( + spec.private_directory().unwrap().parent(), + Some(scratch.0.as_path()) + ); + + for invalid in [ + "/absolute/wayland.sock", + "../wayland.sock", + "d2b-unsafe-local-00112233445566778899aabbccddeeff/../wayland.sock", + "d2b-unsafe-local-short/wayland.sock", + ] { + spec.display = invalid.to_owned(); + assert_eq!(spec.validate(), Err(RuntimeError::ProxyUnavailable)); + } + assert!(SUPERVISOR_START_TIMEOUT > FIRST_CLIENT_TIMEOUT); + } + + #[test] + fn readiness_validation_rejects_order_identity_protocol_and_failure_drift() { + let scratch = Scratch::new(); + let spec = graphical_spec(scratch.0.clone()); + let listener_event = ready(&spec, ProxyReadinessStage::Listener); + assert_eq!( + validate_readiness_event(&listener_event, ProxyReadinessStage::Upstream, &spec), + Err(RuntimeError::WaylandUnavailable) + ); + + let mut mismatch = ready(&spec, ProxyReadinessStage::Upstream); + mismatch.target = WorkloadTarget::parse("other.host.d2b").unwrap(); + assert_eq!( + validate_readiness_event(&mismatch, ProxyReadinessStage::Upstream, &spec), + Err(RuntimeError::WaylandUnavailable) + ); + mismatch = ready(&spec, ProxyReadinessStage::Upstream); + mismatch.protocol_version += 1; + assert!(validate_readiness_event(&mismatch, ProxyReadinessStage::Upstream, &spec).is_err()); + mismatch = ready(&spec, ProxyReadinessStage::FirstClient); + mismatch.state = ProxyReadinessState::Failed; + assert_eq!( + validate_readiness_event(&mismatch, ProxyReadinessStage::FirstClient, &spec), + Err(RuntimeError::FirstClientTimeout) + ); + } + + #[test] + fn readiness_parser_is_bounded_and_rejects_malformed_frames() { + let scratch = Scratch::new(); + let socket = scratch.0.join("unused.sock"); + let listener = UnixListener::bind(socket).unwrap(); + let (mut writer, reader) = UnixStream::pair().unwrap(); + reader.set_nonblocking(true).unwrap(); + let mut channel = ReadinessChannel { + listener: &listener, + stream: Some(reader), + buffered: Vec::new(), + expected_uid: Uid::current().as_raw(), + }; + writer.write_all(b"{not-json}\n").unwrap(); + assert_eq!(channel.read_event(), Err(RuntimeError::ProxyUnavailable)); + + let (mut writer, reader) = UnixStream::pair().unwrap(); + reader.set_nonblocking(true).unwrap(); + let mut channel = ReadinessChannel { + listener: &listener, + stream: Some(reader), + buffered: Vec::new(), + expected_uid: Uid::current().as_raw(), + }; + writer + .write_all(&vec![b'x'; MAX_READINESS_EVENT_BYTES + 1]) + .unwrap(); + assert_eq!(channel.read_event(), Err(RuntimeError::ProxyUnavailable)); + } + + #[test] + fn fake_proxy_and_app_complete_typed_readiness_and_cleanup() { + let scratch = Scratch::new(); + let spec = graphical_spec(scratch.0.clone()); + let private_directory = spec.private_directory().unwrap(); + let runtime = PrivateGraphicalDirectory::prepare(&spec).unwrap(); + let metadata = fs::symlink_metadata(&private_directory).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o7777, 0o700); + let (mut readiness_writer, readiness_reader) = UnixStream::pair().unwrap(); + readiness_reader.set_nonblocking(true).unwrap(); + assert!( + rustix::io::fcntl_getfd(&readiness_reader) + .unwrap() + .contains(rustix::io::FdFlags::CLOEXEC) + ); + let unused_listener = UnixListener::bind(scratch.0.join("unused.sock")).unwrap(); + let wayland_path = PathBuf::from(&spec.display); + let generated_private_directory = wayland_path.parent().unwrap().to_path_buf(); + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + builder.create(&generated_private_directory).unwrap(); + + let proxy_spec = spec.clone(); + let proxy_wayland_path = wayland_path.clone(); + let proxy = std::thread::spawn(move || { + for stage in [ProxyReadinessStage::Upstream, ProxyReadinessStage::Listener] { + serde_json::to_writer(&mut readiness_writer, &ready(&proxy_spec, stage)).unwrap(); + readiness_writer.write_all(b"\n").unwrap(); + } + let listener = UnixListener::bind(proxy_wayland_path).unwrap(); + let _client = listener.accept().unwrap().0; + serde_json::to_writer( + &mut readiness_writer, + &ready(&proxy_spec, ProxyReadinessStage::FirstClient), + ) + .unwrap(); + readiness_writer.write_all(b"\n").unwrap(); + }); + let mut channel = ReadinessChannel { + listener: &unused_listener, + stream: Some(readiness_reader), + buffered: Vec::new(), + expected_uid: Uid::current().as_raw(), + }; + let event = read_test_event(&mut channel); + validate_readiness_event(&event, ProxyReadinessStage::Upstream, &spec).unwrap(); + let event = read_test_event(&mut channel); + validate_readiness_event(&event, ProxyReadinessStage::Listener, &spec).unwrap(); + + let app_path = PathBuf::from(&spec.display); + let app = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match UnixStream::connect(&app_path) { + Ok(stream) => return stream, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("{error}"), + } + } + }); + let event = read_test_event(&mut channel); + validate_readiness_event(&event, ProxyReadinessStage::FirstClient, &spec).unwrap(); + drop(app.join().unwrap()); + proxy.join().unwrap(); + drop(channel); + drop(runtime); + assert!(!private_directory.exists()); + fs::remove_file(&wayland_path).unwrap(); + fs::remove_dir(&generated_private_directory).unwrap(); + } + + #[test] + fn plain_supervisor_behavior_is_unchanged() { + let spec = SupervisorSpec { + program: std::env::current_exe().unwrap(), + args: vec!["--list".to_owned()], + environment: BTreeMap::new(), + cwd: std::env::current_dir().unwrap(), + graphical: None, + }; + let mut ack = Vec::new(); + run_plain_supervisor(&spec, &mut ack).unwrap(); + assert_eq!(ack, [1]); + } + + struct DelayedFailingAck; + + impl Write for DelayedFailingAck { + fn write(&mut self, _buffer: &[u8]) -> std::io::Result { + std::thread::sleep(Duration::from_millis(250)); + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "test ack failure", + )) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[test] + fn plain_supervisor_reaps_child_when_started_ack_fails() { + let scratch = Scratch::new(); + let marker = scratch.0.join("child-pid"); + let spec = SupervisorSpec { + program: std::env::current_exe().unwrap(), + args: vec![ + "--exact".to_owned(), + "runtime::tests::test_child_hold".to_owned(), + "--nocapture".to_owned(), + ], + environment: BTreeMap::from([( + "D2B_TEST_PID_MARKER".to_owned(), + marker.to_string_lossy().into_owned(), + )]), + cwd: std::env::current_dir().unwrap(), + graphical: None, + }; + + assert_eq!( + run_plain_supervisor(&spec, &mut DelayedFailingAck), + Err(RuntimeError::Internal) + ); + let pid = fs::read_to_string(marker) + .expect("child marker") + .trim() + .parse::() + .expect("child pid"); + assert!(!Path::new(&format!("/proc/{pid}")).exists()); + } + + #[test] + fn first_client_wait_fails_immediately_when_app_exits() { + let scratch = Scratch::new(); + let spec = graphical_spec(scratch.0.clone()); + let listener = UnixListener::bind(scratch.0.join("readiness.sock")).unwrap(); + listener.set_nonblocking(true).unwrap(); + let mut channel = ReadinessChannel::new(&listener, Uid::current().as_raw()).unwrap(); + let executable = std::env::current_exe().unwrap(); + let mut proxy = Command::new(&executable) + .args(["--exact", "runtime::tests::test_child_hold", "--nocapture"]) + .env("D2B_TEST_HOLD", "1") + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let mut app = Command::new(executable) + .arg("--list") + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let started = Instant::now(); + assert_eq!( + channel.expect( + ProxyReadinessStage::FirstClient, + &spec, + Instant::now() + FIRST_CLIENT_TIMEOUT, + &mut proxy, + Some(&mut app), + ), + Err(RuntimeError::FirstClientTimeout) + ); + assert!(started.elapsed() < Duration::from_secs(1)); + terminate_and_reap(&mut proxy); + terminate_and_reap(&mut app); + } + + #[test] + fn readiness_wait_uses_an_absolute_deadline() { + let scratch = Scratch::new(); + let spec = graphical_spec(scratch.0.clone()); + let listener = UnixListener::bind(scratch.0.join("readiness.sock")).unwrap(); + listener.set_nonblocking(true).unwrap(); + let mut channel = ReadinessChannel::new(&listener, Uid::current().as_raw()).unwrap(); + let mut proxy = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "runtime::tests::test_child_hold", "--nocapture"]) + .env("D2B_TEST_HOLD", "1") + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let started = Instant::now(); + assert_eq!( + channel.expect( + ProxyReadinessStage::Upstream, + &spec, + Instant::now() + Duration::from_millis(30), + &mut proxy, + None, + ), + Err(RuntimeError::WaylandUnavailable) + ); + assert!(started.elapsed() < Duration::from_millis(250)); + terminate_and_reap(&mut proxy); + } + + #[test] + fn test_child_hold() { + if let Some(marker) = std::env::var_os("D2B_TEST_PID_MARKER") { + fs::write(marker, std::process::id().to_string()).unwrap(); + std::thread::sleep(Duration::from_secs(2)); + } + if std::env::var_os("D2B_TEST_HOLD").is_some() { + std::thread::sleep(Duration::from_secs(2)); + } + } + + #[test] + fn immutable_proxy_path_rejects_mutable_and_non_executable_paths() { + assert_eq!( + validate_immutable_proxy_binary(Path::new("/usr/bin/d2b-wayland-proxy")), + Err(RuntimeError::ProxyUnavailable) + ); + assert_eq!( + validate_immutable_proxy_binary(Path::new("relative/proxy")), + Err(RuntimeError::ProxyUnavailable) + ); } #[test] diff --git a/packages/d2b-unsafe-local-helper/src/shell_runtime.rs b/packages/d2b-unsafe-local-helper/src/shell_runtime.rs new file mode 100644 index 000000000..47b937560 --- /dev/null +++ b/packages/d2b-unsafe-local-helper/src/shell_runtime.rs @@ -0,0 +1,1469 @@ +use crate::runtime::{ + LaunchReservation, PersistedScope, PersistedShellMetadata, RuntimeError, ScopeRuntime, + ShellOperationBegin, persist_ledger, +}; +use crate::shell_socket::{ + connect_owned_stream, supervisor_socket_path, validate_runtime_directory, +}; +use crate::shell_supervisor::{ + BlockedShellSupervisor, DEFAULT_SHELL_OUTPUT_RING_BYTES, MAX_HELPER_SHELL_OUTPUT_BYTES, + ShellSupervisorError, ShellSupervisorSpec, +}; +use crate::supervisor_protocol::{ + SUPERVISOR_PROTOCOL_VERSION, SupervisorAction, SupervisorFailure, SupervisorRequest, + SupervisorResponse, SupervisorResult, read_frame, write_frame, +}; +use crate::systemd::{ScopeInspection, UserScopeManager}; +use d2b_contracts::public_wire::{ + ShellCloseCause, ShellDetachResult, ShellKillResult, ShellListEntry, ShellListResult, + ShellName, ShellSessionState, +}; +use d2b_contracts::unsafe_local_wire::{ + HelperPersistentShellSnapshot, HelperScopeKind, HelperScopeState, HelperShellAttachResult, + HelperShellDetachResponse, HelperShellKillResponse, HelperShellListResponse, HelperShellPolicy, + HelperShellRequest, HelperShellResponse, HelperSupervisorId, HelperTerminalReady, + HelperTerminalTransport, UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION, UnsafeLocalHelperToDaemon, +}; +use d2b_core::workload_identity::WorkloadIdentity; +use d2b_realm_core::ids::OperationId; +use nix::libc; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::os::fd::OwnedFd; +use std::os::unix::net::UnixStream; +use std::time::{Duration, Instant}; +use uzers::get_user_by_uid; +use uzers::os::unix::UserExt; + +const SUPERVISOR_CONTROL_TIMEOUT: Duration = Duration::from_secs(2); +const SHELL_CLOSE_GRACE: Duration = Duration::from_millis(250); +const SCOPE_POLL_INTERVAL: Duration = Duration::from_millis(10); +const SHELL_LIST_RECONCILE_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct ShellDispatch { + pub(crate) response: UnsafeLocalHelperToDaemon, + pub(crate) terminal_fd: Option, +} + +fn collect_verified_scope( + runtime: &ScopeRuntime, + scope: &crate::systemd::VerifiedScope, +) -> Result<(), RuntimeError> { + if !wait_for_scope_exit(runtime, scope, SHELL_CLOSE_GRACE)? { + runtime.manager.terminate_scope(scope, libc::SIGTERM)?; + if !wait_for_scope_exit(runtime, scope, SHELL_CLOSE_GRACE)? { + runtime.manager.terminate_scope(scope, libc::SIGKILL)?; + let _ = wait_for_scope_exit(runtime, scope, SHELL_CLOSE_GRACE)?; + } + } + match runtime.manager.inspect_scope(scope) { + Ok(inspection) + if inspection.identity_matches && inspection.state != HelperScopeState::Exited => + { + runtime.manager.stop_scope(scope)?; + Ok(()) + } + Ok(_) => Ok(()), + Err(error) => Err(error.into()), + } +} + +impl std::fmt::Debug for ShellDispatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShellDispatch") + .field("response", &self.response) + .field("has_terminal_fd", &self.terminal_fd.is_some()) + .finish() + } +} + +impl ShellDispatch { + fn shell(response: HelperShellResponse) -> Self { + Self { + response: UnsafeLocalHelperToDaemon::Shell(response), + terminal_fd: None, + } + } + + fn terminal(response: HelperTerminalReady, stream: UnixStream) -> Self { + Self { + response: UnsafeLocalHelperToDaemon::TerminalReady(response), + terminal_fd: Some(stream.into()), + } + } + + pub fn into_parts(self) -> (UnsafeLocalHelperToDaemon, Option) { + (self.response, self.terminal_fd) + } +} + +pub(crate) fn dispatch( + runtime: &ScopeRuntime, + request: HelperShellRequest, +) -> Result { + request + .validate_bounds() + .map_err(|_| RuntimeError::InvalidRequest)?; + let fingerprint = shell_fingerprint(&request)?; + let operation_id = request.operation_id().clone(); + let begin = runtime + .ledger + .lock() + .map_err(|_| RuntimeError::Internal)? + .begin_shell_operation(&operation_id, fingerprint)?; + match begin { + ShellOperationBegin::Replayed(Some(response)) => Ok(ShellDispatch::shell( + recorrelate_response(response, request.request_id(), operation_id), + )), + ShellOperationBegin::Replayed(None) => replay_attach(runtime, request), + ShellOperationBegin::ExistingScope(scope) => { + replay_persisted_attach(runtime, request, *scope) + } + ShellOperationBegin::Started(reservation) => { + let result = dispatch_started(runtime, request, reservation); + if result.is_err() + && let Ok(mut ledger) = runtime.ledger.lock() + { + ledger.clear_shell_operation(&operation_id, reservation, None); + } + result + } + } +} + +fn recorrelate_response( + mut response: HelperShellResponse, + request_id: u64, + operation_id: OperationId, +) -> HelperShellResponse { + match &mut response { + HelperShellResponse::List(value) => { + value.request_id = request_id; + value.operation_id = operation_id; + } + HelperShellResponse::Detach(value) => { + value.request_id = request_id; + value.operation_id = operation_id; + } + HelperShellResponse::Kill(value) => { + value.request_id = request_id; + value.operation_id = operation_id; + } + } + response +} + +fn dispatch_started( + runtime: &ScopeRuntime, + request: HelperShellRequest, + reservation: LaunchReservation, +) -> Result { + match request { + HelperShellRequest::List { + request_id, + operation_id, + workload, + policy, + } => list( + runtime, + request_id, + operation_id, + workload, + policy, + reservation, + ), + HelperShellRequest::Attach { + request_id, + operation_id, + workload, + policy, + name, + force, + initial_terminal_size, + } => attach( + runtime, + AttachOperation { + request_id, + operation_id, + workload, + policy, + name, + force, + rows: initial_terminal_size.rows, + cols: initial_terminal_size.cols, + }, + reservation, + ), + HelperShellRequest::Detach { + request_id, + operation_id, + workload, + policy: _, + name, + } => detach( + runtime, + request_id, + operation_id, + workload, + name, + reservation, + ), + HelperShellRequest::Kill { + request_id, + operation_id, + workload, + policy: _, + name, + } => kill( + runtime, + request_id, + operation_id, + workload, + name, + reservation, + ), + } +} + +#[derive(Clone)] +struct AttachOperation { + request_id: u64, + operation_id: OperationId, + workload: WorkloadIdentity, + policy: HelperShellPolicy, + name: Option, + force: bool, + rows: u32, + cols: u32, +} + +impl std::fmt::Debug for AttachOperation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AttachOperation") + .field("request_id", &self.request_id) + .field("operation_id", &self.operation_id) + .field("workload", &self.workload) + .field("policy", &self.policy) + .field("has_name", &self.name.is_some()) + .field("force", &self.force) + .field("rows", &self.rows) + .field("cols", &self.cols) + .finish() + } +} + +fn list( + runtime: &ScopeRuntime, + request_id: u64, + operation_id: OperationId, + workload: WorkloadIdentity, + policy: HelperShellPolicy, + reservation: LaunchReservation, +) -> Result { + let entries = runtime + .ledger + .lock() + .map_err(|_| RuntimeError::Internal)? + .persisted + .scopes + .iter() + .filter(|scope| { + same_workload(&scope.workload, &workload) && scope.persistent_shell.is_some() + }) + .cloned() + .collect::>(); + let mut sessions = Vec::with_capacity(entries.len()); + let deadline = Instant::now() + SHELL_LIST_RECONCILE_TIMEOUT; + for entry in entries { + let metadata = entry + .persistent_shell + .as_ref() + .ok_or(RuntimeError::Internal)?; + let state = if Instant::now() >= deadline { + ShellSessionState::PoolUnavailable + } else { + inspect_shell(runtime, &entry) + .map(|status| shell_state(status.running, status.attached)) + .unwrap_or(ShellSessionState::PoolUnavailable) + }; + sessions.push(ShellListEntry { + name: metadata.name.clone(), + state, + attached: state == ShellSessionState::Attached, + is_default: metadata.name == policy.default_name, + }); + } + sessions.sort_by(|left, right| left.name.cmp(&right.name)); + let response = HelperShellResponse::List(HelperShellListResponse { + request_id, + operation_id: operation_id.clone(), + result: ShellListResult { + default_name: policy.default_name, + sessions, + }, + }); + runtime + .ledger + .lock() + .map_err(|_| RuntimeError::Internal)? + .complete_shell_operation(&operation_id, reservation, None, Some(response.clone()))?; + Ok(ShellDispatch::shell(response)) +} + +fn attach( + runtime: &ScopeRuntime, + operation: AttachOperation, + reservation: LaunchReservation, +) -> Result { + let resolved_name = operation + .name + .clone() + .unwrap_or_else(|| operation.policy.default_name.clone()); + let name_key = shell_name_key(&operation.workload, &resolved_name); + let existing = { + let mut ledger = runtime.ledger.lock().map_err(|_| RuntimeError::Internal)?; + if let Some(scope) = find_shell( + &ledger.persisted.scopes, + &operation.workload, + &resolved_name, + ) { + Some(scope.clone()) + } else { + let workload_count = ledger + .persisted + .scopes + .iter() + .filter(|scope| { + same_workload(&scope.workload, &operation.workload) + && scope.persistent_shell.is_some() + }) + .count(); + let workload_prefix = format!("{}\u{1f}", operation.workload.target().to_canonical()); + let workload_reserved = ledger + .shell_name_reservations + .keys() + .filter(|key| key.starts_with(&workload_prefix)) + .count(); + let global_shells = ledger + .persisted + .scopes + .iter() + .filter(|scope| scope.persistent_shell.is_some()) + .count() + .saturating_add(ledger.shell_name_reservations.len()); + if !shell_quota_allows( + workload_count, + workload_reserved, + operation.policy.max_sessions, + global_shells, + ) { + return Err(RuntimeError::QuotaExceeded); + } + ledger.reserve_shell_name(name_key.clone(), reservation)?; + None + } + }; + + if let Some(scope) = existing { + let stream = attach_existing( + runtime, + &scope, + operation.force, + operation.rows, + operation.cols, + )?; + let force_evicted = stream.force_evicted; + let terminal = terminal_ready( + operation.request_id, + operation.operation_id.clone(), + &scope, + resolved_name, + force_evicted, + ); + runtime + .ledger + .lock() + .map_err(|_| RuntimeError::Internal)? + .complete_shell_operation(&operation.operation_id, reservation, None, None)?; + return Ok(ShellDispatch::terminal(terminal, stream.stream)); + } + + let result = create_and_attach(runtime, &operation, resolved_name, &name_key, reservation); + if result.is_err() + && let Ok(mut ledger) = runtime.ledger.lock() + { + ledger.clear_shell_operation(&operation.operation_id, reservation, Some(&name_key)); + } + result +} + +fn create_and_attach( + runtime: &ScopeRuntime, + operation: &AttachOperation, + resolved_name: ShellName, + name_key: &str, + reservation: LaunchReservation, +) -> Result { + let environment = runtime.manager.manager_environment()?; + let runtime_directory = environment.runtime_directory()?; + validate_runtime_directory(&runtime_directory, runtime.uid) + .map_err(|_| RuntimeError::EnvironmentInvalid)?; + let user = get_user_by_uid(runtime.uid).ok_or(RuntimeError::InvalidIdentity)?; + if user.home_dir() != runtime.shell_home || !user.shell().is_absolute() { + return Err(RuntimeError::InvalidIdentity); + } + let child_environment = environment.persistent_shell_entries(); + let supervisor_id = random_supervisor_id()?; + supervisor_socket_path(&runtime_directory, &supervisor_id) + .map_err(|_| RuntimeError::EnvironmentInvalid)?; + let spec = ShellSupervisorSpec { + supervisor_id: supervisor_id.clone(), + runtime_directory, + environment: child_environment, + cwd: runtime.shell_home.clone(), + initial_rows: u16::try_from(operation.rows).map_err(|_| RuntimeError::InvalidRequest)?, + initial_cols: u16::try_from(operation.cols).map_err(|_| RuntimeError::InvalidRequest)?, + output_ring_bytes: DEFAULT_SHELL_OUTPUT_RING_BYTES, + }; + let mut supervisor = + BlockedShellSupervisor::spawn(&runtime.executable, &spec).map_err(map_supervisor_error)?; + drop(spec); + let scope = match runtime + .manager + .start_scope(supervisor.id(), HelperScopeKind::PersistentShell) + { + Ok(scope) => scope, + Err(error) => { + supervisor.abort(); + return Err(error.into()); + } + }; + if let Err(error) = supervisor.release_and_wait_ready() { + supervisor.abort(); + runtime.stop_failed_scope(&scope); + return Err(map_supervisor_error(error)); + } + + let persisted = PersistedScope { + operation_id: operation.operation_id.clone(), + fingerprint: Some(reservation.fingerprint), + workload: operation.workload.clone(), + unit_name: scope.unit_name.clone(), + invocation_id: scope.invocation_id.clone(), + control_group: scope.control_group.clone(), + kind: scope.kind, + persistent_shell: Some(PersistedShellMetadata { + name: resolved_name.clone(), + supervisor_id, + }), + }; + if inspect_shell(runtime, &persisted).is_err() { + supervisor.abort(); + runtime.stop_failed_scope(&scope); + return Err(RuntimeError::ShellUnavailable); + } + if let Err(error) = commit_shell_scope(runtime, &persisted, reservation, name_key) { + supervisor.abort(); + runtime.stop_failed_scope(&scope); + return Err(error); + } + supervisor.reap_in_background(); + + let attached = match attach_existing( + runtime, + &persisted, + operation.force, + operation.rows, + operation.cols, + ) { + Ok(attached) => attached, + Err(error) => { + cleanup_created_shell(runtime, &persisted); + return Err(error); + } + }; + let terminal = terminal_ready( + operation.request_id, + operation.operation_id.clone(), + &persisted, + resolved_name, + attached.force_evicted, + ); + Ok(ShellDispatch::terminal(terminal, attached.stream)) +} + +fn detach( + runtime: &ScopeRuntime, + request_id: u64, + operation_id: OperationId, + workload: WorkloadIdentity, + name: ShellName, + reservation: LaunchReservation, +) -> Result { + let scope = persisted_shell(runtime, &workload, &name)?; + verify_scope(runtime, &scope)?; + let result = supervisor_action(runtime, &scope, SupervisorAction::Detach)?; + let detached = match result.result { + SupervisorResult::Detached { detached } => detached, + SupervisorResult::Rejected { code } => return Err(map_supervisor_failure(code)), + _ => return Err(RuntimeError::ShellUnavailable), + }; + let response = HelperShellResponse::Detach(HelperShellDetachResponse { + request_id, + operation_id: operation_id.clone(), + result: ShellDetachResult { + resolved_name: name, + detached, + cause: detached.then_some(ShellCloseCause::EvictedByAdminDetach), + }, + }); + runtime + .ledger + .lock() + .map_err(|_| RuntimeError::Internal)? + .complete_shell_operation(&operation_id, reservation, None, Some(response.clone()))?; + Ok(ShellDispatch::shell(response)) +} + +fn kill( + runtime: &ScopeRuntime, + request_id: u64, + operation_id: OperationId, + workload: WorkloadIdentity, + name: ShellName, + reservation: LaunchReservation, +) -> Result { + let scope = persisted_shell(runtime, &workload, &name)?; + let verified = scope.verified(); + let inspection = runtime.manager.inspect_scope(&verified)?; + if !inspection.identity_matches { + return Err(RuntimeError::ScopeIdentityMismatch); + } + let killed = match inspection.state { + HelperScopeState::Starting | HelperScopeState::Active => { + // A missing or replaced control socket cannot make a verified scope + // unkillable. Attempt graceful supervisor shutdown, then collect + // the exact scope regardless of the control result. + let _ = supervisor_action(runtime, &scope, SupervisorAction::Kill); + if let Err(error) = collect_verified_scope(runtime, &verified) + && error != RuntimeError::ScopeIdentityMismatch + { + return Err(error); + } + true + } + HelperScopeState::Stopping => { + if let Err(error) = collect_verified_scope(runtime, &verified) + && error != RuntimeError::ScopeIdentityMismatch + { + return Err(error); + } + true + } + HelperScopeState::Exited => false, + HelperScopeState::Degraded => return Err(RuntimeError::ScopeIdentityMismatch), + }; + remove_shell_scope(runtime, &scope)?; + + let response = HelperShellResponse::Kill(HelperShellKillResponse { + request_id, + operation_id: operation_id.clone(), + result: ShellKillResult { + name, + killed, + state: ShellSessionState::Killed, + }, + }); + runtime + .ledger + .lock() + .map_err(|_| RuntimeError::Internal)? + .complete_shell_operation(&operation_id, reservation, None, Some(response.clone()))?; + Ok(ShellDispatch::shell(response)) +} + +fn replay_attach( + runtime: &ScopeRuntime, + request: HelperShellRequest, +) -> Result { + let HelperShellRequest::Attach { + request_id, + operation_id, + workload, + policy, + name, + initial_terminal_size, + .. + } = request + else { + return Err(RuntimeError::OperationIdConflict); + }; + let name = name.unwrap_or(policy.default_name); + let scope = persisted_shell(runtime, &workload, &name)?; + let attached = attach_existing( + runtime, + &scope, + true, + initial_terminal_size.rows, + initial_terminal_size.cols, + )?; + let ready = terminal_ready( + request_id, + operation_id, + &scope, + name, + attached.force_evicted, + ); + Ok(ShellDispatch::terminal(ready, attached.stream)) +} + +fn replay_persisted_attach( + runtime: &ScopeRuntime, + request: HelperShellRequest, + scope: PersistedScope, +) -> Result { + let HelperShellRequest::Attach { + request_id, + operation_id, + initial_terminal_size, + .. + } = request + else { + return Err(RuntimeError::OperationIdConflict); + }; + let metadata = scope + .persistent_shell + .as_ref() + .ok_or(RuntimeError::OperationIdConflict)?; + let attached = attach_existing( + runtime, + &scope, + true, + initial_terminal_size.rows, + initial_terminal_size.cols, + )?; + let ready = terminal_ready( + request_id, + operation_id, + &scope, + metadata.name.clone(), + attached.force_evicted, + ); + Ok(ShellDispatch::terminal(ready, attached.stream)) +} + +struct AttachedStream { + stream: UnixStream, + force_evicted: bool, +} + +fn attach_existing( + runtime: &ScopeRuntime, + scope: &PersistedScope, + force: bool, + rows: u32, + cols: u32, +) -> Result { + verify_scope(runtime, scope)?; + let action = SupervisorAction::Attach { + force, + initial_terminal_size: d2b_contracts::terminal_wire::TerminalSize { rows, cols }, + }; + let (response, stream) = supervisor_action_with_stream(runtime, scope, action)?; + match response.result { + SupervisorResult::Attached { force_evicted } => { + stream + .set_read_timeout(None) + .and_then(|()| stream.set_write_timeout(None)) + .map_err(|_| RuntimeError::ShellUnavailable)?; + Ok(AttachedStream { + stream, + force_evicted, + }) + } + SupervisorResult::Rejected { + code: SupervisorFailure::AlreadyAttached, + } => Err(RuntimeError::ShellAlreadyAttached), + SupervisorResult::Rejected { code } => Err(map_supervisor_failure(code)), + _ => Err(RuntimeError::ShellUnavailable), + } +} + +struct SupervisorStatus { + running: bool, + attached: bool, +} + +fn inspect_shell( + runtime: &ScopeRuntime, + scope: &PersistedScope, +) -> Result { + verify_scope(runtime, scope)?; + let response = supervisor_action(runtime, scope, SupervisorAction::Status)?; + match response.result { + SupervisorResult::Status { + running, attached, .. + } => Ok(SupervisorStatus { running, attached }), + _ => Err(RuntimeError::ShellUnavailable), + } +} + +pub(crate) fn snapshot_shell( + runtime: &ScopeRuntime, + scope: &PersistedScope, + manager_state: HelperScopeState, +) -> (HelperScopeState, Option) { + let Some(metadata) = scope.persistent_shell.as_ref() else { + return (manager_state, None); + }; + let (state, shell_state, attached) = if manager_state == HelperScopeState::Degraded { + ( + HelperScopeState::Degraded, + ShellSessionState::PoolUnavailable, + false, + ) + } else { + match inspect_shell(runtime, scope) { + Ok(status) => ( + manager_state, + shell_state(status.running, status.attached), + status.attached, + ), + Err(_) => ( + HelperScopeState::Degraded, + ShellSessionState::PoolUnavailable, + false, + ), + } + }; + ( + state, + Some(HelperPersistentShellSnapshot { + name: metadata.name.clone(), + state: shell_state, + attached, + supervisor_id: metadata.supervisor_id.clone(), + }), + ) +} + +fn verify_scope( + runtime: &ScopeRuntime, + scope: &PersistedScope, +) -> Result { + let inspection = runtime.manager.inspect_scope(&scope.verified())?; + if !inspection.identity_matches + || !matches!( + inspection.state, + HelperScopeState::Starting | HelperScopeState::Active + ) + { + return Err(RuntimeError::ScopeIdentityMismatch); + } + Ok(inspection) +} + +fn supervisor_action( + runtime: &ScopeRuntime, + scope: &PersistedScope, + action: SupervisorAction, +) -> Result { + supervisor_action_with_stream(runtime, scope, action).map(|(response, _)| response) +} + +fn supervisor_action_with_stream( + runtime: &ScopeRuntime, + scope: &PersistedScope, + action: SupervisorAction, +) -> Result<(SupervisorResponse, UnixStream), RuntimeError> { + let metadata = scope + .persistent_shell + .as_ref() + .ok_or(RuntimeError::ShellUnavailable)?; + let environment = runtime.manager.manager_environment()?; + let runtime_directory = environment.runtime_directory()?; + let mut stream = connect_owned_stream(&runtime_directory, &metadata.supervisor_id, runtime.uid) + .map_err(|_| RuntimeError::ShellUnavailable)?; + stream + .set_read_timeout(Some(SUPERVISOR_CONTROL_TIMEOUT)) + .and_then(|()| stream.set_write_timeout(Some(SUPERVISOR_CONTROL_TIMEOUT))) + .map_err(|_| RuntimeError::ShellUnavailable)?; + let request_id = random_request_id()?; + write_frame( + &mut stream, + &SupervisorRequest { + version: SUPERVISOR_PROTOCOL_VERSION, + request_id, + action, + }, + ) + .map_err(|_| RuntimeError::ShellUnavailable)?; + let response: SupervisorResponse = + read_frame(&mut stream).map_err(|_| RuntimeError::ShellUnavailable)?; + if response.version != SUPERVISOR_PROTOCOL_VERSION || response.request_id != request_id { + return Err(RuntimeError::ShellUnavailable); + } + Ok((response, stream)) +} + +fn wait_for_scope_exit( + runtime: &ScopeRuntime, + scope: &crate::systemd::VerifiedScope, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + match runtime.manager.inspect_scope(scope) { + Ok(inspection) if inspection.identity_matches => { + if inspection.state == HelperScopeState::Exited { + return Ok(true); + } + } + Ok(_) => return Ok(true), + Err(error) => return Err(error.into()), + } + if Instant::now() >= deadline { + return Ok(false); + } + std::thread::sleep(SCOPE_POLL_INTERVAL); + } +} + +fn commit_shell_scope( + runtime: &ScopeRuntime, + scope: &PersistedScope, + reservation: LaunchReservation, + name_key: &str, +) -> Result<(), RuntimeError> { + let mut ledger = runtime.ledger.lock().map_err(|_| RuntimeError::Internal)?; + if ledger.shell_name_reservations.get(name_key).copied() != Some(reservation.owner) + || ledger + .reservations + .get(scope.operation_id.as_str()) + .is_none_or(|active| active.owner != reservation.owner) + { + return Err(RuntimeError::OperationIdConflict); + } + let mut candidate = ledger.persisted.clone(); + candidate.scopes.push(scope.clone()); + persist_ledger(&runtime.ledger_path, &candidate)?; + ledger.persisted = candidate; + ledger.clear_shell_operation(&scope.operation_id, reservation, Some(name_key)); + Ok(()) +} + +fn cleanup_created_shell(runtime: &ScopeRuntime, scope: &PersistedScope) { + let cleaned = (|| { + verify_scope(runtime, scope)?; + let response = supervisor_action(runtime, scope, SupervisorAction::Kill)?; + if !matches!(response.result, SupervisorResult::KillAccepted) { + return Err(RuntimeError::ShellUnavailable); + } + let verified = scope.verified(); + collect_verified_scope(runtime, &verified) + })(); + if cleaned.is_ok() { + let _ = remove_shell_scope(runtime, scope); + } +} + +fn remove_shell_scope( + runtime: &ScopeRuntime, + scope: &PersistedScope, +) -> Result<(), RuntimeError> { + let mut ledger = runtime.ledger.lock().map_err(|_| RuntimeError::Internal)?; + let mut candidate = ledger.persisted.clone(); + candidate.scopes.retain(|candidate| { + !(candidate.operation_id == scope.operation_id + && candidate.invocation_id == scope.invocation_id + && candidate.kind == HelperScopeKind::PersistentShell) + }); + if candidate.scopes.len() == ledger.persisted.scopes.len() { + return Err(RuntimeError::ShellNotFound); + } + persist_ledger(&runtime.ledger_path, &candidate)?; + ledger.persisted = candidate; + if let Some(fingerprint) = scope.fingerprint { + ledger.remember_completed_shell_operation(&scope.operation_id, fingerprint, None); + } + Ok(()) +} + +fn persisted_shell( + runtime: &ScopeRuntime, + workload: &WorkloadIdentity, + name: &ShellName, +) -> Result { + runtime + .ledger + .lock() + .map_err(|_| RuntimeError::Internal)? + .persisted + .scopes + .iter() + .find(|scope| { + same_workload(&scope.workload, workload) + && scope + .persistent_shell + .as_ref() + .is_some_and(|shell| shell.name == *name) + }) + .cloned() + .ok_or(RuntimeError::ShellNotFound) +} + +fn find_shell<'a>( + scopes: &'a [PersistedScope], + workload: &WorkloadIdentity, + name: &ShellName, +) -> Option<&'a PersistedScope> { + scopes.iter().find(|scope| { + same_workload(&scope.workload, workload) + && scope + .persistent_shell + .as_ref() + .is_some_and(|shell| shell.name == *name) + }) +} + +fn shell_name_key(workload: &WorkloadIdentity, name: &ShellName) -> String { + format!( + "{}\u{1f}{}", + workload.target().to_canonical(), + name.as_str() + ) +} + +fn same_workload(left: &WorkloadIdentity, right: &WorkloadIdentity) -> bool { + left.target() == right.target() +} + +fn shell_state(running: bool, attached: bool) -> ShellSessionState { + if !running { + ShellSessionState::Killed + } else if attached { + ShellSessionState::Attached + } else { + ShellSessionState::Detached + } +} + +fn shell_quota_allows( + workload_shells: usize, + workload_reservations: usize, + max_sessions: u16, + global_shells_and_reservations: usize, +) -> bool { + workload_shells.saturating_add(workload_reservations) < max_sessions as usize + && global_shells_and_reservations.saturating_mul(DEFAULT_SHELL_OUTPUT_RING_BYTES) + < MAX_HELPER_SHELL_OUTPUT_BYTES +} + +fn terminal_ready( + request_id: u64, + operation_id: OperationId, + scope: &PersistedScope, + resolved_name: ShellName, + force_evicted: bool, +) -> HelperTerminalReady { + HelperTerminalReady { + request_id, + operation_id, + terminal_protocol_version: UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION, + transport: HelperTerminalTransport::ConnectedUnixStream, + scope: scope.verified().wire_identity(), + result: HelperShellAttachResult { + resolved_name, + state: ShellSessionState::Attached, + force_evicted, + }, + } +} + +fn shell_fingerprint(request: &HelperShellRequest) -> Result<[u8; 32], RuntimeError> { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + enum Fingerprint<'a> { + List { + workload: &'a WorkloadIdentity, + policy: &'a HelperShellPolicy, + }, + Attach { + workload: &'a WorkloadIdentity, + policy: &'a HelperShellPolicy, + name: &'a Option, + force: bool, + rows: u32, + cols: u32, + }, + Detach { + workload: &'a WorkloadIdentity, + policy: &'a HelperShellPolicy, + name: &'a ShellName, + }, + Kill { + workload: &'a WorkloadIdentity, + policy: &'a HelperShellPolicy, + name: &'a ShellName, + }, + } + let fingerprint = match request { + HelperShellRequest::List { + workload, policy, .. + } => Fingerprint::List { workload, policy }, + HelperShellRequest::Attach { + workload, + policy, + name, + force, + initial_terminal_size, + .. + } => Fingerprint::Attach { + workload, + policy, + name, + force: *force, + rows: initial_terminal_size.rows, + cols: initial_terminal_size.cols, + }, + HelperShellRequest::Detach { + workload, + policy, + name, + .. + } => Fingerprint::Detach { + workload, + policy, + name, + }, + HelperShellRequest::Kill { + workload, + policy, + name, + .. + } => Fingerprint::Kill { + workload, + policy, + name, + }, + }; + let encoded = serde_json::to_vec(&fingerprint).map_err(|_| RuntimeError::Internal)?; + Ok(Sha256::digest(encoded).into()) +} + +fn random_supervisor_id() -> Result { + let mut bytes = [0u8; 16]; + getrandom::getrandom(&mut bytes).map_err(|_| RuntimeError::Internal)?; + HelperSupervisorId::new(hex(&bytes)).map_err(|_| RuntimeError::Internal) +} + +fn random_request_id() -> Result { + let mut bytes = [0u8; 8]; + getrandom::getrandom(&mut bytes).map_err(|_| RuntimeError::Internal)?; + let value = u64::from_le_bytes(bytes); + Ok(if value == 0 { 1 } else { value }) +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(DIGITS[(byte >> 4) as usize] as char); + encoded.push(DIGITS[(byte & 0x0f) as usize] as char); + } + encoded +} + +fn map_supervisor_error(error: ShellSupervisorError) -> RuntimeError { + match error { + ShellSupervisorError::InvalidSpec => RuntimeError::InvalidRequest, + ShellSupervisorError::ReadyTimeout => RuntimeError::Timeout, + ShellSupervisorError::SpawnFailed | ShellSupervisorError::RuntimeUnavailable => { + RuntimeError::ShellUnavailable + } + } +} + +fn map_supervisor_failure(error: SupervisorFailure) -> RuntimeError { + match error { + SupervisorFailure::AlreadyAttached => RuntimeError::ShellAlreadyAttached, + SupervisorFailure::Closed => RuntimeError::TerminalClosed, + SupervisorFailure::InvalidRequest => RuntimeError::InvalidRequest, + SupervisorFailure::Internal => RuntimeError::Internal, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::environment::ManagerEnvironment; + use crate::runtime::{PersistedScopeLedger, RuntimeLedger}; + use crate::shell_socket::OwnedShellListener; + use crate::systemd::{ScopeError, VerifiedScope}; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Barrier, Mutex}; + use uzers::get_current_uid; + + fn workload() -> WorkloadIdentity { + serde_json::from_value(serde_json::json!({ + "workloadId": "tools", + "realmId": "host", + "realmPath": ["host"], + "canonicalTarget": "tools.host.d2b" + })) + .unwrap() + } + + fn policy() -> HelperShellPolicy { + HelperShellPolicy { + default_name: ShellName::new("host").unwrap(), + max_sessions: 2, + } + } + + #[derive(Debug)] + struct FakeManager { + environment: ManagerEnvironment, + stop_calls: Arc, + } + + impl UserScopeManager for FakeManager { + fn manager_environment(&self) -> Result { + Ok(self.environment.clone()) + } + + fn start_scope( + &self, + _supervisor_pid: u32, + _kind: HelperScopeKind, + ) -> Result { + Err(ScopeError::CreateFailed) + } + + fn inspect_scope(&self, _scope: &VerifiedScope) -> Result { + Ok(ScopeInspection { + state: HelperScopeState::Active, + identity_matches: true, + }) + } + + fn terminate_scope(&self, _scope: &VerifiedScope, _signal: i32) -> Result<(), ScopeError> { + self.stop_calls.fetch_add(1, Ordering::AcqRel); + Ok(()) + } + + fn stop_scope(&self, _scope: &VerifiedScope) -> Result<(), ScopeError> { + self.stop_calls.fetch_add(1, Ordering::AcqRel); + Ok(()) + } + } + + struct Scratch(PathBuf); + + impl Scratch { + fn new() -> Self { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(std::path::Path::parent) + .unwrap(); + for _ in 0..32 { + let mut random = [0u8; 2]; + getrandom::getrandom(&mut random).unwrap(); + let path = root.join(format!("s{:02x}{:02x}", random[0], random[1])); + if fs::create_dir(&path).is_ok() { + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + return Self(path); + } + } + panic!("could not create repository-local shell test directory"); + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn attach_request(operation: &str, name: &str) -> HelperShellRequest { + HelperShellRequest::Attach { + request_id: 1, + operation_id: OperationId::parse(operation).unwrap(), + workload: workload(), + policy: policy(), + name: Some(ShellName::new(name).unwrap()), + force: false, + initial_terminal_size: d2b_contracts::terminal_wire::TerminalSize { + rows: 24, + cols: 80, + }, + } + } + + #[test] + fn concurrent_duplicate_attach_reserves_one_operation() { + const CONTENDERS: usize = 16; + let ledger = Arc::new(Mutex::new(RuntimeLedger::from_persisted( + PersistedScopeLedger { + schema_version: 1, + scopes: Vec::new(), + }, + ))); + let barrier = Arc::new(Barrier::new(CONTENDERS)); + let request = attach_request("op-concurrent-shell", "host"); + let fingerprint = shell_fingerprint(&request).unwrap(); + let operation_id = request.operation_id().clone(); + let mut threads = Vec::new(); + for _ in 0..CONTENDERS { + let ledger = Arc::clone(&ledger); + let barrier = Arc::clone(&barrier); + let operation_id = operation_id.clone(); + threads.push(std::thread::spawn(move || { + barrier.wait(); + ledger + .lock() + .unwrap() + .begin_shell_operation(&operation_id, fingerprint) + })); + } + let results = threads + .into_iter() + .map(|thread| thread.join().unwrap()) + .collect::>(); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Ok(ShellOperationBegin::Started(_)))) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, Err(RuntimeError::OperationInProgress))) + .count(), + CONTENDERS - 1 + ); + } + + #[test] + fn changed_operation_fingerprint_conflicts() { + let first = attach_request("op-shell", "one"); + let changed = attach_request("op-shell", "two"); + let mut ledger = RuntimeLedger::from_persisted(PersistedScopeLedger { + schema_version: 1, + scopes: Vec::new(), + }); + assert!(matches!( + ledger.begin_shell_operation(first.operation_id(), shell_fingerprint(&first).unwrap()), + Ok(ShellOperationBegin::Started(_)) + )); + assert!(matches!( + ledger.begin_shell_operation( + changed.operation_id(), + shell_fingerprint(&changed).unwrap() + ), + Err(RuntimeError::OperationIdConflict) + )); + } + + #[test] + fn idempotent_management_replay_uses_current_request_correlation() { + let response = HelperShellResponse::Detach(HelperShellDetachResponse { + request_id: 1, + operation_id: OperationId::parse("op-replay").unwrap(), + result: ShellDetachResult { + resolved_name: ShellName::new("host").unwrap(), + detached: true, + cause: Some(ShellCloseCause::EvictedByAdminDetach), + }, + }); + let replayed = recorrelate_response(response, 99, OperationId::parse("op-replay").unwrap()); + assert_eq!(replayed.request_id(), 99); + assert_eq!(replayed.operation_id().as_str(), "op-replay"); + } + + #[test] + fn shell_keys_are_workload_scoped_and_debug_redacted() { + let name = ShellName::new("private-name-canary").unwrap(); + let key = shell_name_key(&workload(), &name); + assert!(key.ends_with("private-name-canary")); + let operation = AttachOperation { + request_id: 1, + operation_id: OperationId::parse("op-redact").unwrap(), + workload: workload(), + policy: policy(), + name: Some(name), + force: false, + rows: 24, + cols: 80, + }; + assert!(!format!("{operation:?}").contains("private-name-canary")); + } + + #[test] + fn helper_wide_ring_reservation_is_bounded() { + assert!(shell_quota_allows(1, 0, 2, 63)); + assert!(!shell_quota_allows(2, 0, 2, 2)); + assert!(!shell_quota_allows(1, 1, 2, 2)); + assert!(!shell_quota_allows(0, 0, 64, 64)); + } + + #[test] + fn reconstructed_runtime_adopts_verified_shell_and_degrades_missing_socket() { + if get_current_uid() == 0 { + return; + } + let scratch = Scratch::new(); + let supervisor_id = HelperSupervisorId::new("adoption-test").unwrap(); + let listener = + OwnedShellListener::bind(&scratch.0, &supervisor_id, get_current_uid()).unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.listener().accept().unwrap(); + let request: SupervisorRequest = read_frame(&mut stream).unwrap(); + write_frame( + &mut stream, + &SupervisorResponse { + version: SUPERVISOR_PROTOCOL_VERSION, + request_id: request.request_id, + result: SupervisorResult::Status { + running: true, + attached: false, + terminal_status: None, + }, + }, + ) + .unwrap(); + }); + let identity = workload(); + let ledger = PersistedScopeLedger { + schema_version: 1, + scopes: vec![ + PersistedScope { + operation_id: OperationId::parse("op-launcher").unwrap(), + fingerprint: Some([1; 32]), + workload: identity.clone(), + unit_name: "launcher.scope".to_owned(), + invocation_id: "launcher-invocation".to_owned(), + control_group: "/user/launcher.scope".to_owned(), + kind: HelperScopeKind::LauncherApp, + persistent_shell: None, + }, + PersistedScope { + operation_id: OperationId::parse("op-shell-adopt").unwrap(), + fingerprint: Some([2; 32]), + workload: identity, + unit_name: "shell.scope".to_owned(), + invocation_id: "shell-invocation".to_owned(), + control_group: "/user/shell.scope".to_owned(), + kind: HelperScopeKind::PersistentShell, + persistent_shell: Some(PersistedShellMetadata { + name: ShellName::new("host").unwrap(), + supervisor_id, + }), + }, + ], + }; + let ledger_path = scratch.0.join("ledger.json"); + persist_ledger(&ledger_path, &ledger).unwrap(); + let stops = Arc::new(AtomicUsize::new(0)); + let manager = FakeManager { + environment: ManagerEnvironment::parse(vec![ + "PATH=/bin".to_owned(), + format!("XDG_RUNTIME_DIR={}", scratch.0.display()), + ]) + .unwrap(), + stop_calls: Arc::clone(&stops), + }; + let runtime = ScopeRuntime::with_paths_and_executable( + manager, + scratch.0.clone(), + ledger_path, + std::env::current_exe().unwrap(), + ) + .unwrap(); + let adopted = runtime.snapshot(7).unwrap(); + server.join().unwrap(); + assert_eq!(adopted.scopes.len(), 2); + let shell = adopted + .scopes + .iter() + .find(|scope| scope.persistent_shell.is_some()) + .unwrap(); + assert_eq!(shell.state, HelperScopeState::Active); + assert_eq!( + shell.persistent_shell.as_ref().unwrap().state, + ShellSessionState::Detached + ); + + let degraded = runtime.snapshot(8).unwrap(); + let shell = degraded + .scopes + .iter() + .find(|scope| scope.persistent_shell.is_some()) + .unwrap(); + assert_eq!(shell.state, HelperScopeState::Degraded); + assert_eq!( + shell.persistent_shell.as_ref().unwrap().state, + ShellSessionState::PoolUnavailable + ); + assert_eq!(stops.load(Ordering::Acquire), 0); + assert_eq!(runtime.ledger.lock().unwrap().persisted.scopes.len(), 2); + } + + #[test] + fn shell_name_reservation_conflicts_without_consuming_other_names() { + let mut ledger = RuntimeLedger::from_persisted(PersistedScopeLedger { + schema_version: 1, + scopes: Vec::new(), + }); + let first = attach_request("op-name-one", "host"); + let second = attach_request("op-name-two", "host"); + let other = attach_request("op-name-three", "other"); + let first_reservation = match ledger + .begin_shell_operation(first.operation_id(), shell_fingerprint(&first).unwrap()) + .unwrap() + { + ShellOperationBegin::Started(reservation) => reservation, + _ => panic!("unexpected replay"), + }; + ledger + .reserve_shell_name( + shell_name_key(&workload(), &ShellName::new("host").unwrap()), + first_reservation, + ) + .unwrap(); + let second_reservation = match ledger + .begin_shell_operation(second.operation_id(), shell_fingerprint(&second).unwrap()) + .unwrap() + { + ShellOperationBegin::Started(reservation) => reservation, + _ => panic!("unexpected replay"), + }; + assert_eq!( + ledger.reserve_shell_name( + shell_name_key(&workload(), &ShellName::new("host").unwrap()), + second_reservation, + ), + Err(RuntimeError::OperationInProgress) + ); + let other_reservation = match ledger + .begin_shell_operation(other.operation_id(), shell_fingerprint(&other).unwrap()) + .unwrap() + { + ShellOperationBegin::Started(reservation) => reservation, + _ => panic!("unexpected replay"), + }; + assert!( + ledger + .reserve_shell_name( + shell_name_key(&workload(), &ShellName::new("other").unwrap()), + other_reservation, + ) + .is_ok() + ); + } +} diff --git a/packages/d2b-unsafe-local-helper/src/shell_socket.rs b/packages/d2b-unsafe-local-helper/src/shell_socket.rs new file mode 100644 index 000000000..5c7dacd50 --- /dev/null +++ b/packages/d2b-unsafe-local-helper/src/shell_socket.rs @@ -0,0 +1,291 @@ +use d2b_contracts::unsafe_local_wire::HelperSupervisorId; +use nix::sys::socket::{getsockopt, sockopt::PeerCredentials}; +use nix::sys::stat::{Mode as NixMode, umask}; +use std::fmt; +use std::fs; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +const MAX_SOCKET_PATH_BYTES: usize = 107; +static SOCKET_BIND_UMASK: Mutex<()> = Mutex::new(()); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ShellSocketError { + RuntimeDirectoryInvalid, + PathInvalid, + AlreadyExists, + BindFailed, + OwnershipMismatch, + ConnectFailed, +} + +pub(crate) fn validate_runtime_directory( + directory: &Path, + expected_uid: u32, +) -> Result<(), ShellSocketError> { + if !directory.is_absolute() { + return Err(ShellSocketError::RuntimeDirectoryInvalid); + } + let metadata = + fs::symlink_metadata(directory).map_err(|_| ShellSocketError::RuntimeDirectoryInvalid)?; + let mode = metadata.permissions().mode() & 0o7777; + if !metadata.file_type().is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != expected_uid + || mode & 0o700 != 0o700 + || mode & 0o027 != 0 + || mode & 0o7000 != 0 + { + return Err(ShellSocketError::RuntimeDirectoryInvalid); + } + Ok(()) +} + +pub(crate) fn supervisor_socket_path( + runtime_directory: &Path, + supervisor_id: &HelperSupervisorId, +) -> Result { + let path = runtime_directory.join(format!(".d2b-shell-{}.sock", supervisor_id.as_str())); + if path.as_os_str().as_bytes().len() > MAX_SOCKET_PATH_BYTES { + return Err(ShellSocketError::PathInvalid); + } + Ok(path) +} + +pub(crate) struct OwnedShellListener { + listener: UnixListener, + path: PathBuf, + owner_uid: u32, + device: u64, + inode: u64, +} + +impl fmt::Debug for OwnedShellListener { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OwnedShellListener") + .field("path", &"") + .field("owner_uid", &"") + .finish_non_exhaustive() + } +} + +impl OwnedShellListener { + pub(crate) fn bind( + runtime_directory: &Path, + supervisor_id: &HelperSupervisorId, + expected_uid: u32, + ) -> Result { + validate_runtime_directory(runtime_directory, expected_uid)?; + let path = supervisor_socket_path(runtime_directory, supervisor_id)?; + match fs::symlink_metadata(&path) { + Ok(_) => return Err(ShellSocketError::AlreadyExists), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(ShellSocketError::BindFailed), + } + + let bind_guard = SOCKET_BIND_UMASK + .lock() + .map_err(|_| ShellSocketError::BindFailed)?; + let previous_umask = umask(NixMode::from_bits_truncate(0o177)); + let listener = UnixListener::bind(&path); + umask(previous_umask); + drop(bind_guard); + let listener = listener.map_err(|_| ShellSocketError::BindFailed)?; + let result = (|| { + let metadata = + fs::symlink_metadata(&path).map_err(|_| ShellSocketError::OwnershipMismatch)?; + if !metadata.file_type().is_socket() + || metadata.uid() != expected_uid + || metadata.permissions().mode() & 0o7777 != 0o600 + { + return Err(ShellSocketError::OwnershipMismatch); + } + Ok((metadata.dev(), metadata.ino())) + })(); + let (device, inode) = match result { + Ok(identity) => identity, + Err(error) => { + remove_if_exact_socket(&path, expected_uid, None); + return Err(error); + } + }; + Ok(Self { + listener, + path, + owner_uid: expected_uid, + device, + inode, + }) + } + + pub(crate) fn listener(&self) -> &UnixListener { + &self.listener + } +} + +impl Drop for OwnedShellListener { + fn drop(&mut self) { + remove_if_exact_socket(&self.path, self.owner_uid, Some((self.device, self.inode))); + } +} + +pub(crate) fn connect_owned_stream( + runtime_directory: &Path, + supervisor_id: &HelperSupervisorId, + expected_uid: u32, +) -> Result { + validate_runtime_directory(runtime_directory, expected_uid)?; + let path = supervisor_socket_path(runtime_directory, supervisor_id)?; + let before = fs::symlink_metadata(&path).map_err(|_| ShellSocketError::ConnectFailed)?; + if !before.file_type().is_socket() + || before.uid() != expected_uid + || before.permissions().mode() & 0o7777 != 0o600 + { + return Err(ShellSocketError::OwnershipMismatch); + } + let stream = UnixStream::connect(&path).map_err(|_| ShellSocketError::ConnectFailed)?; + let peer = + getsockopt(&stream, PeerCredentials).map_err(|_| ShellSocketError::OwnershipMismatch)?; + if peer.uid() as u32 != expected_uid { + return Err(ShellSocketError::OwnershipMismatch); + } + let after = fs::symlink_metadata(&path).map_err(|_| ShellSocketError::OwnershipMismatch)?; + if !after.file_type().is_socket() + || after.uid() != expected_uid + || before.dev() != after.dev() + || before.ino() != after.ino() + { + return Err(ShellSocketError::OwnershipMismatch); + } + Ok(stream) +} + +fn remove_if_exact_socket(path: &Path, owner_uid: u32, identity: Option<(u64, u64)>) { + let Ok(metadata) = fs::symlink_metadata(path) else { + return; + }; + let identity_matches = identity + .map(|(device, inode)| metadata.dev() == device && metadata.ino() == inode) + .unwrap_or(true); + if metadata.file_type().is_socket() && metadata.uid() == owner_uid && identity_matches { + let _ = fs::remove_file(path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nix::unistd::Uid; + use std::os::fd::{AsFd, AsRawFd}; + + fn scratch() -> PathBuf { + let mut random = [0u8; 4]; + getrandom::getrandom(&mut random).unwrap(); + let suffix = random + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .unwrap(); + let path = root.join(format!(".d2bt-{suffix}")); + fs::create_dir_all(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + path + } + + #[test] + fn listener_is_private_cloexec_and_cleans_only_owned_inode() { + let directory = scratch(); + let uid = Uid::current().as_raw(); + let id = HelperSupervisorId::new("socket-test").unwrap(); + let path = supervisor_socket_path(&directory, &id).unwrap(); + { + let owned = OwnedShellListener::bind(&directory, &id, uid).unwrap(); + let metadata = fs::symlink_metadata(&path).unwrap(); + assert!(metadata.file_type().is_socket()); + assert_eq!(metadata.permissions().mode() & 0o7777, 0o600); + assert!(owned.listener().as_fd().as_raw_fd() >= 0); + assert!( + rustix::io::fcntl_getfd(owned.listener()) + .unwrap() + .contains(rustix::io::FdFlags::CLOEXEC) + ); + } + assert!(!path.exists()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn runtime_directory_allows_acl_traversal_without_group_write() { + let directory = scratch(); + let uid = Uid::current().as_raw(); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o750)).unwrap(); + assert_eq!(validate_runtime_directory(&directory, uid), Ok(())); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn runtime_directory_rejects_symlink_and_permissive_mode() { + let directory = scratch(); + let uid = Uid::current().as_raw(); + for mode in [0o770, 0o755, 0o1750] { + fs::set_permissions(&directory, fs::Permissions::from_mode(mode)).unwrap(); + assert_eq!( + validate_runtime_directory(&directory, uid), + Err(ShellSocketError::RuntimeDirectoryInvalid), + "{mode:o}" + ); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn supervisor_socket_path_rejects_paths_beyond_sun_path() { + let directory = PathBuf::from("/").join("r".repeat(MAX_SOCKET_PATH_BYTES)); + let id = HelperSupervisorId::new("path-boundary").unwrap(); + assert_eq!( + supervisor_socket_path(&directory, &id), + Err(ShellSocketError::PathInvalid) + ); + } + + #[test] + fn bind_never_follows_preexisting_symlink() { + use std::os::unix::fs::symlink; + + let directory = scratch(); + let uid = Uid::current().as_raw(); + let id = HelperSupervisorId::new("symlink-test").unwrap(); + let path = supervisor_socket_path(&directory, &id).unwrap(); + let target = directory.join("target"); + fs::write(&target, b"unchanged").unwrap(); + symlink(&target, &path).unwrap(); + assert_eq!( + OwnedShellListener::bind(&directory, &id, uid).unwrap_err(), + ShellSocketError::AlreadyExists + ); + assert_eq!(fs::read(&target).unwrap(), b"unchanged"); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn cleanup_preserves_replacement_socket_inode() { + let directory = scratch(); + let uid = Uid::current().as_raw(); + let id = HelperSupervisorId::new("replacement-test").unwrap(); + let path = supervisor_socket_path(&directory, &id).unwrap(); + let owned = OwnedShellListener::bind(&directory, &id, uid).unwrap(); + fs::remove_file(&path).unwrap(); + let replacement = UnixListener::bind(&path).unwrap(); + drop(owned); + assert!(fs::symlink_metadata(&path).unwrap().file_type().is_socket()); + drop(replacement); + fs::remove_file(path).unwrap(); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs b/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs new file mode 100644 index 000000000..cf3d03f73 --- /dev/null +++ b/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs @@ -0,0 +1,1271 @@ +use crate::output_ring::OutputRing; +use crate::shell_socket::OwnedShellListener; +use crate::supervisor_protocol::{ + SUPERVISOR_PROTOCOL_VERSION, SupervisorAction, SupervisorFailure, SupervisorRequest, + SupervisorResponse, SupervisorResult, read_frame as read_supervisor_frame, + validate_request as validate_supervisor_request, write_frame as write_supervisor_frame, +}; +use d2b_contracts::terminal_wire::{ + TerminalCloseResult, TerminalControlResult, TerminalStatus, TerminalStream, TerminalWaitResult, + TerminalWriteStdinResult, +}; +use d2b_contracts::unsafe_local_wire::{ + HelperFailureCode, HelperSupervisorId, HelperTerminalAttachmentClosed, + HelperTerminalControlResponse, HelperTerminalOperationResult, HelperTerminalReadOutputResult, + HelperTerminalRejected, HelperTerminalRequest, HelperTerminalResponse, + MAX_UNSAFE_LOCAL_TERMINAL_FRAME_SIZE, MAX_UNSAFE_LOCAL_TERMINAL_WAIT_TIMEOUT_MS, + decode_unsafe_local_terminal_frame, encode_unsafe_local_terminal_frame, +}; +use nix::poll::{PollFd, PollFlags, PollTimeout, poll}; +use nix::sys::socket::{getsockopt, sockopt::PeerCredentials}; +use rustix::fs::{Mode, OFlags}; +use rustix::io::{Errno, fcntl_dupfd_cloexec, ioctl_fionbio, read as fd_read, write as fd_write}; +use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt}; +use rustix::termios::{Winsize, tcsetwinsize}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fmt; +use std::fs::File; +use std::io::{Read, Write}; +use std::os::fd::{AsFd, OwnedFd}; +use std::os::unix::net::UnixStream; +use std::os::unix::process::ExitStatusExt; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex, mpsc}; +use std::time::{Duration, Instant}; +use uzers::os::unix::UserExt; +use uzers::{get_current_uid, get_user_by_uid}; + +pub(crate) const DEFAULT_SHELL_OUTPUT_RING_BYTES: usize = 512 * 1024; +pub(crate) const MAX_HELPER_SHELL_OUTPUT_BYTES: usize = 32 * 1024 * 1024; +pub(crate) const SHELL_SUPERVISOR_READY_TIMEOUT: Duration = Duration::from_secs(5); +const _: () = { + assert!(DEFAULT_SHELL_OUTPUT_RING_BYTES > 0); + assert!(DEFAULT_SHELL_OUTPUT_RING_BYTES < 8 * 1024 * 1024); + assert!(MAX_HELPER_SHELL_OUTPUT_BYTES / DEFAULT_SHELL_OUTPUT_RING_BYTES == 64); +}; +const MAX_SUPERVISOR_SPEC_BYTES: usize = 2 * 1024 * 1024; +const MAX_TERMINAL_WORKERS: usize = 16; +const MAX_CONTROL_CONNECTIONS: usize = 32; +const SUPERVISOR_POLL_TIMEOUT: Duration = Duration::from_millis(100); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ShellSupervisorError { + InvalidSpec, + SpawnFailed, + ReadyTimeout, + RuntimeUnavailable, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ShellSupervisorSpec { + pub supervisor_id: HelperSupervisorId, + pub runtime_directory: PathBuf, + pub environment: BTreeMap, + pub cwd: PathBuf, + pub initial_rows: u16, + pub initial_cols: u16, + pub output_ring_bytes: usize, +} + +impl fmt::Debug for ShellSupervisorSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ShellSupervisorSpec") + .field("supervisor_id", &"") + .field("runtime_directory", &"") + .field("environment_count", &self.environment.len()) + .field("cwd", &"") + .field("initial_rows", &self.initial_rows) + .field("initial_cols", &self.initial_cols) + .field("output_ring_bytes", &self.output_ring_bytes) + .finish() + } +} + +pub(crate) struct BlockedShellSupervisor { + child: Option, + stdin: Option, + stdout: Option, +} + +impl BlockedShellSupervisor { + pub(crate) fn spawn( + executable: &Path, + spec: &ShellSupervisorSpec, + ) -> Result { + let encoded = serde_json::to_vec(spec).map_err(|_| ShellSupervisorError::InvalidSpec)?; + if encoded.is_empty() || encoded.len() > MAX_SUPERVISOR_SPEC_BYTES { + return Err(ShellSupervisorError::InvalidSpec); + } + let mut child = Command::new(executable) + .arg("shell-supervisor") + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| ShellSupervisorError::SpawnFailed)?; + let mut stdin = child + .stdin + .take() + .ok_or(ShellSupervisorError::SpawnFailed)?; + let stdout = child + .stdout + .take() + .ok_or(ShellSupervisorError::SpawnFailed)?; + let length = u32::try_from(encoded.len()).map_err(|_| ShellSupervisorError::InvalidSpec)?; + if stdin.write_all(&length.to_le_bytes()).is_err() || stdin.write_all(&encoded).is_err() { + let _ = child.kill(); + let _ = child.wait(); + return Err(ShellSupervisorError::SpawnFailed); + } + Ok(Self { + child: Some(child), + stdin: Some(stdin), + stdout: Some(stdout), + }) + } + + pub(crate) fn id(&self) -> u32 { + self.child.as_ref().expect("shell supervisor child").id() + } + + pub(crate) fn release_and_wait_ready(&mut self) -> Result<(), ShellSupervisorError> { + let mut stdin = self.stdin.take().ok_or(ShellSupervisorError::SpawnFailed)?; + stdin + .write_all(&[1]) + .map_err(|_| ShellSupervisorError::SpawnFailed)?; + drop(stdin); + + let mut stdout = self + .stdout + .take() + .ok_or(ShellSupervisorError::SpawnFailed)?; + let (sender, receiver) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("d2b-shell-ready".to_owned()) + .spawn(move || { + let mut ready = [0u8; 1]; + let result = stdout.read_exact(&mut ready).map(|()| ready[0]); + let _ = sender.send(result); + }) + .map_err(|_| ShellSupervisorError::RuntimeUnavailable)?; + match receiver.recv_timeout(SHELL_SUPERVISOR_READY_TIMEOUT) { + Ok(Ok(1)) => Ok(()), + Ok(Ok(_)) | Ok(Err(_)) => Err(ShellSupervisorError::SpawnFailed), + Err(_) => Err(ShellSupervisorError::ReadyTimeout), + } + } + + pub(crate) fn abort(&mut self) { + self.stdin.take(); + self.stdout.take(); + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + + pub(crate) fn reap_in_background(mut self) { + self.stdin.take(); + self.stdout.take(); + if let Some(mut child) = self.child.take() { + let _ = std::thread::Builder::new() + .name("d2b-shell-reaper".to_owned()) + .spawn(move || { + let _ = child.wait(); + }); + } + } +} + +impl Drop for BlockedShellSupervisor { + fn drop(&mut self) { + if self.child.is_some() { + self.abort(); + } + } +} + +#[derive(Debug, Default)] +struct ProcessState { + terminal_status: Option, +} + +#[derive(Debug, Default)] +struct AttachmentProtocolState { + input_offset: u64, + control_sequence: u64, +} + +struct AttachmentSlot { + generation: u64, + shutdown: UnixStream, +} + +impl fmt::Debug for AttachmentSlot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AttachmentSlot") + .field("generation", &self.generation) + .finish_non_exhaustive() + } +} + +struct SupervisorState { + ring: Arc, + master: Mutex>, + process: Mutex, + process_changed: Condvar, + attachment: Mutex>, + next_attachment: AtomicU64, + kill_requested: AtomicBool, + kill_wakeup: UnixStream, + stdin_closed: AtomicBool, + terminal_workers: Arc<(Mutex, Condvar)>, + control_connections: AtomicUsize, +} + +impl fmt::Debug for SupervisorState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SupervisorState") + .field( + "kill_requested", + &self.kill_requested.load(Ordering::Acquire), + ) + .field("stdin_closed", &self.stdin_closed.load(Ordering::Acquire)) + .finish_non_exhaustive() + } +} + +impl SupervisorState { + fn new(master: OwnedFd, ring: Arc, kill_wakeup: UnixStream) -> Self { + Self { + ring, + master: Mutex::new(Some(master)), + process: Mutex::new(ProcessState::default()), + process_changed: Condvar::new(), + attachment: Mutex::new(None), + next_attachment: AtomicU64::new(0), + kill_requested: AtomicBool::new(false), + kill_wakeup, + stdin_closed: AtomicBool::new(false), + terminal_workers: Arc::new((Mutex::new(0), Condvar::new())), + control_connections: AtomicUsize::new(0), + } + } + + fn status(&self) -> (bool, bool, Option) { + let terminal_status = self + .process + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .terminal_status + .clone(); + let attached = self + .attachment + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .is_some(); + (terminal_status.is_none(), attached, terminal_status) + } + + fn set_terminal_status(&self, status: TerminalStatus) { + let mut process = self + .process + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if process.terminal_status.is_none() { + process.terminal_status = Some(status); + self.process_changed.notify_all(); + } + } + + fn register_attachment( + &self, + stream: &UnixStream, + force: bool, + ) -> Result<(u64, bool), SupervisorFailure> { + let shutdown = stream + .try_clone() + .map_err(|_| SupervisorFailure::Internal)?; + let mut slot = self + .attachment + .lock() + .map_err(|_| SupervisorFailure::Internal)?; + let force_evicted = if let Some(existing) = slot.as_ref() { + if !force { + return Err(SupervisorFailure::AlreadyAttached); + } + let _ = existing.shutdown.shutdown(std::net::Shutdown::Both); + true + } else { + false + }; + let mut generation = self.next_attachment.fetch_add(1, Ordering::AcqRel) + 1; + if generation == 0 { + self.next_attachment.store(1, Ordering::Release); + generation = 1; + } + *slot = Some(AttachmentSlot { + generation, + shutdown, + }); + Ok((generation, force_evicted)) + } + + fn attachment_is_current(&self, generation: u64) -> bool { + self.attachment + .lock() + .map(|slot| { + slot.as_ref() + .is_some_and(|active| active.generation == generation) + }) + .unwrap_or(false) + } + + fn clear_attachment(&self, generation: u64) -> bool { + let Ok(mut slot) = self.attachment.lock() else { + return false; + }; + if slot + .as_ref() + .is_some_and(|active| active.generation == generation) + { + slot.take(); + true + } else { + false + } + } + + fn detach_current(&self) -> bool { + let Ok(mut slot) = self.attachment.lock() else { + return false; + }; + let Some(active) = slot.take() else { + return false; + }; + let _ = active.shutdown.shutdown(std::net::Shutdown::Both); + true + } + + fn close_master(&self) { + if let Ok(mut master) = self.master.lock() { + master.take(); + } + self.ring.close(); + } + + fn close_for_kill(&self) { + self.detach_current(); + self.close_master(); + } + + fn finish_kill(&self) { + self.kill_requested.store(true, Ordering::Release); + let mut wakeup = &self.kill_wakeup; + let _ = wakeup.write(&[1]); + self.process_changed.notify_all(); + } + + fn resize(&self, rows: u32, cols: u32) -> Result<(), HelperFailureCode> { + let rows = u16::try_from(rows).map_err(|_| HelperFailureCode::InvalidTerminalSize)?; + let cols = u16::try_from(cols).map_err(|_| HelperFailureCode::InvalidTerminalSize)?; + if rows == 0 || cols == 0 { + return Err(HelperFailureCode::InvalidTerminalSize); + } + let master = self + .master + .lock() + .map_err(|_| HelperFailureCode::Internal)?; + let master = master.as_ref().ok_or(HelperFailureCode::TerminalClosed)?; + tcsetwinsize( + master, + Winsize { + ws_row: rows, + ws_col: cols, + ws_xpixel: 0, + ws_ypixel: 0, + }, + ) + .map_err(|_| HelperFailureCode::TerminalClosed) + } + + fn write_input(&self, input: &[u8]) -> Result<(usize, bool), HelperFailureCode> { + if self.stdin_closed.load(Ordering::Acquire) { + return Err(HelperFailureCode::TerminalClosed); + } + let master = self + .master + .lock() + .map_err(|_| HelperFailureCode::Internal)?; + let master = master.as_ref().ok_or(HelperFailureCode::TerminalClosed)?; + match fd_write(master, input) { + Ok(written) => Ok((written, written < input.len())), + Err(Errno::AGAIN) => Ok((0, true)), + Err(_) => Err(HelperFailureCode::TerminalClosed), + } + } + + fn close_stdin(&self) -> Result { + if self.stdin_closed.load(Ordering::Acquire) { + return Ok(true); + } + let (written, backpressured) = self.write_input(&[0x04])?; + if written == 1 && !backpressured { + self.stdin_closed.store(true, Ordering::Release); + Ok(true) + } else { + Ok(false) + } + } + + fn wait(&self, timeout: Duration) -> TerminalWaitResult { + let process = self + .process + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if process.terminal_status.is_some() || timeout.is_zero() { + return TerminalWaitResult { + running: process.terminal_status.is_none(), + terminal_status: process.terminal_status.clone(), + }; + } + let (process, _) = self + .process_changed + .wait_timeout(process, timeout) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + TerminalWaitResult { + running: process.terminal_status.is_none(), + terminal_status: process.terminal_status.clone(), + } + } +} + +pub(crate) fn run_shell_supervisor() -> Result<(), ShellSupervisorError> { + let spec = read_supervisor_spec()?; + let uid = get_current_uid(); + let user = get_user_by_uid(uid).ok_or(ShellSupervisorError::InvalidSpec)?; + if spec.initial_rows == 0 + || spec.initial_cols == 0 + || spec.output_ring_bytes == 0 + || spec.output_ring_bytes > DEFAULT_SHELL_OUTPUT_RING_BYTES + || !spec.cwd.is_absolute() + || user.home_dir() != spec.cwd + { + return Err(ShellSupervisorError::InvalidSpec); + } + let listener = OwnedShellListener::bind(&spec.runtime_directory, &spec.supervisor_id, uid) + .map_err(|_| ShellSupervisorError::RuntimeUnavailable)?; + listener + .listener() + .set_nonblocking(true) + .map_err(|_| ShellSupervisorError::RuntimeUnavailable)?; + let (master, mut child) = spawn_login_shell(&spec)?; + ioctl_fionbio(&master, true).map_err(|_| ShellSupervisorError::RuntimeUnavailable)?; + let ring = + Arc::new(OutputRing::new(spec.output_ring_bytes).ok_or(ShellSupervisorError::InvalidSpec)?); + let (kill_wakeup_read, kill_wakeup_write) = + UnixStream::pair().map_err(|_| ShellSupervisorError::RuntimeUnavailable)?; + kill_wakeup_read + .set_nonblocking(true) + .map_err(|_| ShellSupervisorError::RuntimeUnavailable)?; + kill_wakeup_write + .set_nonblocking(true) + .map_err(|_| ShellSupervisorError::RuntimeUnavailable)?; + let state = Arc::new(SupervisorState::new( + master, + Arc::clone(&ring), + kill_wakeup_write, + )); + drop(spec); + start_pty_reader(Arc::clone(&state)); + write_ready(1)?; + + while !state.kill_requested.load(Ordering::Acquire) { + let listener_ready = + poll_listener_or_kill(listener.listener().as_fd(), kill_wakeup_read.as_fd())?; + if state.kill_requested.load(Ordering::Acquire) { + break; + } + if listener_ready { + match listener.listener().accept() { + Ok((stream, _)) => { + if state.control_connections.fetch_add(1, Ordering::AcqRel) + >= MAX_CONTROL_CONNECTIONS + { + state.control_connections.fetch_sub(1, Ordering::AcqRel); + drop(stream); + continue; + } + let worker_state = Arc::clone(&state); + if std::thread::Builder::new() + .name("d2b-shell-control".to_owned()) + .spawn(move || { + handle_supervisor_connection(stream, Arc::clone(&worker_state)); + worker_state + .control_connections + .fetch_sub(1, Ordering::AcqRel); + }) + .is_err() + { + state.control_connections.fetch_sub(1, Ordering::AcqRel); + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} + Err(_) => return Err(ShellSupervisorError::RuntimeUnavailable), + } + } + match child.try_wait() { + Ok(Some(status)) => state.set_terminal_status(exit_status(status)), + Ok(None) => {} + Err(_) => state.set_terminal_status(TerminalStatus::Error { + slug: "wait-failed".to_owned(), + }), + } + } + drop(listener); + let deadline = Instant::now() + Duration::from_millis(250); + while Instant::now() < deadline { + match child.try_wait() { + Ok(Some(status)) => { + state.set_terminal_status(exit_status(status)); + break; + } + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + Err(_) => break, + } + } + Ok(()) +} + +fn poll_listener_or_kill( + listener: std::os::fd::BorrowedFd<'_>, + kill_wakeup: std::os::fd::BorrowedFd<'_>, +) -> Result { + let timeout = PollTimeout::try_from(SUPERVISOR_POLL_TIMEOUT) + .map_err(|_| ShellSupervisorError::RuntimeUnavailable)?; + let interests = PollFlags::POLLIN | PollFlags::POLLERR | PollFlags::POLLHUP; + let mut fds = [ + PollFd::new(listener, interests), + PollFd::new(kill_wakeup, interests), + ]; + loop { + match poll(&mut fds, timeout) { + Ok(_) => break, + Err(nix::errno::Errno::EINTR) => continue, + Err(_) => return Err(ShellSupervisorError::RuntimeUnavailable), + } + } + let kill_events = fds[1].revents().unwrap_or(PollFlags::empty()); + if kill_events.intersects(PollFlags::POLLERR | PollFlags::POLLNVAL) { + return Err(ShellSupervisorError::RuntimeUnavailable); + } + if kill_events.intersects(PollFlags::POLLIN | PollFlags::POLLHUP) { + return Ok(false); + } + let listener_events = fds[0].revents().unwrap_or(PollFlags::empty()); + if listener_events.intersects(PollFlags::POLLERR | PollFlags::POLLNVAL) { + return Err(ShellSupervisorError::RuntimeUnavailable); + } + Ok(listener_events.intersects(PollFlags::POLLIN | PollFlags::POLLHUP)) +} + +fn poll_readable(fd: std::os::fd::BorrowedFd<'_>) -> Result { + let timeout = PollTimeout::try_from(SUPERVISOR_POLL_TIMEOUT) + .map_err(|_| ShellSupervisorError::RuntimeUnavailable)?; + let mut fds = [PollFd::new( + fd, + PollFlags::POLLIN | PollFlags::POLLERR | PollFlags::POLLHUP, + )]; + loop { + match poll(&mut fds, timeout) { + Ok(_) => break, + Err(nix::errno::Errno::EINTR) => continue, + Err(_) => return Err(ShellSupervisorError::RuntimeUnavailable), + } + } + let events = fds[0].revents().unwrap_or(PollFlags::empty()); + if events.intersects(PollFlags::POLLERR | PollFlags::POLLNVAL) { + return Err(ShellSupervisorError::RuntimeUnavailable); + } + Ok(events.intersects(PollFlags::POLLIN | PollFlags::POLLHUP)) +} + +fn start_pty_reader(state: Arc) { + let reader = { + let master = state + .master + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + master + .as_ref() + .and_then(|master| fcntl_dupfd_cloexec(master, 3).ok()) + }; + let Some(reader) = reader else { + state.ring.close(); + return; + }; + let _ = std::thread::Builder::new() + .name("d2b-shell-pty-reader".to_owned()) + .spawn(move || { + let mut buffer = [0u8; 16 * 1024]; + loop { + if state.kill_requested.load(Ordering::Acquire) { + break; + } + match poll_readable(reader.as_fd()) { + Ok(false) => continue, + Ok(true) => {} + Err(_) => break, + } + match fd_read(&reader, &mut buffer) { + Ok(0) => break, + Ok(count) => state.ring.append(&buffer[..count]), + Err(Errno::AGAIN) | Err(Errno::INTR) => {} + Err(_) => break, + } + } + state.ring.close(); + }); +} + +fn read_supervisor_spec() -> Result { + let mut prefix = [0u8; 4]; + std::io::stdin() + .read_exact(&mut prefix) + .map_err(|_| ShellSupervisorError::InvalidSpec)?; + let length = u32::from_le_bytes(prefix) as usize; + if length == 0 || length > MAX_SUPERVISOR_SPEC_BYTES { + return Err(ShellSupervisorError::InvalidSpec); + } + let mut body = vec![0u8; length]; + std::io::stdin() + .read_exact(&mut body) + .map_err(|_| ShellSupervisorError::InvalidSpec)?; + let spec = serde_json::from_slice(&body).map_err(|_| ShellSupervisorError::InvalidSpec)?; + let mut release = [0u8; 1]; + std::io::stdin() + .read_exact(&mut release) + .map_err(|_| ShellSupervisorError::InvalidSpec)?; + if release != [1] { + return Err(ShellSupervisorError::InvalidSpec); + } + Ok(spec) +} + +fn write_ready(value: u8) -> Result<(), ShellSupervisorError> { + std::io::stdout() + .write_all(&[value]) + .and_then(|()| std::io::stdout().flush()) + .map_err(|_| ShellSupervisorError::RuntimeUnavailable) +} + +fn spawn_login_shell(spec: &ShellSupervisorSpec) -> Result<(OwnedFd, Child), ShellSupervisorError> { + let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC) + .map_err(|_| ShellSupervisorError::SpawnFailed)?; + grantpt(&master).map_err(|_| ShellSupervisorError::SpawnFailed)?; + unlockpt(&master).map_err(|_| ShellSupervisorError::SpawnFailed)?; + let slave_path = ptsname(&master, Vec::new()).map_err(|_| ShellSupervisorError::SpawnFailed)?; + let slave = rustix::fs::open( + &slave_path, + OFlags::RDWR | OFlags::NOCTTY | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(|_| ShellSupervisorError::SpawnFailed)?; + let (status_read, status_write) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC) + .map_err(|_| ShellSupervisorError::SpawnFailed)?; + let executable = std::env::current_exe().map_err(|_| ShellSupervisorError::SpawnFailed)?; + let mut command = Command::new(executable); + command + .arg("--tty-exec") + .arg("--rows") + .arg(spec.initial_rows.to_string()) + .arg("--cols") + .arg(spec.initial_cols.to_string()) + .env_clear() + .envs(&spec.environment) + .current_dir(&spec.cwd) + .stdin(Stdio::from(slave)) + .stdout(Stdio::from(status_write)) + .stderr(Stdio::null()); + let mut child = command + .spawn() + .map_err(|_| ShellSupervisorError::SpawnFailed)?; + drop(command); + + let (sender, receiver) = mpsc::sync_channel(1); + std::thread::Builder::new() + .name("d2b-shell-exec-ready".to_owned()) + .spawn(move || { + let mut status = File::from(status_read); + let mut byte = [0u8; 1]; + let result = status.read(&mut byte); + let _ = sender.send(result); + }) + .map_err(|_| ShellSupervisorError::RuntimeUnavailable)?; + match receiver.recv_timeout(SHELL_SUPERVISOR_READY_TIMEOUT) { + Ok(Ok(0)) => Ok((master, child)), + Ok(Ok(_)) | Ok(Err(_)) | Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + Err(ShellSupervisorError::SpawnFailed) + } + } +} + +fn handle_supervisor_connection(mut stream: UnixStream, state: Arc) { + let Ok(peer) = getsockopt(&stream, PeerCredentials) else { + return; + }; + if peer.uid() != get_current_uid() { + return; + } + if stream + .set_read_timeout(Some(Duration::from_secs(2))) + .and_then(|()| stream.set_write_timeout(Some(Duration::from_secs(2)))) + .is_err() + { + return; + } + let Ok(request) = read_supervisor_frame::(&mut stream) else { + return; + }; + if validate_supervisor_request(&request).is_err() { + return; + } + let result = match request.action { + SupervisorAction::Status => { + let (running, attached, terminal_status) = state.status(); + SupervisorResult::Status { + running, + attached, + terminal_status, + } + } + SupervisorAction::Attach { + force, + initial_terminal_size, + } => { + if !state.status().0 { + SupervisorResult::Rejected { + code: SupervisorFailure::Closed, + } + } else { + match state.register_attachment(&stream, force) { + Ok((generation, force_evicted)) => { + if state + .resize(initial_terminal_size.rows, initial_terminal_size.cols) + .is_err() + { + state.clear_attachment(generation); + SupervisorResult::Rejected { + code: SupervisorFailure::InvalidRequest, + } + } else { + let response = SupervisorResponse { + version: SUPERVISOR_PROTOCOL_VERSION, + request_id: request.request_id, + result: SupervisorResult::Attached { force_evicted }, + }; + if write_supervisor_frame(&mut stream, &response).is_ok() { + if stream + .set_read_timeout(None) + .and_then(|()| { + stream.set_write_timeout(Some(Duration::from_secs(2))) + }) + .is_err() + { + state.clear_attachment(generation); + return; + } + serve_terminal(stream, state, generation); + } else { + state.clear_attachment(generation); + } + return; + } + } + Err(code) => SupervisorResult::Rejected { code }, + } + } + } + SupervisorAction::Detach => SupervisorResult::Detached { + detached: state.detach_current(), + }, + SupervisorAction::Kill => { + state.close_for_kill(); + let response = SupervisorResponse { + version: SUPERVISOR_PROTOCOL_VERSION, + request_id: request.request_id, + result: SupervisorResult::KillAccepted, + }; + let _ = write_supervisor_frame(&mut stream, &response); + state.finish_kill(); + return; + } + }; + let response = SupervisorResponse { + version: SUPERVISOR_PROTOCOL_VERSION, + request_id: request.request_id, + result, + }; + let _ = write_supervisor_frame(&mut stream, &response); +} + +fn serve_terminal(stream: UnixStream, state: Arc, generation: u64) { + let Ok(mut reader) = stream.try_clone() else { + state.clear_attachment(generation); + return; + }; + let mut writer = stream; + let protocol_state = Arc::new(Mutex::new(AttachmentProtocolState::default())); + let (responses_tx, responses_rx) = + mpsc::sync_channel::<(HelperTerminalResponse, bool)>(MAX_TERMINAL_WORKERS * 2); + let writer_thread = std::thread::Builder::new() + .name("d2b-shell-terminal-writer".to_owned()) + .spawn(move || { + while let Ok((response, close)) = responses_rx.recv() { + let Ok(frame) = encode_unsafe_local_terminal_frame(&response) else { + break; + }; + if writer.write_all(&frame).is_err() { + break; + } + if close { + let _ = writer.shutdown(std::net::Shutdown::Both); + break; + } + } + }); + if writer_thread.is_err() { + state.clear_attachment(generation); + return; + } + + let limiter = Arc::clone(&state.terminal_workers); + while state.attachment_is_current(generation) { + let request = match read_terminal_request(&mut reader) { + Ok(request) => request, + Err(_) => break, + }; + let request_id = request.request_id(); + if let Err(code) = request.validate_bounds() { + if responses_tx + .send(( + HelperTerminalResponse::Rejected(HelperTerminalRejected { request_id, code }), + false, + )) + .is_err() + { + break; + } + continue; + } + if !terminal_request_may_run_concurrently(&request) { + let (response, close) = + process_terminal_request(request, &state, &protocol_state, generation); + if responses_tx.send((response, close)).is_err() || close { + break; + } + continue; + } + let (count, available) = &*limiter; + let mut active = count + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + while *active >= MAX_TERMINAL_WORKERS { + active = available + .wait(active) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + } + *active += 1; + drop(active); + + let state = Arc::clone(&state); + let protocol_state = Arc::clone(&protocol_state); + let responses = responses_tx.clone(); + let limiter = Arc::clone(&limiter); + if std::thread::Builder::new() + .name("d2b-shell-terminal-operation".to_owned()) + .spawn(move || { + let (response, close) = if state.attachment_is_current(generation) { + process_terminal_request(request, &state, &protocol_state, generation) + } else { + ( + HelperTerminalResponse::Rejected(HelperTerminalRejected { + request_id, + code: HelperFailureCode::TerminalClosed, + }), + true, + ) + }; + let _ = responses.send((response, close)); + let (count, available) = &*limiter; + if let Ok(mut active) = count.lock() { + *active = active.saturating_sub(1); + available.notify_one(); + } + }) + .is_err() + { + let mut active = count + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *active = active.saturating_sub(1); + available.notify_one(); + break; + } + } + state.clear_attachment(generation); + drop(responses_tx); +} + +fn terminal_request_may_run_concurrently(request: &HelperTerminalRequest) -> bool { + matches!( + request, + HelperTerminalRequest::ReadOutput(_) | HelperTerminalRequest::Wait(_) + ) +} + +fn read_terminal_request( + stream: &mut UnixStream, +) -> Result { + let mut prefix = [0u8; 4]; + stream + .read_exact(&mut prefix) + .map_err(|_| HelperFailureCode::TerminalClosed)?; + let length = u32::from_le_bytes(prefix) as usize; + if length == 0 || length > MAX_UNSAFE_LOCAL_TERMINAL_FRAME_SIZE { + return Err(HelperFailureCode::InvalidRequest); + } + let mut frame = Vec::with_capacity(length + 4); + frame.extend_from_slice(&prefix); + frame.resize(length + 4, 0); + stream + .read_exact(&mut frame[4..]) + .map_err(|_| HelperFailureCode::InvalidRequest)?; + decode_unsafe_local_terminal_frame(&frame) +} + +fn process_terminal_request( + request: HelperTerminalRequest, + state: &SupervisorState, + protocol_state: &Mutex, + generation: u64, +) -> (HelperTerminalResponse, bool) { + let request_id = request.request_id(); + let result = match request { + HelperTerminalRequest::WriteStdin(request) => { + let bytes = match d2b_core::base64_codec::decode(request.chunk_base64.as_str()) { + Ok(bytes) => bytes, + Err(_) => { + return ( + rejected(request_id, HelperFailureCode::InvalidRequest), + false, + ); + } + }; + let mut protocol = match protocol_state.lock() { + Ok(protocol) => protocol, + Err(_) => return (rejected(request_id, HelperFailureCode::Internal), false), + }; + if request.offset != protocol.input_offset { + return ( + rejected(request_id, HelperFailureCode::TerminalOffsetMismatch), + false, + ); + } + match state.write_input(&bytes) { + Ok((accepted, mut backpressured)) => { + protocol.input_offset = protocol.input_offset.saturating_add(accepted as u64); + let mut stdin_closed = state.stdin_closed.load(Ordering::Acquire); + if request.eof && accepted == bytes.len() { + match state.close_stdin() { + Ok(closed) => { + stdin_closed = closed; + backpressured |= !closed; + } + Err(code) => return (rejected(request_id, code), false), + } + } + HelperTerminalResponse::WriteStdin(HelperTerminalOperationResult { + request_id, + result: TerminalWriteStdinResult { + accepted_len: accepted as u64, + next_offset: protocol.input_offset, + backpressured, + stdin_closed, + }, + }) + } + Err(code) => rejected(request_id, code), + } + } + HelperTerminalRequest::ReadOutput(request) => { + let result = match request.stream { + TerminalStream::Stdout => { + let read = state.ring.read( + request.cursor, + request.max_len as usize, + request.wait, + Duration::from_millis(request.timeout_ms), + ); + HelperTerminalReadOutputResult { + data_base64: + match d2b_contracts::unsafe_local_wire::HelperTerminalChunkBase64::new( + d2b_core::base64_codec::encode(&read.data), + ) { + Ok(data) => data, + Err(code) => return (rejected(request_id, code), false), + }, + next_cursor: read.next_cursor, + eof: read.eof, + dropped_bytes: read.dropped_bytes, + truncated: read.truncated, + timed_out: read.timed_out, + } + } + TerminalStream::Stderr => HelperTerminalReadOutputResult { + data_base64: d2b_contracts::unsafe_local_wire::HelperTerminalChunkBase64::new( + "", + ) + .expect("empty base64 is valid"), + next_cursor: request.cursor, + eof: true, + dropped_bytes: 0, + truncated: false, + timed_out: false, + }, + }; + HelperTerminalResponse::ReadOutput(HelperTerminalOperationResult { request_id, result }) + } + HelperTerminalRequest::Resize(request) => { + let mut protocol = match protocol_state.lock() { + Ok(protocol) => protocol, + Err(_) => return (rejected(request_id, HelperFailureCode::Internal), false), + }; + if request.control_sequence <= protocol.control_sequence { + return ( + rejected(request_id, HelperFailureCode::InvalidRequest), + false, + ); + } + match state.resize(request.rows, request.cols) { + Ok(()) => { + protocol.control_sequence = request.control_sequence; + HelperTerminalResponse::Resize(HelperTerminalControlResponse { + request_id, + control_sequence: request.control_sequence, + result: TerminalControlResult { delivered: true }, + }) + } + Err(code) => rejected(request_id, code), + } + } + HelperTerminalRequest::Wait(request) => { + let timeout = request + .timeout_ms + .min(MAX_UNSAFE_LOCAL_TERMINAL_WAIT_TIMEOUT_MS); + HelperTerminalResponse::Wait(HelperTerminalOperationResult { + request_id, + result: state.wait(Duration::from_millis(timeout)), + }) + } + HelperTerminalRequest::CloseStdin(request) => { + let mut protocol = match protocol_state.lock() { + Ok(protocol) => protocol, + Err(_) => return (rejected(request_id, HelperFailureCode::Internal), false), + }; + if request.control_sequence <= protocol.control_sequence { + return ( + rejected(request_id, HelperFailureCode::InvalidRequest), + false, + ); + } + match state.close_stdin() { + Ok(stdin_closed) => { + protocol.control_sequence = request.control_sequence; + HelperTerminalResponse::CloseStdin(HelperTerminalControlResponse { + request_id, + control_sequence: request.control_sequence, + result: TerminalCloseResult { stdin_closed }, + }) + } + Err(code) => rejected(request_id, code), + } + } + HelperTerminalRequest::CloseAttachment(request) => { + let mut protocol = match protocol_state.lock() { + Ok(protocol) => protocol, + Err(_) => return (rejected(request_id, HelperFailureCode::Internal), false), + }; + if request.control_sequence <= protocol.control_sequence { + return ( + rejected(request_id, HelperFailureCode::InvalidRequest), + false, + ); + } + protocol.control_sequence = request.control_sequence; + state.clear_attachment(generation); + return ( + HelperTerminalResponse::CloseAttachment(HelperTerminalControlResponse { + request_id, + control_sequence: request.control_sequence, + result: HelperTerminalAttachmentClosed { + detached: true, + cause: Some(d2b_contracts::public_wire::ShellCloseCause::ClientDetach), + }, + }), + true, + ); + } + }; + (result, false) +} + +fn rejected(request_id: u64, code: HelperFailureCode) -> HelperTerminalResponse { + HelperTerminalResponse::Rejected(HelperTerminalRejected { request_id, code }) +} + +fn exit_status(status: std::process::ExitStatus) -> TerminalStatus { + if let Some(code) = status.code() { + TerminalStatus::Exited { code } + } else if let Some(signal) = status.signal() { + TerminalStatus::Signaled { + signal: signal as u32, + } + } else { + TerminalStatus::Error { + slug: "unknown-exit".to_owned(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use d2b_contracts::terminal_wire::TerminalSize; + use d2b_contracts::unsafe_local_wire::{ + HelperTerminalChunkBase64, HelperTerminalControl, HelperTerminalReadOutput, + HelperTerminalResize, HelperTerminalWait, HelperTerminalWriteStdin, + }; + + #[test] + fn supervisor_spec_debug_redacts_every_sensitive_field() { + let canary = "private-supervisor-canary"; + let spec = ShellSupervisorSpec { + supervisor_id: HelperSupervisorId::new(canary).unwrap(), + runtime_directory: PathBuf::from(format!("/{canary}")), + environment: BTreeMap::from([("PRIVATE".to_owned(), canary.to_owned())]), + cwd: PathBuf::from(format!("/{canary}")), + initial_rows: 24, + initial_cols: 80, + output_ring_bytes: 1024, + }; + assert!(!format!("{spec:?}").contains(canary)); + } + + #[test] + fn input_offsets_and_control_sequences_are_strictly_monotonic() { + let master = + openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC).unwrap(); + let (_kill_wakeup_read, kill_wakeup_write) = UnixStream::pair().unwrap(); + let state = SupervisorState::new( + master, + Arc::new(OutputRing::new(1024).unwrap()), + kill_wakeup_write, + ); + let protocol = Mutex::new(AttachmentProtocolState::default()); + + let mismatch = HelperTerminalRequest::WriteStdin(HelperTerminalWriteStdin { + request_id: 1, + offset: 1, + chunk_base64: HelperTerminalChunkBase64::new("").unwrap(), + eof: false, + }); + assert!(matches!( + process_terminal_request(mismatch, &state, &protocol, 1).0, + HelperTerminalResponse::Rejected(HelperTerminalRejected { + code: HelperFailureCode::TerminalOffsetMismatch, + .. + }) + )); + + let first = HelperTerminalRequest::Resize(HelperTerminalResize { + request_id: 2, + control_sequence: 1, + rows: 24, + cols: 80, + }); + let _ = process_terminal_request(first, &state, &protocol, 1); + let duplicate = HelperTerminalRequest::CloseStdin(HelperTerminalControl { + request_id: 3, + control_sequence: 1, + }); + assert!(matches!( + process_terminal_request(duplicate, &state, &protocol, 1).0, + HelperTerminalResponse::Rejected(HelperTerminalRejected { + code: HelperFailureCode::InvalidRequest, + .. + }) + )); + } + + #[test] + fn only_observation_requests_may_run_concurrently() { + let chunk = d2b_contracts::unsafe_local_wire::HelperTerminalChunkBase64::new("").unwrap(); + assert!(!terminal_request_may_run_concurrently( + &HelperTerminalRequest::WriteStdin(HelperTerminalWriteStdin { + request_id: 1, + offset: 0, + chunk_base64: chunk, + eof: false, + }) + )); + assert!(terminal_request_may_run_concurrently( + &HelperTerminalRequest::ReadOutput(HelperTerminalReadOutput { + request_id: 2, + stream: TerminalStream::Stdout, + cursor: 0, + max_len: 1, + wait: false, + timeout_ms: 0, + }) + )); + assert!(terminal_request_may_run_concurrently( + &HelperTerminalRequest::Wait(HelperTerminalWait { + request_id: 3, + timeout_ms: 0, + }) + )); + assert!(!terminal_request_may_run_concurrently( + &HelperTerminalRequest::Resize(HelperTerminalResize { + request_id: 4, + control_sequence: 1, + rows: 24, + cols: 80, + }) + )); + } + + #[test] + fn supervisor_attach_protocol_carries_no_name_or_path() { + let request = SupervisorRequest { + version: SUPERVISOR_PROTOCOL_VERSION, + request_id: 1, + action: SupervisorAction::Attach { + force: false, + initial_terminal_size: TerminalSize { rows: 24, cols: 80 }, + }, + }; + let encoded = serde_json::to_string(&request).unwrap(); + for forbidden in ["name", "path", "pid", "unit", "argv", "environment"] { + assert!(!encoded.contains(forbidden)); + } + } +} diff --git a/packages/d2b-unsafe-local-helper/src/supervisor_protocol.rs b/packages/d2b-unsafe-local-helper/src/supervisor_protocol.rs new file mode 100644 index 000000000..6d4c8a28e --- /dev/null +++ b/packages/d2b-unsafe-local-helper/src/supervisor_protocol.rs @@ -0,0 +1,181 @@ +use d2b_contracts::terminal_wire::{TerminalSize, TerminalStatus}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; + +pub(crate) const SUPERVISOR_PROTOCOL_VERSION: u32 = 1; +pub(crate) const MAX_SUPERVISOR_FRAME_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SupervisorRequest { + pub version: u32, + pub request_id: u64, + pub action: SupervisorAction, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "op", + content = "args", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub(crate) enum SupervisorAction { + Status, + Attach { + #[serde(default)] + force: bool, + initial_terminal_size: TerminalSize, + }, + Detach, + Kill, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SupervisorResponse { + pub version: u32, + pub request_id: u64, + pub result: SupervisorResult, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + content = "value", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +pub(crate) enum SupervisorResult { + Status { + running: bool, + attached: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + terminal_status: Option, + }, + Attached { + force_evicted: bool, + }, + Detached { + detached: bool, + }, + KillAccepted, + Rejected { + code: SupervisorFailure, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum SupervisorFailure { + InvalidRequest, + AlreadyAttached, + Closed, + Internal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SupervisorProtocolError { + Io, + InvalidFrame, + FrameTooLarge, + VersionMismatch, +} + +pub(crate) fn write_frame( + stream: &mut UnixStream, + value: &T, +) -> Result<(), SupervisorProtocolError> { + let body = serde_json::to_vec(value).map_err(|_| SupervisorProtocolError::InvalidFrame)?; + if body.is_empty() || body.len() > MAX_SUPERVISOR_FRAME_BYTES { + return Err(SupervisorProtocolError::FrameTooLarge); + } + let length = u32::try_from(body.len()).map_err(|_| SupervisorProtocolError::FrameTooLarge)?; + stream + .write_all(&length.to_le_bytes()) + .and_then(|()| stream.write_all(&body)) + .map_err(|_| SupervisorProtocolError::Io) +} + +pub(crate) fn read_frame( + stream: &mut UnixStream, +) -> Result { + let mut prefix = [0u8; 4]; + stream + .read_exact(&mut prefix) + .map_err(|_| SupervisorProtocolError::Io)?; + let length = u32::from_le_bytes(prefix) as usize; + if length == 0 || length > MAX_SUPERVISOR_FRAME_BYTES { + return Err(SupervisorProtocolError::FrameTooLarge); + } + let mut body = vec![0u8; length]; + stream + .read_exact(&mut body) + .map_err(|_| SupervisorProtocolError::Io)?; + serde_json::from_slice(&body).map_err(|_| SupervisorProtocolError::InvalidFrame) +} + +pub(crate) fn validate_request(request: &SupervisorRequest) -> Result<(), SupervisorProtocolError> { + (request.version == SUPERVISOR_PROTOCOL_VERSION) + .then_some(()) + .ok_or(SupervisorProtocolError::VersionMismatch) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> SupervisorRequest { + SupervisorRequest { + version: SUPERVISOR_PROTOCOL_VERSION, + request_id: 7, + action: SupervisorAction::Status, + } + } + + #[test] + fn private_protocol_is_correlated_strict_and_bounded() { + let encoded = serde_json::to_vec(&request()).unwrap(); + let decoded: SupervisorRequest = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(decoded, request()); + + let unknown = br#"{"version":1,"requestId":7,"action":{"op":"status"},"path":"no"}"#; + assert!(serde_json::from_slice::(unknown).is_err()); + let trailing = [encoded.as_slice(), b"{}"].concat(); + assert!(serde_json::from_slice::(&trailing).is_err()); + } + + #[test] + fn stream_decoder_rejects_oversized_and_truncated_frames() { + let (mut writer, mut reader) = UnixStream::pair().unwrap(); + writer + .write_all(&((MAX_SUPERVISOR_FRAME_BYTES + 1) as u32).to_le_bytes()) + .unwrap(); + assert_eq!( + read_frame::(&mut reader), + Err(SupervisorProtocolError::FrameTooLarge) + ); + + let (mut writer, mut reader) = UnixStream::pair().unwrap(); + writer.write_all(&10u32.to_le_bytes()).unwrap(); + writer.write_all(b"short").unwrap(); + drop(writer); + assert_eq!( + read_frame::(&mut reader), + Err(SupervisorProtocolError::Io) + ); + } + + #[test] + fn protocol_version_is_closed() { + let mut old = request(); + old.version = 0; + assert_eq!( + validate_request(&old), + Err(SupervisorProtocolError::VersionMismatch) + ); + } +} diff --git a/packages/d2b-unsafe-local-helper/src/systemd.rs b/packages/d2b-unsafe-local-helper/src/systemd.rs index 9e32c5b29..99886a986 100644 --- a/packages/d2b-unsafe-local-helper/src/systemd.rs +++ b/packages/d2b-unsafe-local-helper/src/systemd.rs @@ -23,6 +23,7 @@ pub enum ScopeError { Timeout, CreateFailed, IdentityMismatch, + NotFound, QueryFailed, StopFailed, } @@ -228,6 +229,10 @@ impl UserScopeManager for SystemdUserScopeManager { state: HelperScopeState::Degraded, identity_matches: false, }), + Err(ScopeError::NotFound) => Ok(ScopeInspection { + state: HelperScopeState::Exited, + identity_matches: true, + }), Err(error) => Err(error), } }) @@ -238,11 +243,16 @@ impl UserScopeManager for SystemdUserScopeManager { if !inspection.identity_matches { return Err(ScopeError::IdentityMismatch); } + if inspection.state == HelperScopeState::Exited { + return Ok(()); + } self.with_connection(|connection| { let manager = Self::manager_proxy(connection)?; manager .call_method("KillUnit", &(scope.unit_name.as_str(), "all", signal)) - .map_err(map_stop_error)?; + .map(|_| ()) + .map_err(map_stop_error) + .or_else(|error| (error == ScopeError::NotFound).then_some(()).ok_or(error))?; Ok(()) }) } @@ -254,9 +264,12 @@ impl UserScopeManager for SystemdUserScopeManager { } self.with_connection(|connection| { let manager = Self::manager_proxy(connection)?; - let _: OwnedObjectPath = manager - .call("StopUnit", &(scope.unit_name.as_str(), "replace")) - .map_err(map_stop_error)?; + let result: Result = + manager.call("StopUnit", &(scope.unit_name.as_str(), "replace")); + result + .map(|_| ()) + .map_err(map_stop_error) + .or_else(|error| (error == ScopeError::NotFound).then_some(()).ok_or(error))?; Ok(()) }) } @@ -281,6 +294,8 @@ fn map_create_error(error: zbus::Error) -> ScopeError { fn map_query_error(error: zbus::Error) -> ScopeError { if is_timeout(&error) { ScopeError::Timeout + } else if is_no_such_unit(&error) { + ScopeError::NotFound } else { ScopeError::QueryFailed } @@ -289,11 +304,21 @@ fn map_query_error(error: zbus::Error) -> ScopeError { fn map_stop_error(error: zbus::Error) -> ScopeError { if is_timeout(&error) { ScopeError::Timeout + } else if is_no_such_unit(&error) { + ScopeError::NotFound } else { ScopeError::StopFailed } } +fn is_no_such_unit(error: &zbus::Error) -> bool { + matches!( + error, + zbus::Error::MethodError(name, _, _) + if name.as_str() == "org.freedesktop.systemd1.NoSuchUnit" + ) +} + fn is_timeout(error: &zbus::Error) -> bool { matches!( error, @@ -315,7 +340,7 @@ where match query() { Ok((scope, HelperScopeState::Starting | HelperScopeState::Active)) => return Ok(scope), Ok(_) => return Err(ScopeError::IdentityMismatch), - Err(ScopeError::QueryFailed | ScopeError::IdentityMismatch) + Err(ScopeError::NotFound | ScopeError::QueryFailed | ScopeError::IdentityMismatch) if Instant::now() < deadline => { std::thread::sleep(retry_interval); diff --git a/packages/d2b-unsafe-local-helper/src/tty_exec.rs b/packages/d2b-unsafe-local-helper/src/tty_exec.rs new file mode 100644 index 000000000..7fd40f401 --- /dev/null +++ b/packages/d2b-unsafe-local-helper/src/tty_exec.rs @@ -0,0 +1,136 @@ +use rustix::io::{fcntl_dupfd_cloexec, write}; +use rustix::process::{ioctl_tiocsctty, setsid}; +use rustix::stdio::{dup2_stderr, dup2_stdout, stdin, stdout}; +use rustix::termios::{Winsize, tcsetwinsize}; +use std::os::unix::process::CommandExt; +use std::process::Command; +use uzers::os::unix::UserExt; +use uzers::{get_current_uid, get_user_by_uid}; + +const STATUS_DUP_MIN_FD: i32 = 10; +const EXIT_NO_STATUS_CHANNEL: i32 = 71; +const EXIT_SETUP_FAILED: i32 = 72; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TtyExecFailure { + Identity = 1, + Setsid = 2, + Ctty = 3, + Winsize = 4, + Dup = 5, + Exec = 6, + Args = 7, + StatusDup = 8, +} + +impl TtyExecFailure { + fn as_byte(self) -> u8 { + self as u8 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TtyExecArgs { + rows: u16, + cols: u16, +} + +impl TtyExecArgs { + fn parse(args: &[String]) -> Option { + match args { + [rows_flag, rows, cols_flag, cols] + if rows_flag == "--rows" && cols_flag == "--cols" => + { + let rows = rows.parse().ok()?; + let cols = cols.parse().ok()?; + (rows > 0 && cols > 0).then_some(Self { rows, cols }) + } + _ => None, + } + } +} + +pub(crate) fn run(args: &[String]) -> i32 { + let Some(args) = TtyExecArgs::parse(args) else { + let _ = write(stdout(), &[TtyExecFailure::Args.as_byte()]); + return 64; + }; + let status = match fcntl_dupfd_cloexec(stdout(), STATUS_DUP_MIN_FD) { + Ok(fd) => fd, + Err(_) => { + let _ = write(stdout(), &[TtyExecFailure::StatusDup.as_byte()]); + return EXIT_NO_STATUS_CHANNEL; + } + }; + let failure = setup_and_exec(args); + let _ = write(&status, &[failure.as_byte()]); + EXIT_SETUP_FAILED +} + +fn setup_and_exec(args: TtyExecArgs) -> TtyExecFailure { + let uid = get_current_uid(); + let Some(user) = get_user_by_uid(uid) else { + return TtyExecFailure::Identity; + }; + let shell = user.shell(); + if !shell.is_absolute() { + return TtyExecFailure::Identity; + } + if setsid().is_err() { + return TtyExecFailure::Setsid; + } + let slave = stdin(); + if ioctl_tiocsctty(slave).is_err() { + return TtyExecFailure::Ctty; + } + let winsize = Winsize { + ws_row: args.rows, + ws_col: args.cols, + ws_xpixel: 0, + ws_ypixel: 0, + }; + if tcsetwinsize(slave, winsize).is_err() { + return TtyExecFailure::Winsize; + } + if dup2_stdout(slave).is_err() || dup2_stderr(slave).is_err() { + return TtyExecFailure::Dup; + } + let mut command = Command::new(shell); + command.arg("-l"); + let _ = command.exec(); + TtyExecFailure::Exec +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(parts: &[&str]) -> Vec { + parts.iter().map(|part| (*part).to_owned()).collect() + } + + #[test] + fn tty_exec_accepts_only_fixed_geometry_arguments() { + assert_eq!( + TtyExecArgs::parse(&args(&["--rows", "24", "--cols", "80"])), + Some(TtyExecArgs { rows: 24, cols: 80 }) + ); + assert!(TtyExecArgs::parse(&args(&["--cols", "80", "--rows", "24"])).is_none()); + assert!( + TtyExecArgs::parse(&args(&["--rows", "24", "--cols", "80", "--", "/bin/sh"])).is_none() + ); + assert!(TtyExecArgs::parse(&args(&["--rows", "0", "--cols", "80"])).is_none()); + } + + #[test] + fn failure_bytes_are_stable() { + assert_eq!(TtyExecFailure::Identity.as_byte(), 1); + assert_eq!(TtyExecFailure::Setsid.as_byte(), 2); + assert_eq!(TtyExecFailure::Ctty.as_byte(), 3); + assert_eq!(TtyExecFailure::Winsize.as_byte(), 4); + assert_eq!(TtyExecFailure::Dup.as_byte(), 5); + assert_eq!(TtyExecFailure::Exec.as_byte(), 6); + assert_eq!(TtyExecFailure::Args.as_byte(), 7); + assert_eq!(TtyExecFailure::StatusDup.as_byte(), 8); + } +} diff --git a/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs b/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs new file mode 100644 index 000000000..7673c4dd7 --- /dev/null +++ b/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs @@ -0,0 +1,642 @@ +use d2b_contracts::terminal_wire::TerminalStream; +use d2b_contracts::unsafe_local_wire::{ + HelperScopeKind, HelperScopeState, HelperShellPolicy, HelperShellRequest, + HelperTerminalChunkBase64, HelperTerminalControl, HelperTerminalReadOutput, + HelperTerminalRequest, HelperTerminalResize, HelperTerminalResponse, HelperTerminalWriteStdin, + UnsafeLocalHelperToDaemon, decode_unsafe_local_terminal_frame, + encode_unsafe_local_terminal_frame, +}; +use d2b_contracts::{public_wire::ShellName, terminal_wire::TerminalSize}; +use d2b_core::base64_codec; +use d2b_core::workload_identity::WorkloadIdentity; +use d2b_realm_core::ids::OperationId; +use d2b_unsafe_local_helper::environment::ManagerEnvironment; +use d2b_unsafe_local_helper::runtime::ScopeRuntime; +use d2b_unsafe_local_helper::systemd::{ + ScopeError, ScopeInspection, UserScopeManager, VerifiedScope, +}; +use nix::libc; +use nix::sys::signal::{Signal, kill}; +use nix::unistd::Pid; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::fs; +use std::io::{Read, Write}; +use std::os::unix::fs::{FileTypeExt, PermissionsExt}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use uzers::os::unix::UserExt; +use uzers::{get_current_uid, get_user_by_uid}; + +const SUPERVISOR_ID: &str = "0123456789abcdef0123456789abcdef"; + +struct Scratch { + path: PathBuf, +} + +impl Scratch { + fn new() -> Self { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("repository root"); + for _ in 0..32 { + let mut random = [0u8; 2]; + getrandom::getrandom(&mut random).unwrap(); + let path = root.join(format!("i{:02x}{:02x}", random[0], random[1])); + if fs::create_dir(&path).is_ok() { + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + return Self { path }; + } + } + panic!("could not reserve repository-local integration directory"); + } + + fn socket(&self) -> PathBuf { + self.path.join(format!(".d2b-shell-{SUPERVISOR_ID}.sock")) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +struct Supervisor { + child: Child, + scratch: Scratch, +} + +impl Supervisor { + fn start() -> Self { + let scratch = Scratch::new(); + let user = get_user_by_uid(get_current_uid()).expect("passwd identity"); + let home = user.home_dir().to_path_buf(); + let mut environment = std::env::vars() + .map(|(key, value)| (key, Value::String(value))) + .collect::>(); + environment.insert( + "D2B_TEST_ENV".to_owned(), + Value::String("manager-env-canary".to_owned()), + ); + let spec = json!({ + "supervisorId": SUPERVISOR_ID, + "runtimeDirectory": scratch.path, + "environment": environment, + "cwd": home, + "initialRows": 24, + "initialCols": 80, + "outputRingBytes": 262144 + }); + let encoded = serde_json::to_vec(&spec).unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_d2b-unsafe-local-helper")) + .arg("shell-supervisor") + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn supervisor"); + let mut stdin = child.stdin.take().unwrap(); + stdin + .write_all(&(encoded.len() as u32).to_le_bytes()) + .unwrap(); + stdin.write_all(&encoded).unwrap(); + stdin.write_all(&[1]).unwrap(); + drop(stdin); + let mut ready = [0u8; 1]; + child + .stdout + .as_mut() + .unwrap() + .read_exact(&mut ready) + .expect("supervisor ready"); + assert_eq!(ready, [1]); + assert!(scratch.socket().exists()); + Self { child, scratch } + } + + fn control(&self, request_id: u64, action: Value) -> (Value, UnixStream) { + let mut stream = UnixStream::connect(self.scratch.socket()).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + write_private_frame( + &mut stream, + &json!({ + "version": 1, + "requestId": request_id, + "action": action + }), + ); + let response = read_private_frame(&mut stream); + assert_eq!(response["version"], 1); + assert_eq!(response["requestId"], request_id); + (response, stream) + } + + fn attach(&self, request_id: u64, force: bool) -> (Value, UnixStream) { + self.control( + request_id, + json!({ + "op": "attach", + "args": { + "force": force, + "initialTerminalSize": {"rows": 24, "cols": 80} + } + }), + ) + } + + fn wait_for_exit(&mut self) { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if self.child.try_wait().unwrap().is_some() { + return; + } + assert!(Instant::now() < deadline, "supervisor did not exit"); + std::thread::sleep(Duration::from_millis(10)); + } + } +} + +impl Drop for Supervisor { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +#[derive(Clone)] +struct FakeScopeManager { + environment: ManagerEnvironment, + active: Arc>>, +} + +impl UserScopeManager for FakeScopeManager { + fn manager_environment(&self) -> Result { + Ok(self.environment.clone()) + } + + fn start_scope( + &self, + supervisor_pid: u32, + kind: HelperScopeKind, + ) -> Result { + if kind != HelperScopeKind::PersistentShell { + return Err(ScopeError::IdentityMismatch); + } + let scope = VerifiedScope { + unit_name: "d2b-shell-test.scope".to_owned(), + invocation_id: "00112233445566778899aabbccddeeff".to_owned(), + control_group: "/user.slice/d2b-shell-test.scope".to_owned(), + kind, + }; + *self.active.lock().unwrap() = Some((supervisor_pid, scope.clone())); + Ok(scope) + } + + fn inspect_scope(&self, scope: &VerifiedScope) -> Result { + let active = self.active.lock().unwrap(); + let Some((pid, expected)) = active.as_ref() else { + return Ok(ScopeInspection { + state: HelperScopeState::Exited, + identity_matches: false, + }); + }; + let identity_matches = expected == scope; + let state = if Path::new(&format!("/proc/{pid}")).exists() { + HelperScopeState::Active + } else { + HelperScopeState::Exited + }; + Ok(ScopeInspection { + state, + identity_matches, + }) + } + + fn terminate_scope(&self, scope: &VerifiedScope, signal: i32) -> Result<(), ScopeError> { + let active = self.active.lock().unwrap(); + let Some((pid, expected)) = active.as_ref() else { + return Ok(()); + }; + if expected != scope { + return Err(ScopeError::IdentityMismatch); + } + let signal = Signal::try_from(signal).map_err(|_| ScopeError::StopFailed)?; + match kill(Pid::from_raw(*pid as i32), Some(signal)) { + Ok(()) | Err(nix::errno::Errno::ESRCH) => Ok(()), + Err(_) => Err(ScopeError::StopFailed), + } + } + + fn stop_scope(&self, scope: &VerifiedScope) -> Result<(), ScopeError> { + self.terminate_scope(scope, libc::SIGKILL) + } +} + +#[test] +fn real_supervisor_preserves_pty_across_reconnect_and_kills_exact_scope() { + let mut unrelated = Command::new("sleep") + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn unrelated same-uid process"); + let mut supervisor = Supervisor::start(); + + let (attached, mut terminal) = supervisor.attach(1, false); + assert_eq!(attached["result"]["kind"], "attached"); + assert_eq!(attached["result"]["value"]["forceEvicted"], false); + terminal + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + terminal + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + + let home = get_user_by_uid(get_current_uid()) + .unwrap() + .home_dir() + .to_path_buf(); + let command = b"if test -t 0; then printf 'D2B_RESULT:%s:%s:tty\\n' \"$D2B_TEST_ENV\" \"$PWD\"; else printf 'D2B_RESULT:notty\\n'; fi\n"; + let write = HelperTerminalRequest::WriteStdin(HelperTerminalWriteStdin { + request_id: 10, + offset: 0, + chunk_base64: HelperTerminalChunkBase64::new(base64_codec::encode(command)).unwrap(), + eof: false, + }); + write_terminal_frame(&mut terminal, &write); + assert!(matches!( + read_terminal_frame(&mut terminal), + HelperTerminalResponse::WriteStdin(_) + )); + let expected = format!( + "D2B_RESULT:manager-env-canary:{}:tty", + home.to_string_lossy() + ); + let (mut cursor, output) = read_until(&mut terminal, 11, 0, expected.as_bytes()); + assert!( + output + .windows(expected.len()) + .any(|window| window == expected.as_bytes()), + "login shell did not inherit manager environment, passwd cwd, and PTY" + ); + + write_terminal_frame( + &mut terminal, + &HelperTerminalRequest::Resize(HelperTerminalResize { + request_id: 12, + control_sequence: 1, + rows: 41, + cols: 101, + }), + ); + assert!(matches!( + read_terminal_frame(&mut terminal), + HelperTerminalResponse::Resize(_) + )); + let geometry_command = b"stty size\n"; + write_terminal_frame( + &mut terminal, + &HelperTerminalRequest::WriteStdin(HelperTerminalWriteStdin { + request_id: 13, + offset: command.len() as u64, + chunk_base64: HelperTerminalChunkBase64::new(base64_codec::encode(geometry_command)) + .unwrap(), + eof: false, + }), + ); + let _ = read_terminal_frame(&mut terminal); + let (next_cursor, geometry) = read_until(&mut terminal, 14, cursor, b"41 101"); + cursor = next_cursor; + assert!(geometry.windows(6).any(|window| window == b"41 101")); + + write_terminal_frame( + &mut terminal, + &HelperTerminalRequest::CloseAttachment(HelperTerminalControl { + request_id: 15, + control_sequence: 2, + }), + ); + assert!(matches!( + read_terminal_frame(&mut terminal), + HelperTerminalResponse::CloseAttachment(_) + )); + drop(terminal); + + let (status, _) = supervisor.control(2, json!({"op": "status"})); + assert_eq!(status["result"]["kind"], "status"); + assert_eq!(status["result"]["value"]["running"], true); + assert_eq!(status["result"]["value"]["attached"], false); + + let (_, old_terminal) = supervisor.attach(3, false); + let (rejected, _) = supervisor.attach(4, false); + assert_eq!(rejected["result"]["kind"], "rejected"); + assert_eq!(rejected["result"]["value"]["code"], "already-attached"); + let (forced, _new_terminal) = supervisor.attach(5, true); + assert_eq!(forced["result"]["kind"], "attached"); + assert_eq!(forced["result"]["value"]["forceEvicted"], true); + old_terminal + .set_read_timeout(Some(Duration::from_millis(200))) + .unwrap(); + let mut byte = [0u8; 1]; + assert!(old_terminal.take_error().unwrap().is_none()); + assert!(matches!((&old_terminal).read(&mut byte), Ok(0) | Err(_))); + + let (kill, _) = supervisor.control(6, json!({"op": "kill"})); + assert_eq!(kill["result"]["kind"], "killAccepted"); + supervisor.wait_for_exit(); + assert!(!supervisor.scratch.socket().exists()); + assert!( + unrelated.try_wait().unwrap().is_none(), + "shell kill affected unrelated same-uid process" + ); + let _ = unrelated.kill(); + let _ = unrelated.wait(); + + let _ = cursor; +} + +#[test] +fn helper_runtime_creates_persists_and_reconstructs_real_supervisor() { + let scratch = Scratch::new(); + exercise_helper_runtime_reconstruction(&scratch, "single"); +} + +#[test] +fn repeated_missing_socket_kill_cleans_scope_ledger() { + let scratch = Scratch::new(); + for iteration in 0..8 { + exercise_helper_runtime_reconstruction(&scratch, &format!("stress-{iteration}")); + } +} + +fn exercise_helper_runtime_reconstruction(scratch: &Scratch, operation_suffix: &str) { + if get_current_uid() == 0 { + return; + } + let user = get_user_by_uid(get_current_uid()).unwrap(); + let mut environment = BTreeMap::from([ + ( + "PATH".to_owned(), + std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_owned()), + ), + ( + "HOME".to_owned(), + user.home_dir().to_string_lossy().into_owned(), + ), + ]); + environment.insert( + "XDG_RUNTIME_DIR".to_owned(), + scratch.path.display().to_string(), + ); + environment.insert("D2B_TEST_ENV".to_owned(), "manager-env-canary".to_owned()); + environment.insert("TERM".to_owned(), "dumb".to_owned()); + environment.insert("COLORTERM".to_owned(), "manager-value".to_owned()); + let manager = FakeScopeManager { + environment: ManagerEnvironment::parse( + environment + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(), + ) + .unwrap(), + active: Arc::new(Mutex::new(None)), + }; + let ledger = scratch.path.join("ledger.json"); + let binary = PathBuf::from(env!("CARGO_BIN_EXE_d2b-unsafe-local-helper")); + let runtime = ScopeRuntime::with_paths_and_executable( + manager.clone(), + user.home_dir().to_path_buf(), + ledger.clone(), + binary.clone(), + ) + .unwrap(); + let workload = workload(); + let create_operation = format!("op-runtime-create-{operation_suffix}"); + let created = runtime + .shell(attach_request(&create_operation, workload.clone(), false)) + .unwrap(); + let (frame, fd) = created.into_parts(); + assert!(matches!(frame, UnsafeLocalHelperToDaemon::TerminalReady(_))); + let mut terminal = UnixStream::from(fd.expect("terminal fd")); + terminal + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let command = b"printf 'D2B_RUNTIME_ENV:%s:%s\\n' \"$TERM\" \"$COLORTERM\"; if test -n \"$BASH_VERSION\"; then case $- in *i*) d2b_mode=interactive ;; *) d2b_mode=noninteractive ;; esac; if shopt -q login_shell; then d2b_login=login; else d2b_login=nonlogin; fi; printf 'D2B_RUNTIME_BASH:%s:%s\\n' \"$d2b_mode\" \"$d2b_login\"; fi\n"; + write_terminal_frame( + &mut terminal, + &HelperTerminalRequest::WriteStdin(HelperTerminalWriteStdin { + request_id: 1, + offset: 0, + chunk_base64: HelperTerminalChunkBase64::new(base64_codec::encode(command)).unwrap(), + eof: false, + }), + ); + let _ = read_terminal_frame(&mut terminal); + let bash_login_shell = user.shell().file_name().and_then(|name| name.to_str()) == Some("bash"); + let needle = if bash_login_shell { + b"D2B_RUNTIME_BASH:interactive:login".as_slice() + } else { + b"D2B_RUNTIME_ENV:xterm-256color:truecolor".as_slice() + }; + let (_, output) = read_until(&mut terminal, 2, 0, needle); + assert!( + output + .windows(b"D2B_RUNTIME_ENV:xterm-256color:truecolor".len()) + .any(|window| window == b"D2B_RUNTIME_ENV:xterm-256color:truecolor"), + "persistent shell inherited non-terminal manager TERM or COLORTERM" + ); + if bash_login_shell { + assert!( + output + .windows(b"D2B_RUNTIME_BASH:interactive:login".len()) + .any(|window| window == b"D2B_RUNTIME_BASH:interactive:login"), + "Bash persistent shell was not interactive and login-mode" + ); + } + drop(terminal); + + let reconstructed = ScopeRuntime::with_paths_and_executable( + manager, + user.home_dir().to_path_buf(), + ledger, + binary, + ) + .unwrap(); + let snapshot = reconstructed.snapshot(11).unwrap(); + assert_eq!(snapshot.scopes.len(), 1); + assert!(snapshot.scopes[0].persistent_shell.is_some()); + let reattach_operation = format!("op-runtime-reattach-{operation_suffix}"); + let reattached = reconstructed + .shell(attach_request(&reattach_operation, workload.clone(), true)) + .unwrap(); + let (_, fd) = reattached.into_parts(); + drop(fd); + + let socket = shell_socket(&scratch.path).expect("supervisor socket"); + fs::remove_file(socket).unwrap(); + assert!( + !has_shell_socket(&scratch.path), + "missing-socket kill precondition was not established" + ); + let kill_operation = format!("op-runtime-kill-{operation_suffix}"); + let killed = reconstructed + .shell(HelperShellRequest::Kill { + request_id: 4, + operation_id: OperationId::parse(kill_operation).unwrap(), + workload, + policy: shell_policy(), + name: ShellName::new("host").unwrap(), + }) + .unwrap(); + assert!(matches!( + killed.into_parts().0, + UnsafeLocalHelperToDaemon::Shell(_) + )); + assert!(reconstructed.snapshot(12).unwrap().scopes.is_empty()); + let deadline = Instant::now() + Duration::from_secs(5); + while has_shell_socket(&scratch.path) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + !has_shell_socket(&scratch.path), + "shell supervisor socket survived the cleanup deadline" + ); +} + +fn workload() -> WorkloadIdentity { + serde_json::from_value(json!({ + "workloadId": "tools", + "realmId": "host", + "realmPath": ["host"], + "canonicalTarget": "tools.host.d2b" + })) + .unwrap() +} + +fn shell_policy() -> HelperShellPolicy { + HelperShellPolicy { + default_name: ShellName::new("host").unwrap(), + max_sessions: 2, + } +} + +fn attach_request( + operation_id: &str, + workload: WorkloadIdentity, + force: bool, +) -> HelperShellRequest { + HelperShellRequest::Attach { + request_id: 1, + operation_id: OperationId::parse(operation_id).unwrap(), + workload, + policy: shell_policy(), + name: Some(ShellName::new("host").unwrap()), + force, + initial_terminal_size: TerminalSize { rows: 24, cols: 80 }, + } +} + +fn has_shell_socket(directory: &Path) -> bool { + fs::read_dir(directory) + .into_iter() + .flatten() + .filter_map(Result::ok) + .any(|entry| { + entry + .file_type() + .map(|file_type| file_type.is_socket()) + .unwrap_or(false) + }) +} + +fn shell_socket(directory: &Path) -> Option { + fs::read_dir(directory) + .into_iter() + .flatten() + .filter_map(Result::ok) + .find_map(|entry| { + entry + .file_type() + .ok() + .filter(|file_type| file_type.is_socket()) + .map(|_| entry.path()) + }) +} + +fn write_private_frame(stream: &mut UnixStream, value: &Value) { + let body = serde_json::to_vec(value).unwrap(); + stream + .write_all(&(body.len() as u32).to_le_bytes()) + .unwrap(); + stream.write_all(&body).unwrap(); +} + +fn read_private_frame(stream: &mut UnixStream) -> Value { + let mut prefix = [0u8; 4]; + stream.read_exact(&mut prefix).unwrap(); + let length = u32::from_le_bytes(prefix) as usize; + assert!(length <= 16 * 1024); + let mut body = vec![0u8; length]; + stream.read_exact(&mut body).unwrap(); + serde_json::from_slice(&body).unwrap() +} + +fn write_terminal_frame(stream: &mut UnixStream, request: &HelperTerminalRequest) { + let frame = encode_unsafe_local_terminal_frame(request).unwrap(); + stream.write_all(&frame).unwrap(); +} + +fn read_terminal_frame(stream: &mut UnixStream) -> HelperTerminalResponse { + let mut prefix = [0u8; 4]; + stream.read_exact(&mut prefix).unwrap(); + let length = u32::from_le_bytes(prefix) as usize; + let mut frame = Vec::with_capacity(length + 4); + frame.extend_from_slice(&prefix); + frame.resize(length + 4, 0); + stream.read_exact(&mut frame[4..]).unwrap(); + decode_unsafe_local_terminal_frame(&frame).unwrap() +} + +fn read_until( + stream: &mut UnixStream, + mut request_id: u64, + mut cursor: u64, + needle: &[u8], +) -> (u64, Vec) { + let mut output = Vec::new(); + for _ in 0..8 { + write_terminal_frame( + stream, + &HelperTerminalRequest::ReadOutput(HelperTerminalReadOutput { + request_id, + stream: TerminalStream::Stdout, + cursor, + max_len: 65_536, + wait: true, + timeout_ms: 1_000, + }), + ); + let HelperTerminalResponse::ReadOutput(response) = read_terminal_frame(stream) else { + panic!("unexpected terminal response"); + }; + let data = base64_codec::decode(response.result.data_base64.as_str()).unwrap(); + output.extend_from_slice(&data); + cursor = response.result.next_cursor; + if output.windows(needle.len()).any(|window| window == needle) { + return (cursor, output); + } + request_id += 1; + } + (cursor, output) +} diff --git a/packages/d2b-wayland-proxy/src/terminal.rs b/packages/d2b-wayland-proxy/src/terminal.rs index 5f2aa417c..13bbf1188 100644 --- a/packages/d2b-wayland-proxy/src/terminal.rs +++ b/packages/d2b-wayland-proxy/src/terminal.rs @@ -21,6 +21,7 @@ use rustix::{ const TERMINAL_RUNTIME_DIR: &str = "d2b-wayland-proxy"; const SOCKET_MODE: u32 = 0o600; const DIR_MODE: u32 = 0o700; +const MAX_UNIX_SOCKET_PATH_BYTES: usize = 107; #[derive(Debug)] pub struct TerminalRuntime { @@ -70,13 +71,10 @@ impl TerminalRuntime { validate_identity_component(identity_component)?; validate_token(token)?; ensure_runtime_parent(runtime_dir)?; - let root = runtime_dir - .join(TERMINAL_RUNTIME_DIR) - .join(identity_component); + let (root, listen_socket, mux_socket) = + terminal_runtime_paths(runtime_dir, identity_component, token)?; ensure_private_dir(&root)?; - let listen_socket = root.join(format!("wayland-{token}.sock")); - let mux_socket = root.join(format!("wezterm-mux-{token}.sock")); unlink_stale_socket(&listen_socket)?; unlink_stale_socket(&mux_socket)?; @@ -89,6 +87,27 @@ impl TerminalRuntime { } } +fn terminal_runtime_paths( + runtime_dir: &Path, + identity_component: &str, + token: &str, +) -> io::Result<(PathBuf, PathBuf, PathBuf)> { + let root = runtime_dir + .join(TERMINAL_RUNTIME_DIR) + .join(identity_component); + let listen_socket = root.join(format!("w-{token}")); + let mux_socket = root.join(format!("m-{token}")); + for path in [&listen_socket, &mux_socket] { + if path.as_os_str().as_encoded_bytes().len() > MAX_UNIX_SOCKET_PATH_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "terminal runtime socket path exceeds the Unix socket limit", + )); + } + } + Ok((root, listen_socket, mux_socket)) +} + impl Drop for TerminalRuntime { fn drop(&mut self) { let _ = fs::remove_file(&self.listen_socket); @@ -299,18 +318,38 @@ mod tests { runtime.listen_socket(), root.join(TERMINAL_RUNTIME_DIR) .join("work") - .join("wayland-abc123.sock") + .join("w-abc123") ); assert_eq!( runtime.mux_socket(), root.join(TERMINAL_RUNTIME_DIR) .join("work") - .join("wezterm-mux-abc123.sock") + .join("m-abc123") ); drop(runtime); let _ = fs::remove_dir_all(root); } + #[test] + fn terminal_runtime_bounds_canonical_target_socket_paths() { + let (_, listen_socket, mux_socket) = terminal_runtime_paths( + Path::new("/run/user/1000"), + "endpoint-fc002cd9909aab17c2232e85", + "00112233445566778899aabbccddeeff", + ) + .expect("canonical target paths"); + assert!(listen_socket.as_os_str().as_encoded_bytes().len() <= MAX_UNIX_SOCKET_PATH_BYTES); + assert!(mux_socket.as_os_str().as_encoded_bytes().len() <= MAX_UNIX_SOCKET_PATH_BYTES); + } + + #[test] + fn terminal_runtime_rejects_socket_paths_beyond_sun_path() { + let runtime_dir = PathBuf::from("/").join("r".repeat(MAX_UNIX_SOCKET_PATH_BYTES)); + let error = terminal_runtime_paths(&runtime_dir, "work", "abc123") + .expect_err("oversized socket path"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + } + #[test] fn terminal_runtime_unlinks_stale_socket_but_not_regular_file() { let root = test_root("stale"); diff --git a/packages/d2b/src/lib.rs b/packages/d2b/src/lib.rs index acae36f8c..ab9d7010e 100644 --- a/packages/d2b/src/lib.rs +++ b/packages/d2b/src/lib.rs @@ -100,7 +100,7 @@ const EXIT_GUEST_CONTROL_CONFIG: i32 = 70; #[command( version, about = "d2b — opinionated NixOS desktop microVM CLI.", - long_about = "d2b — daemon-native CLI for d2b microVMs.\n\nAll mutating verbs dispatch through d2bd and d2b-priv-broker. \ + long_about = "d2b — daemon-native CLI for d2b microVMs.\n\nMutating verbs dispatch through d2bd; privileged host mutations additionally use d2b-priv-broker. \ Read-only verbs (list, status, audit, host check) prefer d2bd's \ public socket and fall back to static/local sources where documented. \ See `d2b --help` for per-verb usage." @@ -116,6 +116,8 @@ enum NativeCommand { List(ListArgs), /// Show per-VM runtime status plus bridge health. Status(StatusArgs), + /// Launch a trusted configured workload item through its runtime provider. + Launch(LaunchArgs), /// USB attach / detach / probe. Usb(UsbArgs), /// Foreground serial console bridge for headless VMs. @@ -178,6 +180,21 @@ enum NativeCommand { Clipboard(ClipboardArgs), } +#[derive(Debug, Args)] +struct LaunchArgs { + /// Canonical workload target or an unambiguous workload id. + target: String, + /// Configured launcher item id. Omit to use the declared default or sole item. + #[arg(long)] + item: Option, + /// Emit a structured JSON result. + #[arg(long, conflicts_with = "human")] + json: bool, + /// Force human-readable output. + #[arg(long, conflicts_with = "json")] + human: bool, +} + #[derive(Debug, Args)] struct ListArgs { #[arg(long, conflicts_with = "human")] @@ -1492,6 +1509,15 @@ struct ShellResponseFrame { payload: ShellOpResponse, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkloadResponseFrame { + #[serde(rename = "type")] + _type_name: String, + #[serde(flatten)] + payload: public_wire::WorkloadOpResponse, +} + #[derive(Debug, Clone)] enum AuditSocketOutcome { Unreachable, @@ -1568,7 +1594,11 @@ where fn daemon_supported_features() -> Vec { vec![ KnownFeatureFlag::TypedErrors.wire_value(), + KnownFeatureFlag::StatusCheckBridges.wire_value(), KnownFeatureFlag::ExportBrokerAudit.wire_value(), + KnownFeatureFlag::ConfiguredLaunchV1.wire_value(), + KnownFeatureFlag::UnsafeLocalProviderV1.wire_value(), + KnownFeatureFlag::UnsafeLocalShellV1.wire_value(), ] } @@ -1817,11 +1847,19 @@ fn cmd_shell(context: &Context, args: &ShellArgs) -> Result { } } let json_mode = !matches!(action, ShellAction::Attach) && args.json; - let local_vm = match route_vm_target(context, &args.vm, json_mode)? { - VmTargetRoute::Local { vm } => vm, - VmTargetRoute::Gateway { - realm, gateway_vm, .. - } => return cmd_gateway_shell(context, args, action, realm, gateway_vm), + let direct_target = if args.vm.ends_with(".d2b") && context.public_socket.exists() { + try_resolve_direct_shell_target(context, &args.vm)? + } else { + None + }; + let local_vm = match direct_target { + Some(target) => target, + None => match route_vm_target(context, &args.vm, json_mode)? { + VmTargetRoute::Local { vm } => vm, + VmTargetRoute::Gateway { + realm, gateway_vm, .. + } => return cmd_gateway_shell(context, args, action, realm, gateway_vm), + }, }; match action { ShellAction::Attach => { @@ -1977,7 +2015,7 @@ fn cmd_shell_attach( })?; let mut transport = shell_owner_transport(context)?; let size = exec_client::current_window_size() - .map(|(rows, cols)| d2b_contracts::terminal_wire::TerminalSize { rows, cols }) + .and_then(shell_terminal_size) .unwrap_or(d2b_contracts::terminal_wire::TerminalSize { rows: 24, cols: 80 }); let start = transport.round_trip(&ShellOp::Attach(public_wire::ShellAttachArgs { vm: vm.to_owned(), @@ -2002,6 +2040,13 @@ fn cmd_shell_attach( Ok(0) } +fn shell_terminal_size( + (rows, cols): (u32, u32), +) -> Option { + ((1..=65_535).contains(&rows) && (1..=65_535).contains(&cols)) + .then_some(d2b_contracts::terminal_wire::TerminalSize { rows, cols }) +} + struct ShellOwnerTransport { socket: SeqpacketUnixSocket, next_op_id: u64, @@ -2084,9 +2129,13 @@ where fn is_close_attach_transport_unavailable(err: &CliFailure) -> bool { err.exit_code == 69 - && err + && (err .message .contains("guest-control-shell-transport-unavailable") + || err.message.contains("unsafe-local-shell-terminal-closed") + || err + .message + .contains("unsafe-local-shell-helper-unavailable")) } fn run_shell_fsm( @@ -2472,6 +2521,7 @@ fn dispatch( match &cli.command { NativeCommand::List(args) => cmd_list(context, args), NativeCommand::Status(args) => cmd_status(context, args), + NativeCommand::Launch(args) => cmd_launch(context, args), NativeCommand::Usb(args) => match &args.command { UsbCommand::Attach(args) => cmd_usb_attach(context, args), UsbCommand::Detach(args) => cmd_usb_detach(context, args), @@ -3557,6 +3607,497 @@ fn cmd_config_status(args: &ConfigStatusArgs) -> Result { Ok(0) } +fn cmd_launch(context: &Context, args: &LaunchArgs) -> Result { + use d2b_realm_core::{LauncherItemKind, ProtocolToken}; + + if !context.public_socket.exists() { + return Err(CliFailure::new( + 69, + "launch requires the d2bd public socket; no static or provider fallback is permitted", + )); + } + let mut socket = SeqpacketUnixSocket::connect(&context.public_socket).map_err(|error| { + CliFailure::new( + 69, + format!("failed to connect to the d2bd public socket: {error}"), + ) + })?; + socket + .send_frame(&daemon_hello_frame("hello")?) + .map_err(|error| CliFailure::new(69, format!("failed to send hello frame: {error}")))?; + let hello = socket + .recv_frame() + .map_err(|error| CliFailure::new(69, format!("failed to receive hello reply: {error}")))?; + let negotiated = parse_hello_reply(&hello)?; + require_launch_features(&negotiated.capabilities, None)?; + + let list = public_wire::WorkloadOp::List(public_wire::WorkloadListArgs::default()); + let list_response = workload_socket_exchange(&mut socket, &list, "workload list")?; + let public_wire::WorkloadOpResponse::List(list_result) = list_response else { + return Err(CliFailure::new( + 76, + "daemon returned the wrong workload response to list", + )); + }; + let workload = select_launch_workload(list_result.workloads, &args.target)?; + require_launch_features(&negotiated.capabilities, Some(workload.provider_kind))?; + let item = select_launcher_item(&workload, args.item.as_deref())?; + + if item.kind == LauncherItemKind::Shell { + let vm = shell_launch_target(&workload, &negotiated.capabilities)?; + return cmd_shell( + context, + &ShellArgs { + vm, + action: Some(ShellAction::Attach), + name: None, + force: false, + json: args.json, + human: args.human, + }, + ); + } + + let item_id = ProtocolToken::parse(item.id.as_str().to_owned()) + .map_err(|_| CliFailure::new(70, "trusted launcher item id is invalid"))?; + let operation_id = new_launch_operation_id()?; + let target = workload.identity.canonical_target.clone(); + let launch = public_wire::WorkloadOp::LauncherExec(public_wire::LauncherExecArgs { + target: target.clone(), + item_id: item_id.clone(), + operation_id: operation_id.clone(), + }); + let response = workload_socket_exchange(&mut socket, &launch, "launcher exec")?; + let public_wire::WorkloadOpResponse::LauncherExec(result) = response else { + return Err(CliFailure::new( + 76, + "daemon returned the wrong workload response to launcher exec", + )); + }; + let output = LaunchOutputV1 { + command: "launch".to_owned(), + target, + item_id, + operation_id, + disposition: result.disposition, + }; + if args.json { + print_json(&output)?; + } else { + let disposition = match output.disposition { + public_wire::LauncherExecDisposition::Committed => "committed", + public_wire::LauncherExecDisposition::AlreadyCommitted => "already committed", + }; + print_stdout(&format!( + "launched {} item {} ({disposition})\n", + output.target.to_canonical(), + output.item_id.as_str() + )); + } + Ok(0) +} + +fn local_vm_shell_target(workload: &public_wire::WorkloadPublicSummary) -> &str { + workload.identity.legacy_vm_name.as_ref().map_or_else( + || workload.identity.workload_id.as_str(), + |legacy| legacy.as_str(), + ) +} + +fn shell_launch_target( + workload: &public_wire::WorkloadPublicSummary, + capabilities: &[d2b_contracts::FeatureFlag], +) -> Result { + if workload.provider_kind == d2b_realm_core::WorkloadProviderKind::UnsafeLocal { + require_unsafe_local_shell_feature(capabilities)?; + Ok(workload.identity.canonical_target.to_canonical()) + } else { + Ok(local_vm_shell_target(workload).to_owned()) + } +} + +fn require_launch_features( + capabilities: &[d2b_contracts::FeatureFlag], + provider: Option, +) -> Result<(), CliFailure> { + let has_feature = |expected| { + capabilities + .iter() + .any(|feature| feature.known() == Some(expected)) + }; + if !has_feature(KnownFeatureFlag::ConfiguredLaunchV1) { + return Err(CliFailure::new( + 70, + "daemon does not negotiate configured-launch-v1; update d2b and d2bd together", + )); + } + if provider == Some(d2b_realm_core::WorkloadProviderKind::UnsafeLocal) + && !has_feature(KnownFeatureFlag::UnsafeLocalProviderV1) + { + return Err(CliFailure::new( + 70, + "daemon does not negotiate unsafe-local-provider-v1; no local execution fallback is permitted", + )); + } + Ok(()) +} + +fn require_unsafe_local_shell_feature( + capabilities: &[d2b_contracts::FeatureFlag], +) -> Result<(), CliFailure> { + if capabilities + .iter() + .any(|feature| feature.known() == Some(KnownFeatureFlag::UnsafeLocalShellV1)) + { + Ok(()) + } else { + Err(CliFailure::new( + 70, + "daemon does not negotiate unsafe-local-shell-v1; update d2b, d2bd, and d2b-unsafe-local-helper together; no host-shell fallback is permitted", + )) + } +} + +fn try_resolve_direct_shell_target( + context: &Context, + requested: &str, +) -> Result, CliFailure> { + if !requested.ends_with(".d2b") { + return Ok(None); + } + let mut socket = SeqpacketUnixSocket::connect(&context.public_socket).map_err(|error| { + CliFailure::new( + 69, + format!("shell: failed to connect to the d2bd public socket: {error}"), + ) + })?; + socket + .send_frame(&daemon_hello_frame("hello")?) + .map_err(|error| CliFailure::new(69, format!("shell: failed to send hello: {error}")))?; + let hello = socket + .recv_frame() + .map_err(|error| CliFailure::new(69, format!("shell: failed to receive hello: {error}")))?; + let negotiated = parse_hello_reply(&hello)?; + if !negotiated + .capabilities + .iter() + .any(|feature| feature.known() == Some(KnownFeatureFlag::ConfiguredLaunchV1)) + { + return Err(CliFailure::new( + 70, + "daemon cannot resolve canonical shell targets; update d2b and d2bd together", + )); + } + let response = workload_socket_exchange( + &mut socket, + &public_wire::WorkloadOp::List(public_wire::WorkloadListArgs::default()), + "shell workload resolution", + )?; + let public_wire::WorkloadOpResponse::List(result) = response else { + return Err(CliFailure::new( + 76, + "daemon returned the wrong workload response while resolving the shell target", + )); + }; + let Some(workload) = result + .workloads + .into_iter() + .find(|workload| workload.identity.canonical_target.to_canonical() == requested) + else { + return Ok(None); + }; + match workload.provider_kind { + d2b_realm_core::WorkloadProviderKind::UnsafeLocal => { + require_unsafe_local_shell_feature(&negotiated.capabilities)?; + Ok(Some(workload.identity.canonical_target.to_canonical())) + } + d2b_realm_core::WorkloadProviderKind::LocalVm => { + Ok(Some(local_vm_shell_target(&workload).to_owned())) + } + provider => Err(CliFailure::new( + 70, + format!( + "workload provider '{}' does not support direct local persistent shells", + workload_provider_kind_label(provider) + ), + )), + } +} + +fn workload_provider_kind_label(provider: d2b_realm_core::WorkloadProviderKind) -> &'static str { + match provider { + d2b_realm_core::WorkloadProviderKind::LocalVm => "local-vm", + d2b_realm_core::WorkloadProviderKind::QemuMedia => "qemu-media", + d2b_realm_core::WorkloadProviderKind::ProviderManaged => "provider-managed", + d2b_realm_core::WorkloadProviderKind::UnsafeLocal => "unsafe-local", + } +} + +fn select_launch_workload( + workloads: Vec, + target: &str, +) -> Result { + let mut candidates = workloads + .into_iter() + .filter(|workload| { + workload.identity.canonical_target.to_canonical() == target + || workload.identity.workload_id.as_str() == target + }) + .collect::>(); + match candidates.len() { + 1 => Ok(candidates.remove(0)), + 0 => Err(CliFailure::new( + 2, + format!("workload target `{target}` was not found"), + )), + _ => { + let targets = candidates + .iter() + .map(|workload| workload.identity.canonical_target.to_canonical()) + .collect::>() + .join(", "); + Err(CliFailure::new( + 2, + format!("workload id `{target}` is ambiguous; use one of: {targets}"), + )) + } + } +} + +fn select_launcher_item( + workload: &public_wire::WorkloadPublicSummary, + requested: Option<&str>, +) -> Result { + if let Some(item) = requested { + return workload + .launcher_items + .iter() + .find(|candidate| candidate.id.as_str() == item) + .cloned() + .ok_or_else(|| { + CliFailure::new( + 2, + format!( + "launcher item `{item}` is not configured for `{}`", + workload.identity.canonical_target.to_canonical() + ), + ) + }); + } + if let Some(default_item) = workload.default_item_id.as_ref() { + return workload + .launcher_items + .iter() + .find(|candidate| &candidate.id == default_item) + .cloned() + .ok_or_else(|| { + CliFailure::new( + 70, + "trusted launcher metadata names a missing default item; rebuild the bundle", + ) + }); + } + if let [only] = workload.launcher_items.as_slice() { + return Ok(only.clone()); + } + let choices = workload + .launcher_items + .iter() + .map(|item| format!("{} ({})", item.id.as_str(), item.name)) + .collect::>() + .join(", "); + Err(CliFailure::new( + 2, + if choices.is_empty() { + "workload has no configured launcher items".to_owned() + } else { + format!("launcher item is ambiguous; choose one with --item: {choices}") + }, + )) +} + +#[cfg(test)] +mod workload_launch_tests { + use super::*; + use d2b_contracts::public_wire::{ + GraphicalLaunchPosture, WorkloadAvailability, WorkloadPublicSummary, + }; + use d2b_core::workload_identity::{WorkloadIdentity, WorkloadTarget}; + use d2b_realm_core::{ + CapabilitySet, DisplayEnvironmentPosture, EnvironmentPosture, ExecutionIdentityPosture, + IsolationPosture, LauncherIcon, LauncherItemKind, LauncherItemSummary, ProtocolToken, + SessionPersistencePosture, WorkloadExecutionPosture, WorkloadProviderKind, WorkloadState, + ids::{RealmId, WorkloadId}, + realm::RealmPath, + }; + + fn item(id: &str) -> LauncherItemSummary { + LauncherItemSummary { + id: ProtocolToken::parse(id).unwrap(), + name: id.to_owned(), + icon: LauncherIcon::default(), + kind: LauncherItemKind::Exec, + graphical: false, + capabilities: CapabilitySet::default(), + } + } + + pub(super) fn workload( + workload_id: &str, + realm: &str, + items: Vec, + default_item: Option<&str>, + ) -> WorkloadPublicSummary { + let realm_id = RealmId::parse(realm).unwrap(); + let identity = WorkloadIdentity::new( + WorkloadId::parse(workload_id).unwrap(), + realm_id.clone(), + RealmPath::new(vec![realm_id]).unwrap(), + WorkloadTarget::parse(&format!("{workload_id}.{realm}.d2b")).unwrap(), + ); + WorkloadPublicSummary { + identity, + provider_kind: WorkloadProviderKind::UnsafeLocal, + state: WorkloadState::Stopped, + execution_posture: WorkloadExecutionPosture { + isolation: IsolationPosture::UnsafeLocal, + environment: EnvironmentPosture::SystemdUserManagerAmbient, + display_environment: DisplayEnvironmentPosture::NotApplicable, + execution_identity: ExecutionIdentityPosture::AuthenticatedRequesterUid, + session_persistence: SessionPersistencePosture::UserManagerLifetime, + }, + availability: WorkloadAvailability::Ready, + graphical_posture: GraphicalLaunchPosture::NotApplicable, + capabilities: CapabilitySet::default(), + launcher_items: items, + default_item_id: default_item.map(|id| ProtocolToken::parse(id).unwrap()), + } + } + + #[test] + fn target_alias_ambiguity_lists_canonical_choices() { + let error = select_launch_workload( + vec![ + workload("browser", "work", vec![item("open")], None), + workload("browser", "home", vec![item("open")], None), + ], + "browser", + ) + .unwrap_err(); + assert_eq!(error.exit_code, 2); + assert!(error.message.contains("browser.work.d2b")); + assert!(error.message.contains("browser.home.d2b")); + } + + #[test] + fn item_selection_covers_sole_ambiguous_and_missing_default() { + let sole = workload("tools", "host", vec![item("only")], None); + assert_eq!( + select_launcher_item(&sole, None).unwrap().id.as_str(), + "only" + ); + + let ambiguous = workload("tools", "host", vec![item("browser"), item("editor")], None); + let error = select_launcher_item(&ambiguous, None).unwrap_err(); + assert_eq!(error.exit_code, 2); + assert!(error.message.contains("--item")); + + let missing_default = workload("tools", "host", vec![item("browser")], Some("missing")); + let error = select_launcher_item(&missing_default, None).unwrap_err(); + assert_eq!(error.exit_code, 70); + assert!(error.message.contains("rebuild the bundle")); + } + + #[test] + fn local_vm_shell_target_uses_workload_id_without_legacy_binding() { + let mut first_class = workload("browser", "work", vec![item("terminal")], None); + first_class.provider_kind = WorkloadProviderKind::LocalVm; + assert_eq!(local_vm_shell_target(&first_class), "browser"); + + let mut legacy = first_class; + legacy.identity.legacy_vm_name = + Some(d2b_core::contract_id::ContractId::parse("corp-vm").unwrap()); + assert_eq!(local_vm_shell_target(&legacy), "corp-vm"); + } + + #[test] + fn launch_feature_skew_fails_closed() { + let error = require_launch_features(&[], None).unwrap_err(); + assert_eq!(error.exit_code, 70); + assert!(error.message.contains("configured-launch-v1")); + + let configured_only = [KnownFeatureFlag::ConfiguredLaunchV1.wire_value()]; + let error = + require_launch_features(&configured_only, Some(WorkloadProviderKind::UnsafeLocal)) + .unwrap_err(); + assert_eq!(error.exit_code, 70); + assert!(error.message.contains("unsafe-local-provider-v1")); + + let error = require_unsafe_local_shell_feature(&configured_only).unwrap_err(); + assert_eq!(error.exit_code, 70); + assert!(error.message.contains("unsafe-local-shell-v1")); + assert!(error.message.contains("update d2b")); + assert!(error.message.contains("no host-shell fallback")); + } + + #[test] + fn terminal_launcher_uses_canonical_target_only_for_unsafe_local() { + let features = [KnownFeatureFlag::UnsafeLocalShellV1.wire_value()]; + let unsafe_local = workload("tools", "host", vec![item("terminal")], None); + assert_eq!( + shell_launch_target(&unsafe_local, &features).unwrap(), + "tools.host.d2b" + ); + + let mut local = workload("tools", "work", vec![item("terminal")], None); + local.provider_kind = WorkloadProviderKind::LocalVm; + local.identity.legacy_vm_name = + Some(d2b_core::contract_id::ContractId::parse("corp-vm").unwrap()); + assert_eq!(shell_launch_target(&local, &[]).unwrap(), "corp-vm"); + } +} + +fn workload_socket_exchange( + socket: &mut SeqpacketUnixSocket, + op: &public_wire::WorkloadOp, + label: &str, +) -> Result { + let request = encode_type_tagged_message("workload", op, label)?; + socket + .send_frame(&request) + .map_err(|error| CliFailure::new(69, format!("failed to send {label}: {error}")))?; + let response = socket + .recv_frame() + .map_err(|error| CliFailure::new(69, format!("failed to receive {label}: {error}")))?; + let value = decode_daemon_frame(&response, label)?; + match value.get("type").and_then(Value::as_str) { + Some("workloadResponse") => serde_json::from_value::(value) + .map(|frame| frame.payload) + .map_err(|error| { + CliFailure::new(76, format!("failed to decode {label} response: {error}")) + }), + Some("error") => { + let frame: ErrorFrame = serde_json::from_value(value).map_err(|error| { + CliFailure::new(76, format!("failed to decode {label} error: {error}")) + })?; + Err(cli_failure_from_daemon_error(frame.error)) + } + _ => Err(CliFailure::new( + 76, + format!("daemon returned an unexpected response to {label}"), + )), + } +} + +fn new_launch_operation_id() -> Result { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| CliFailure::new(42, "system clock is before the Unix epoch"))? + .as_nanos(); + d2b_realm_core::OperationId::parse(format!("launch-{}-{nanos}", std::process::id())) + .map_err(|_| CliFailure::new(42, "failed to construct a launch operation id")) +} + fn cmd_list(context: &Context, args: &ListArgs) -> Result { let (output, read_model) = match try_list_via_socket(context)? { ListSocketOutcome::Entries(entries, rm) => { @@ -9958,6 +10499,7 @@ fn all_known_subcommands() -> Vec { vec![ "list", "status", + "launch", "audit", "host check", "auth status", @@ -11444,6 +11986,17 @@ mod host_install_dispatch_tests { let (requests_tx, requests_rx) = mpsc::channel(); let server = thread::spawn(move || { for response in [ + serde_json::from_slice( + &encode_type_tagged_message( + "workloadResponse", + &public_wire::WorkloadOpResponse::List(public_wire::WorkloadListResult { + workloads: Vec::new(), + }), + "empty direct workload response", + ) + .expect("encode empty workload response"), + ) + .expect("decode empty workload response"), running_gateway_list_response("sys-work-gateway"), json!({ "type": "error", @@ -11538,6 +12091,80 @@ mod host_install_dispatch_tests { assert!(!failure.message.contains("unknown field `opId`")); } + #[test] + fn canonical_unsafe_local_shell_target_stays_canonical_after_daemon_resolution() { + let socket_path = test_socket_path("unsafe-local-shell-target", ".sock"); + if let Some(parent) = socket_path.parent() { + std::fs::create_dir_all(parent).expect("create test socket dir"); + } + let _ = std::fs::remove_file(&socket_path); + let listener = socket( + AddressFamily::Unix, + SockType::SeqPacket, + SockFlag::SOCK_CLOEXEC, + None, + ) + .expect("listener socket"); + let addr = UnixAddr::new(&socket_path).expect("unix addr"); + bind(listener.as_raw_fd(), &addr).expect("bind listener"); + listen(&listener, Backlog::new(1).expect("backlog")).expect("listen"); + let response = public_wire::WorkloadOpResponse::List(public_wire::WorkloadListResult { + workloads: vec![super::workload_launch_tests::workload( + "tools", + "host", + Vec::new(), + None, + )], + }); + let server = thread::spawn(move || { + let accepted = accept4(listener.as_raw_fd(), SockFlag::SOCK_CLOEXEC).expect("accept"); + let hello = recv_test_frame(accepted).expect("read hello"); + assert_eq!( + serde_json::from_slice::(&hello).unwrap()["type"], + "hello" + ); + let hello_reply = encode_type_tagged_message( + "helloOk", + &IpcHelloOk { + server_version: Version::new("0.4.0").unwrap(), + selected_version: Version::new("0.4.0").unwrap(), + capabilities: daemon_supported_features(), + }, + "hello response", + ) + .unwrap(); + send_test_frame(accepted, &hello_reply).unwrap(); + let request = recv_test_frame(accepted).expect("read workload list"); + assert_eq!( + serde_json::from_slice::(&request).unwrap()["type"], + "workload" + ); + let response = + encode_type_tagged_message("workloadResponse", &response, "workload response") + .unwrap(); + send_test_frame(accepted, &response).unwrap(); + close(accepted).unwrap(); + }); + let context = Context { + manifest_path: socket_path.with_extension("manifest.json"), + bundle_path: socket_path.with_extension("bundle.json"), + public_socket: socket_path.clone(), + broker_socket: PathBuf::from("/dev/null"), + state_root: None, + host_runtime_path: PathBuf::from("/dev/null"), + system_state_fixture: None, + auth_status_fixture: None, + daemon_state_dir: PathBuf::from("/dev/null"), + metrics_url: "http://127.0.0.1:1/metrics".to_owned(), + }; + assert_eq!( + super::try_resolve_direct_shell_target(&context, "tools.host.d2b").unwrap(), + Some("tools.host.d2b".to_owned()) + ); + server.join().unwrap(); + let _ = std::fs::remove_file(socket_path); + } + #[test] fn shell_vm_first_grammar_parses_attach_and_management_forms() { let implicit = parse_shell_raw(&["d2b", "shell", "work", "--name", "dev", "--force"]); @@ -11706,21 +12333,25 @@ mod host_install_dispatch_tests { result.expect("mock returns gateway exec transport status"), 0 ); - assert_eq!(requests.len(), 2); + assert_eq!(requests.len(), 3); assert_eq!( requests[0].get("type").and_then(Value::as_str), - Some("list") + Some("workload") ); assert_eq!( requests[1].get("type").and_then(Value::as_str), + Some("list") + ); + assert_eq!( + requests[2].get("type").and_then(Value::as_str), Some("exec") ); assert_eq!( - requests[1].pointer("/args/vm").and_then(Value::as_str), + requests[2].pointer("/args/vm").and_then(Value::as_str), Some("sys-work-gateway") ); assert_eq!( - requests[1] + requests[2] .pointer("/args/argv") .and_then(Value::as_array) .cloned() @@ -11736,11 +12367,11 @@ mod host_install_dispatch_tests { ] ); assert_eq!( - requests[1].pointer("/args/tty").and_then(Value::as_bool), + requests[2].pointer("/args/tty").and_then(Value::as_bool), Some(false) ); assert_eq!( - requests[1] + requests[2] .pointer("/args/detached") .and_then(Value::as_bool), Some(false) @@ -12247,6 +12878,17 @@ mod host_install_dispatch_tests { assert!(message.contains("exit or Ctrl-D ends the session")); } + #[test] + fn shell_terminal_size_rejects_zero_and_out_of_range_pty_geometry() { + assert!(super::shell_terminal_size((0, 0)).is_none()); + assert!(super::shell_terminal_size((24, 0)).is_none()); + assert!(super::shell_terminal_size((65_536, 80)).is_none()); + assert_eq!( + super::shell_terminal_size((24, 80)), + Some(d2b_contracts::terminal_wire::TerminalSize { rows: 24, cols: 80 }) + ); + } + #[test] fn vm_list_all_parse_gateway_selector() { let cli = NativeCli::try_parse_from(["d2b", "vm", "list", "--all"]) diff --git a/packages/d2b/tests/launch_contract.rs b/packages/d2b/tests/launch_contract.rs new file mode 100644 index 000000000..08e6c16a3 --- /dev/null +++ b/packages/d2b/tests/launch_contract.rs @@ -0,0 +1,34 @@ +use std::process::Command; + +#[test] +fn launch_has_no_static_or_ssh_fallback_when_daemon_is_missing() { + let dir = tempfile::tempdir().expect("test dir"); + let output = Command::new(env!("CARGO_BIN_EXE_d2b")) + .args(["launch", "browser.host.d2b", "--item", "browser"]) + .env("D2B_PUBLIC_SOCKET", dir.path().join("missing-public.sock")) + .env("D2B_BUNDLE_PATH", dir.path().join("missing-bundle.json")) + .output() + .expect("spawn d2b launch"); + assert_eq!(output.status.code(), Some(69)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("no static or provider fallback")); + assert!(!stderr.contains("ssh")); + assert!(!stderr.contains("sudo")); +} + +#[test] +fn launch_rejects_public_command_arguments() { + let output = Command::new(env!("CARGO_BIN_EXE_d2b")) + .args([ + "launch", + "browser.host.d2b", + "--item", + "browser", + "--", + "private-canary", + ]) + .output() + .expect("spawn d2b launch"); + assert_eq!(output.status.code(), Some(2)); + assert!(!String::from_utf8_lossy(&output.stdout).contains("private-canary")); +} diff --git a/packages/d2bd/src/daemon_audit.rs b/packages/d2bd/src/daemon_audit.rs index e2a03962a..efae0264b 100644 --- a/packages/d2bd/src/daemon_audit.rs +++ b/packages/d2bd/src/daemon_audit.rs @@ -52,23 +52,37 @@ pub enum DetachedExecAuditResult { #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum ShellAuditAction { + Create, Attach, + List, Detach, Kill, + Close, + Failure, } /// Closed persistent-shell owner/management result. #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum ShellAuditResult { + Requested, Attached, + Listed, Detached, Killed, Closed, + Refused, Timeout, Error, } +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ShellAuditProvider { + GuestControl, + UnsafeLocal, +} + /// Closed reason a vm-start runner node fast-failed before readiness. #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] @@ -121,6 +135,18 @@ pub enum VmShutdownOutcome { #[serde(rename_all = "snake_case", tag = "kind")] #[non_exhaustive] pub enum DaemonEvent { + /// Bounded configured-launch lifecycle boundary. Target and item identity + /// are trusted bundle tokens; execution details never enter this record. + WorkloadLauncher { + target: String, + item_id: String, + operation_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + exec_id: Option, + peer_uid: u32, + provider: WorkloadLaunchProvider, + result: WorkloadLaunchResult, + }, /// Emitted when the api-ready phase of a VM start does not converge /// within the configured timeout in strict split-readiness mode. ApiReadyTimeout { @@ -158,43 +184,23 @@ pub enum DaemonEvent { /// Admin peer uid (from `SO_PEERCRED`) that owned the session. peer_uid: u32, }, - /// Emitted when an authenticated persistent shell owner attachment is - /// established. - /// - /// Leak-safe: carries ONLY the VM name, admin peer uid, and whether a force - /// takeover was requested. Shell names, session handles, and terminal bytes - /// are never recorded. - GuestControlShellAttached { - /// VM name the shell attachment targets. - vm: String, - /// Admin peer uid (from `SO_PEERCRED`) that opened the attachment. + /// Provider-neutral persistent-shell lifecycle boundary. `target` is either + /// a configured canonical workload target or a local VM identifier. + /// Operation/session values are fixed-length digests; raw shell names, + /// handles, supervisor metadata, terminal bytes, paths, and diagnostics are + /// never present. + ShellLifecycle { + target: String, peer_uid: u32, - /// Closed shell action. + provider: ShellAuditProvider, action: ShellAuditAction, - /// Closed shell result. result: ShellAuditResult, - /// Fixed-length non-raw correlation digest for the targeted shell. This - /// is safe to record; raw shell names/session ids are never written. - shell_ref_digest: String, - /// Whether the caller requested force takeover. - force: bool, - }, - /// Emitted when a persistent shell owner attachment ends. - /// - /// Leak-safe: carries ONLY the VM name, admin peer uid, and a closed result - /// enum. No shell name, session handle, or terminal bytes. - GuestControlShellDetached { - /// VM name the shell attachment targeted. - vm: String, - /// Admin peer uid (from `SO_PEERCRED`) that owned the attachment. - peer_uid: u32, - /// Closed shell action. - action: ShellAuditAction, - /// Closed teardown result. - result: ShellAuditResult, - /// Fixed-length non-raw correlation digest for the targeted shell. This - /// is safe to record; raw shell names/session ids are never written. - shell_ref_digest: String, + #[serde(skip_serializing_if = "Option::is_none")] + force: Option, + #[serde(skip_serializing_if = "Option::is_none")] + operation_digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + session_digest: Option, }, /// Emitted when a detached `vm exec -d` create succeeds. /// @@ -269,6 +275,22 @@ pub enum DaemonEvent { }, } +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum WorkloadLaunchProvider { + LocalVm, + UnsafeLocal, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum WorkloadLaunchResult { + Committed, + AlreadyCommitted, + Refused, + Failed, +} + /// JSONL audit-log writer for daemon-side events. /// /// - **Production**: use [`DaemonAuditLog::new`]; events are appended to @@ -1556,66 +1578,58 @@ mod tests { const SENTINEL: &str = "SECRET-shell-name-session-terminal-/nix/store/path-like-token"; let log = DaemonAuditLog::no_op(); - log.write_event(&DaemonEvent::GuestControlShellAttached { - vm: "corp-vm".to_owned(), + log.write_event(&DaemonEvent::ShellLifecycle { + target: "corp-vm".to_owned(), peer_uid: 1000, + provider: ShellAuditProvider::GuestControl, action: ShellAuditAction::Attach, result: ShellAuditResult::Attached, - shell_ref_digest: "0123456789abcdef".to_owned(), - force: true, - }) - .expect("write shell attached event"); - log.write_event(&DaemonEvent::GuestControlShellDetached { - vm: "corp-vm".to_owned(), - peer_uid: 1000, - action: ShellAuditAction::Detach, - result: ShellAuditResult::Closed, - shell_ref_digest: "0123456789abcdef".to_owned(), + force: Some(true), + operation_digest: Some("1111111111111111".to_owned()), + session_digest: Some("2222222222222222".to_owned()), }) - .expect("write shell detached event"); + .expect("write provider-neutral shell event"); let records = log.captured.lock().expect("lock captured"); - assert_eq!(records.len(), 2, "expected two captured shell records"); + assert_eq!(records.len(), 1, "expected one unified shell record"); for line in records.iter() { assert!( !line.contains(SENTINEL), "shell lifecycle audit leaked sentinel: {line}" ); - for forbidden in ["SECRET", "/nix/store", "session", "handle", "terminal"] { + for forbidden in [ + "SECRET-shell-name", + "/nix/store/path-like-token", + "supervisor_id", + ] { assert!( !line.contains(forbidden), "shell lifecycle audit leaked forbidden canary {forbidden:?}: {line}", ); } - let record: serde_json::Value = - serde_json::from_str(line).expect("parse captured shell record"); - let event = record.get("event").expect("event object"); - let obj = event.as_object().expect("event is object"); - for key in obj.keys() { - assert!( - matches!( - key.as_str(), - "kind" - | "vm" - | "peer_uid" - | "action" - | "result" - | "shell_ref_digest" - | "force" - ), - "shell lifecycle audit exposed unexpected key {key:?}: {line}" - ); - } } + let provider = + serde_json::from_str::(&records[0]).expect("provider shell event"); + assert_eq!(provider["event"]["force"].as_bool(), Some(true)); + let keys = provider["event"] + .as_object() + .expect("provider event object") + .keys() + .map(String::as_str) + .collect::>(); assert_eq!( - serde_json::from_str::(&records[0]).unwrap()["event"]["result"] - .as_str(), - Some("attached") - ); - assert_eq!( - serde_json::from_str::(&records[1]).unwrap()["event"]["result"] - .as_str(), - Some("closed") + keys, + std::collections::BTreeSet::from([ + "action", + "force", + "kind", + "operation_digest", + "peer_uid", + "provider", + "result", + "session_digest", + "target", + ]) ); } @@ -1979,4 +1993,52 @@ mod tests { assert_eq!(parse_ymd("2024-03-09-10"), None); assert_eq!(parse_ymd("not-a-date"), None); } + + #[test] + fn workload_launch_event_contains_boundary_only() { + let event = DaemonEvent::WorkloadLauncher { + target: "browser.host.d2b".to_owned(), + item_id: "browser".to_owned(), + operation_id: "launch-1".to_owned(), + exec_id: None, + peer_uid: 1000, + provider: WorkloadLaunchProvider::UnsafeLocal, + result: WorkloadLaunchResult::Committed, + }; + let rendered = serde_json::to_string(&event).expect("serialize launch event"); + assert!(rendered.contains("\"kind\":\"workload_launcher\"")); + assert!(rendered.contains("\"peer_uid\":1000")); + assert!(rendered.contains("\"provider\":\"unsafe-local\"")); + for canary in [ + "private-argv-canary", + "\"argv\"", + "\"env\"", + "\"cwd\"", + "\"path\"", + "\"pid\"", + "\"unit\"", + ] { + assert!(!rendered.contains(canary)); + } + } + + #[test] + fn local_vm_workload_launch_provider_serializes_canonically() { + let event = DaemonEvent::WorkloadLauncher { + target: "browser.work.d2b".to_owned(), + item_id: "browser".to_owned(), + operation_id: "launch-2".to_owned(), + exec_id: Some("0123456789abcdef0123456789abcdef".to_owned()), + peer_uid: 1000, + provider: WorkloadLaunchProvider::LocalVm, + result: WorkloadLaunchResult::Committed, + }; + let rendered = serde_json::to_string(&event).unwrap(); + assert!(rendered.contains("\"provider\":\"local-vm\"")); + assert!(rendered.contains("\"operation_id\":\"launch-2\"")); + assert!(rendered.contains("\"exec_id\":\"0123456789abcdef0123456789abcdef\"")); + assert!(!rendered.contains("argv")); + assert!(!rendered.contains("environment")); + assert!(!rendered.contains("cwd")); + } } diff --git a/packages/d2bd/src/exec_detached.rs b/packages/d2bd/src/exec_detached.rs index 357998d5c..e81f88a9f 100644 --- a/packages/d2bd/src/exec_detached.rs +++ b/packages/d2bd/src/exec_detached.rs @@ -80,6 +80,7 @@ enum DetachedRealResponse { pub(crate) enum DetachedTestRequest { Create { vm: String, + request_id: Option, argv_len: usize, env_len: usize, has_cwd: bool, @@ -170,10 +171,27 @@ fn test_hook(request: DetachedTestRequest) -> Option Result { + create_with_request_id(state, start, None) +} + +pub(crate) fn create_idempotent( + state: &ServerState, + start: &public_wire::ExecStartArgs, + request_id: String, +) -> Result { + create_with_request_id(state, start, Some(request_id)) +} + +fn create_with_request_id( + state: &ServerState, + start: &public_wire::ExecStartArgs, + request_id: Option, ) -> Result { #[cfg(test)] if let Some(result) = test_hook(DetachedTestRequest::Create { vm: start.vm.clone(), + request_id: request_id.clone(), argv_len: start.argv.len(), env_len: start.env.as_ref().map_or(0, Vec::len), has_cwd: start.cwd.is_some(), @@ -188,6 +206,7 @@ pub(crate) fn create( let spec = ExecStartSpec { vm: start.vm.clone(), + request_id, argv: start.argv.clone(), tty: start.tty, detached: true, @@ -1249,6 +1268,7 @@ mod tests { fn start_spec() -> ExecStartSpec { ExecStartSpec { vm: "work".to_owned(), + request_id: None, argv: vec!["true".to_owned()], tty: false, detached: true, diff --git a/packages/d2bd/src/exec_session.rs b/packages/d2bd/src/exec_session.rs index 268f5c83c..0014a8b44 100644 --- a/packages/d2bd/src/exec_session.rs +++ b/packages/d2bd/src/exec_session.rs @@ -199,6 +199,9 @@ impl Default for ExecOpDeadlines { #[derive(Clone, PartialEq, Eq)] pub struct ExecStartSpec { pub vm: String, + /// Optional opaque idempotency key forwarded as guest request metadata. + /// It is never argv and must not appear in Debug output. + pub request_id: Option, pub argv: Vec, pub tty: bool, pub detached: bool, @@ -211,6 +214,7 @@ impl std::fmt::Debug for ExecStartSpec { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ExecStartSpec") .field("vm", &self.vm) + .field("has_request_id", &self.request_id.is_some()) .field("tty", &self.tty) .field("detached", &self.detached) .field("argv_len", &self.argv.len()) @@ -1067,8 +1071,10 @@ mod tests { const SECRET_KEY: &str = "SENTINEL_ENV_KEY_dspc"; const SECRET_VAL: &str = "SENTINEL_ENV_VAL_dspc"; const SECRET_CWD: &str = "SENTINEL_CWD_dspc"; + const SECRET_REQUEST_ID: &str = "SENTINEL_REQUEST_ID_dspc"; let spec = ExecStartSpec { vm: "corp-vm".to_owned(), + request_id: Some(SECRET_REQUEST_ID.to_owned()), argv: vec!["sh".to_owned(), SECRET_ARGV.to_owned()], tty: true, detached: false, @@ -1077,7 +1083,13 @@ mod tests { term_size: Some((24, 80)), }; let rendered = format!("{spec:?}"); - for secret in [SECRET_ARGV, SECRET_KEY, SECRET_VAL, SECRET_CWD] { + for secret in [ + SECRET_ARGV, + SECRET_KEY, + SECRET_VAL, + SECRET_CWD, + SECRET_REQUEST_ID, + ] { assert!( !rendered.contains(secret), "ExecStartSpec Debug leaked {secret}: {rendered}" @@ -1296,6 +1308,7 @@ mod tests { fn spec() -> ExecStartSpec { ExecStartSpec { vm: "work".to_owned(), + request_id: None, argv: vec!["true".to_owned()], tty: false, detached: false, diff --git a/packages/d2bd/src/exec_session_real.rs b/packages/d2bd/src/exec_session_real.rs index cf3ac647b..0fb89549d 100644 --- a/packages/d2bd/src/exec_session_real.rs +++ b/packages/d2bd/src/exec_session_real.rs @@ -219,7 +219,10 @@ pub(crate) fn build_exec_create_request( ) -> pb::ExecCreateRequest { let mut metadata = pb::RequestMetadata::new(); metadata.vm_id = vm_id.to_owned(); - metadata.request_id = "guest-control-exec".to_owned(); + metadata.request_id = spec + .request_id + .clone() + .unwrap_or_else(|| "guest-control-exec".to_owned()); metadata.protocol_version = GUEST_CONTROL_PROTOCOL_VERSION; let mut request = pb::ExecCreateRequest::new(); @@ -803,6 +806,7 @@ mod tests { let spec = ExecStartSpec { vm: "work".to_owned(), + request_id: None, argv: vec!["true".to_owned()], tty: false, detached: false, @@ -831,6 +835,7 @@ mod tests { // client must never be able to select/escalate the target user). let spec = ExecStartSpec { vm: "work".to_owned(), + request_id: Some("workload-launch:0123456789abcdef0123456789abcdef".to_owned()), argv: vec!["true".to_owned()], tty: false, detached: false, @@ -839,6 +844,13 @@ mod tests { term_size: None, }; let request = build_exec_create_request("work", &spec); + assert_eq!( + request + .metadata + .as_ref() + .map(|metadata| metadata.request_id.as_str()), + Some("workload-launch:0123456789abcdef0123456789abcdef") + ); assert_eq!( request.user.as_deref(), None, @@ -862,6 +874,7 @@ mod tests { // `validate_and_authorize_tty` accepts with an open stdin. let spec = ExecStartSpec { vm: "work".to_owned(), + request_id: None, argv: vec!["true".to_owned()], tty: true, detached: false, diff --git a/packages/d2bd/src/lib.rs b/packages/d2bd/src/lib.rs index a0bf41dad..9c3c052d1 100644 --- a/packages/d2bd/src/lib.rs +++ b/packages/d2bd/src/lib.rs @@ -119,7 +119,9 @@ pub mod supervisor; pub mod terminal_session; pub mod typed_error; pub mod unsafe_local_helper; +pub mod unsafe_local_terminal; pub mod wire; +mod workload_dispatch; pub mod workload_target_index; use admission::{ PeerIdentity, PeerRole, authorize_peer, gateway_display_op_requires_admin, @@ -147,6 +149,7 @@ pub mod known_hosts_refresh; // `dispatch_broker_vm_start` and from the host-prep DAG executor. // The pure check lives in // `crate::ssh_host_key_preflight`. +mod shell_backend; pub mod ssh_host_key_preflight; // Refuses to start a `sys--net` VM when the on-disk dnsmasq.conf // for that env diverges from the @@ -2935,11 +2938,23 @@ fn handle_connection_authorized( return Err(error); } }; - let capabilities = vec![ + let advertised_capabilities = [ KnownFeatureFlag::TypedErrors.wire_value(), KnownFeatureFlag::StatusCheckBridges.wire_value(), KnownFeatureFlag::ExportBrokerAudit.wire_value(), + KnownFeatureFlag::ConfiguredLaunchV1.wire_value(), + KnownFeatureFlag::UnsafeLocalProviderV1.wire_value(), + KnownFeatureFlag::UnsafeLocalShellV1.wire_value(), ]; + let capabilities = advertised_capabilities + .into_iter() + .filter(|capability| { + hello + .supported_features + .iter() + .any(|requested| requested.known() == capability.known()) + }) + .collect::>(); let hello_ok = wire::hello_ok( &state.config.server_version, &selected_version, @@ -2966,6 +2981,20 @@ fn handle_connection_authorized( continue; } }; + if matches!(request, wire::Request::Workload(_)) + && (!capabilities + .iter() + .any(|feature| feature.known() == Some(KnownFeatureFlag::ConfiguredLaunchV1)) + || !capabilities.iter().any(|feature| { + feature.known() == Some(KnownFeatureFlag::UnsafeLocalProviderV1) + })) + { + let error = TypedError::WireUnsupportedRequest { + request_type: "workload".to_owned(), + }; + let _ = write_json_frame(&stream, &wire::error_frame(&error)); + continue; + } // Exec takes over the connection as the long-lived owner connection. // Admin (SO_PEERCRED) is verified here, BEFORE any session work; then // the connection + a cheap ServerState clone move to a SPAWNED owner @@ -3023,6 +3052,19 @@ fn handle_connection_authorized( let _ = write_json_frame(&stream, &wire::error_frame(&error)); continue; } + if !unsafe_local_shell_feature_negotiated(&capabilities) + && let Some((target, operation, resolved)) = + resolve_negotiated_unsafe_local_shell(state, op) + { + let error = shell_backend::unsafe_shell_failed( + typed_error::UnsafeLocalShellErrorKind::FeatureUnavailable, + ); + record_resolved_shell_failure( + state, peer.uid, &resolved, target, operation, None, &error, + ); + let _ = write_json_frame(&stream, &wire::error_frame(&error)); + continue; + } if matches!(op, public_wire::ShellOp::Attach(_)) { let first_op_id = wire::shell_op_id(&frame); let owner_state = state.clone(); @@ -3164,78 +3206,1191 @@ fn request_invalidates_public_status_model(request: &wire::Request) -> bool { | wire::Request::Exec(public_wire::ExecOp::List(_)) | wire::Request::Exec(public_wire::ExecOp::Logs(_)) | wire::Request::Exec(public_wire::ExecOp::Status(_)) + | wire::Request::Workload(public_wire::WorkloadOp::List(_)) + | wire::Request::Workload(public_wire::WorkloadOp::Status(_)) | wire::Request::Audio(public_wire::AudioOp::Status(_)) ) } -/// Verb dispatch body executed under the op lock already acquired by -/// [`dispatch_request`]. Restart/rollback paths call the inner -/// `dispatch_broker_*` helpers directly (never re-entering -/// `dispatch_request`), so the lock is held exactly once for the op. -fn dispatch_request_locked( - state: &ServerState, - peer: &PeerIdentity, - request: wire::Request, -) -> Result { - match request { - wire::Request::List(request) => dispatch_list(state, request), - wire::Request::Status(request) => dispatch_status(state, request), - wire::Request::Audit(request) => dispatch_audit(state, peer, request), - wire::Request::HostCheck(request) => dispatch_host_check(state, request), - wire::Request::AuthStatus => Ok(dispatch_auth_status(state, peer)), - wire::Request::KeysList => dispatch_keys_list(state), - wire::Request::KeysShow(request) => dispatch_keys_show(state, request), - // Mutating-verb apply dispatch is now fully direct. The backlog - // verbs route from these request arms straight to their - // `dispatch_broker_` helpers, and the HostInstall/Migrate - // paths stay on their dedicated broker helpers. - // - // The old shared `dispatch_mutating_verb` split no longer - // applies in d2bd; only `mutating_verb_preflight` remains - // to emit the typed InvalidRequest / dry-run-planned envelope - // before apply dispatch runs. - wire::Request::VmStart(req) => dispatch_broker_vm_start(state, req), - wire::Request::VmStop(req) => { - dispatch_broker_vm_stop_as(state, req, broker_caller_role_for_peer(peer)) - } - wire::Request::VmRestart(req) => { - dispatch_broker_vm_restart_as(state, req, broker_caller_role_for_peer(peer)) +/// Verb dispatch body executed under the op lock already acquired by +/// [`dispatch_request`]. Restart/rollback paths call the inner +/// `dispatch_broker_*` helpers directly (never re-entering +/// `dispatch_request`), so the lock is held exactly once for the op. +fn dispatch_request_locked( + state: &ServerState, + peer: &PeerIdentity, + request: wire::Request, +) -> Result { + match request { + wire::Request::List(request) => dispatch_list(state, request), + wire::Request::Status(request) => dispatch_status(state, request), + wire::Request::Audit(request) => dispatch_audit(state, peer, request), + wire::Request::HostCheck(request) => dispatch_host_check(state, request), + wire::Request::AuthStatus => Ok(dispatch_auth_status(state, peer)), + wire::Request::KeysList => dispatch_keys_list(state), + wire::Request::KeysShow(request) => dispatch_keys_show(state, request), + // Mutating-verb apply dispatch is now fully direct. The backlog + // verbs route from these request arms straight to their + // `dispatch_broker_` helpers, and the HostInstall/Migrate + // paths stay on their dedicated broker helpers. + // + // The old shared `dispatch_mutating_verb` split no longer + // applies in d2bd; only `mutating_verb_preflight` remains + // to emit the typed InvalidRequest / dry-run-planned envelope + // before apply dispatch runs. + wire::Request::VmStart(req) => dispatch_broker_vm_start(state, req), + wire::Request::VmStop(req) => { + dispatch_broker_vm_stop_as(state, req, broker_caller_role_for_peer(peer)) + } + wire::Request::VmRestart(req) => { + dispatch_broker_vm_restart_as(state, req, broker_caller_role_for_peer(peer)) + } + wire::Request::Switch(req) => dispatch_broker_switch(state, req), + wire::Request::Boot(req) => dispatch_broker_boot(state, req), + wire::Request::Test(req) => dispatch_broker_test(state, req), + wire::Request::Rollback(req) => dispatch_broker_rollback(state, req), + wire::Request::Gc(req) => dispatch_broker_gc(state, req), + wire::Request::KeysRotate(req) => dispatch_broker_keys_rotate(state, req), + wire::Request::Trust(req) => dispatch_broker_trust(state, req), + wire::Request::RotateKnownHost(req) => dispatch_broker_rotate_known_host(state, req), + wire::Request::UsbipBind(req) => dispatch_broker_usbip_bind(state, req), + wire::Request::UsbipUnbind(req) => dispatch_broker_usbip_unbind(state, req), + wire::Request::UsbipProbe => dispatch_broker_usbip_probe(state), + wire::Request::StoreVerify(req) => dispatch_broker_store_verify(state, req), + wire::Request::Migrate(req) => dispatch_broker_run_migrate(state, req), + wire::Request::HostPrepare(req) => dispatch_broker_host_prepare(state, req), + wire::Request::HostDestroy(req) => dispatch_broker_host_destroy(state, req), + wire::Request::HostInstall(req) => dispatch_broker_run_host_install(state, req), + wire::Request::HostReconcile(req) => dispatch_broker_host_reconcile(state, req), + wire::Request::ReadGuestConfig(req) => dispatch_read_guest_config(state, req), + // Attached Exec::Start is intercepted in `handle_connection` (it takes + // over the connection as the owner connection and is handled on a + // spawned worker off the serial accept loop). Detached management ops + // are ordinary one-shot requests. + wire::Request::Exec(op) => dispatch_exec_management(state, peer, op), + wire::Request::Shell(op) => dispatch_shell_management(state, peer, op), + wire::Request::Console(op) => dispatch_console(state, peer, op), + wire::Request::GatewayDisplay(op) => dispatch_gateway_display(state, peer, op), + wire::Request::Workload(op) => dispatch_workload(state, peer, op), + wire::Request::Audio(op) => { + if !matches!(op, public_wire::AudioOp::Status(_)) + && !matches!(peer.role, PeerRole::Admin) + { + return Err(TypedError::AuthzNotAdmin { + verb: "audio".to_owned(), + }); + } + audio_dispatch::dispatch_audio(state, op) + } + } +} + +fn dispatch_workload( + state: &ServerState, + peer: &PeerIdentity, + op: public_wire::WorkloadOp, +) -> Result { + use workload_dispatch::{LaunchLedgerBegin, WorkloadCatalog, WorkloadRoute}; + + if !matches!(peer.role, PeerRole::Launcher | PeerRole::Admin) { + return Err(TypedError::AuthzNotALauncher { peer_uid: peer.uid }); + } + let resolver = load_bundle_resolver(state)?; + let catalog = WorkloadCatalog::from_resolver(&resolver).map_err(map_workload_catalog_error)?; + let response = match op { + public_wire::WorkloadOp::List(args) => { + let observed = observe_workload_catalog(state, peer.uid, &catalog); + record_workload_availability_metrics(&state.metrics_registry, &observed); + let workloads = observed + .iter() + .filter(|entry| { + args.realm.as_ref().is_none_or(|realm| { + entry.entry.metadata.identity.realm_path.target_form() == *realm + }) + }) + .map(|observed| { + WorkloadCatalog::public_summary( + observed.entry, + observed.state, + observed.availability, + ) + }) + .collect(); + public_wire::WorkloadOpResponse::List(public_wire::WorkloadListResult { workloads }) + } + public_wire::WorkloadOp::Status(args) => { + let observed = observe_workload_catalog(state, peer.uid, &catalog); + record_workload_availability_metrics(&state.metrics_registry, &observed); + let observed = observed + .into_iter() + .find(|entry| entry.entry.metadata.identity.canonical_target == args.target) + .ok_or_else(|| TypedError::WorkloadTargetNotFound { + target: args.target.to_canonical(), + })?; + public_wire::WorkloadOpResponse::Status(Box::new(public_wire::WorkloadStatusResult { + workload: WorkloadCatalog::public_summary( + observed.entry, + observed.state, + observed.availability, + ), + })) + } + public_wire::WorkloadOp::LauncherExec(args) => { + let route = catalog + .resolve(&args.target) + .map_err(|_| TypedError::WorkloadTargetNotFound { + target: args.target.to_canonical(), + })? + .route + .clone(); + if let WorkloadRoute::CapabilityUnavailable { provider } = route { + return Err(TypedError::RuntimeCapabilityUnsupported { + vm: args.target.to_canonical(), + runtime_kind: workload_provider_label(provider).to_owned(), + capability: "configured-launch".to_owned(), + verb: "launch".to_owned(), + }); + } + let (resolved, audit_context, begin) = prepare_workload_launch( + state, + peer.uid, + &catalog, + resolver.unsafe_local_workloads.as_ref(), + &args, + )?; + let operation_id = args.operation_id.to_string(); + if begin == LaunchLedgerBegin::AlreadyCommitted { + record_workload_launch_result( + state, + peer.uid, + &audit_context, + &args.operation_id, + None, + WorkloadLaunchResult::AlreadyCommitted, + ); + return Ok(wire::workload_response( + &public_wire::WorkloadOpResponse::LauncherExec( + public_wire::LauncherExecResult { + target: args.target, + item_id: args.item_id, + operation_id: args.operation_id, + disposition: public_wire::LauncherExecDisposition::AlreadyCommitted, + }, + ), + )); + } + let dispatch_result = match &resolved.route { + WorkloadRoute::UnsafeLocal => { + dispatch_unsafe_local_launcher(state, peer.uid, &args.operation_id, &resolved) + .map(|disposition| (disposition, None)) + } + WorkloadRoute::LocalVm { vm } => { + dispatch_local_vm_launcher(state, peer.uid, vm, &args.operation_id, &resolved) + } + WorkloadRoute::CapabilityUnavailable { provider } => { + Err(TypedError::RuntimeCapabilityUnsupported { + vm: args.target.to_canonical(), + runtime_kind: workload_provider_label(*provider).to_owned(), + capability: "configured-launch".to_owned(), + verb: "launch".to_owned(), + }) + } + }; + let (disposition, exec_id) = match dispatch_result { + Ok(result) => result, + Err(error) => { + workload_dispatch::abort_launch(peer.uid, &operation_id); + record_workload_launch_result( + state, + peer.uid, + &audit_context, + &args.operation_id, + None, + WorkloadLaunchResult::Failed, + ); + return Err(error); + } + }; + workload_dispatch::complete_launch(peer.uid, &operation_id); + let disposition = match disposition { + public_wire::LauncherExecDisposition::Committed => { + record_workload_launch_result( + state, + peer.uid, + &audit_context, + &args.operation_id, + exec_id.as_deref(), + WorkloadLaunchResult::Committed, + ); + public_wire::LauncherExecDisposition::Committed + } + public_wire::LauncherExecDisposition::AlreadyCommitted => { + record_workload_launch_result( + state, + peer.uid, + &audit_context, + &args.operation_id, + exec_id.as_deref(), + WorkloadLaunchResult::AlreadyCommitted, + ); + public_wire::LauncherExecDisposition::AlreadyCommitted + } + }; + public_wire::WorkloadOpResponse::LauncherExec(public_wire::LauncherExecResult { + target: args.target, + item_id: args.item_id, + operation_id: args.operation_id, + disposition, + }) + } + }; + Ok(wire::workload_response(&response)) +} + +fn prepare_workload_launch( + state: &ServerState, + requester_uid: u32, + catalog: &workload_dispatch::WorkloadCatalog, + private: Option<&d2b_core::unsafe_local_workloads::UnsafeLocalWorkloadsJson>, + args: &public_wire::LauncherExecArgs, +) -> Result< + ( + workload_dispatch::ResolvedExec, + WorkloadLaunchAuditContext, + workload_dispatch::LaunchLedgerBegin, + ), + TypedError, +> { + let entry = catalog + .resolve(&args.target) + .map_err(|_| TypedError::WorkloadTargetNotFound { + target: args.target.to_canonical(), + })?; + let audit_context = WorkloadLaunchAuditContext::from_entry(entry, &args.item_id); + let resolved = match catalog.resolve_exec(private, &args.target, &args.item_id) { + Ok(resolved) => resolved, + Err(error) => { + record_workload_launch_result( + state, + requester_uid, + &audit_context, + &args.operation_id, + None, + WorkloadLaunchResult::Refused, + ); + return Err(map_workload_catalog_error(error)); + } + }; + let begin = match workload_dispatch::begin_launch( + requester_uid, + &args.operation_id.to_string(), + &args.target, + &args.item_id, + ) { + Ok(begin) => begin, + Err(error) => { + record_workload_launch_result( + state, + requester_uid, + &audit_context, + &args.operation_id, + None, + WorkloadLaunchResult::Refused, + ); + return Err(map_workload_catalog_error(error)); + } + }; + Ok((resolved, audit_context, begin)) +} + +struct ObservedWorkload<'a> { + entry: &'a workload_dispatch::CatalogEntry, + state: d2b_realm_core::WorkloadState, + availability: public_wire::WorkloadAvailability, +} + +fn observe_workload_catalog<'a>( + state: &ServerState, + requester_uid: u32, + catalog: &'a workload_dispatch::WorkloadCatalog, +) -> Vec> { + catalog + .entries() + .map(|entry| { + let (workload_state, availability) = + workload_runtime_status(state, requester_uid, entry); + ObservedWorkload { + entry, + state: workload_state, + availability, + } + }) + .collect() +} + +fn workload_runtime_status( + state: &ServerState, + requester_uid: u32, + entry: &workload_dispatch::CatalogEntry, +) -> ( + d2b_realm_core::WorkloadState, + public_wire::WorkloadAvailability, +) { + use d2b_contracts::unsafe_local_wire::HelperScopeState; + use d2b_realm_core::WorkloadState; + use workload_dispatch::WorkloadRoute; + + match &entry.route { + WorkloadRoute::UnsafeLocal => { + let availability = unsafe_local_workload_availability( + state.unsafe_local_helpers.availability(requester_uid), + state + .unsafe_local_helpers + .last_failure(requester_uid, &entry.metadata.identity.canonical_target), + ); + let workload_state = state + .unsafe_local_helpers + .snapshot(requester_uid) + .and_then(|snapshot| { + snapshot + .scopes + .into_iter() + .find(|scope| scope.workload == entry.metadata.identity) + }) + .map_or(WorkloadState::Stopped, |scope| match scope.state { + HelperScopeState::Starting => WorkloadState::Starting, + HelperScopeState::Active => WorkloadState::Running, + HelperScopeState::Stopping => WorkloadState::Stopping, + HelperScopeState::Exited => WorkloadState::Stopped, + HelperScopeState::Degraded => WorkloadState::Failed, + }); + (workload_state, availability) + } + WorkloadRoute::LocalVm { vm } => { + let running = state + .pidfd_table + .list_for_vm(vm) + .into_iter() + .any(|registration| { + state + .pidfd_table + .still_alive_same_start_time(vm, ®istration.role) + }); + ( + if running { + WorkloadState::Running + } else { + WorkloadState::Stopped + }, + public_wire::WorkloadAvailability::Ready, + ) + } + WorkloadRoute::CapabilityUnavailable { .. } => ( + WorkloadState::Stopped, + public_wire::WorkloadAvailability::Degraded, + ), + } +} + +fn unsafe_local_workload_availability( + helper: unsafe_local_helper::HelperAvailability, + last_failure: Option, +) -> public_wire::WorkloadAvailability { + use d2b_contracts::unsafe_local_wire::HelperFailureCode; + use unsafe_local_helper::HelperAvailability; + + match helper { + HelperAvailability::Ready => match last_failure { + Some(HelperFailureCode::UserManagerUnavailable) => { + public_wire::WorkloadAvailability::UserManagerUnavailable + } + Some(HelperFailureCode::GraphicalSessionInactive) => { + public_wire::WorkloadAvailability::GraphicalSessionInactive + } + Some(HelperFailureCode::WaylandUnavailable) => { + public_wire::WorkloadAvailability::WaylandUnavailable + } + Some(HelperFailureCode::ProxyUnavailable | HelperFailureCode::FirstClientTimeout) => { + public_wire::WorkloadAvailability::ProxyUnavailable + } + Some(_) => public_wire::WorkloadAvailability::Degraded, + None => public_wire::WorkloadAvailability::Ready, + }, + HelperAvailability::Unavailable => public_wire::WorkloadAvailability::HelperUnavailable, + HelperAvailability::Stale => public_wire::WorkloadAvailability::HelperStale, + } +} + +fn dispatch_unsafe_local_launcher( + state: &ServerState, + requester_uid: u32, + operation_id: &d2b_realm_core::OperationId, + resolved: &workload_dispatch::ResolvedExec, +) -> Result { + use d2b_contracts::unsafe_local_wire::{HelperLaunchRequest, HelperOperationDisposition}; + let request = HelperLaunchRequest { + request_id: next_internal_helper_request_id(), + operation_id: operation_id.clone(), + workload: resolved.identity.clone(), + item_id: resolved.item_id.clone(), + argv: resolved.argv.clone(), + graphical: resolved.graphical, + realm_accent_color: resolved.realm_accent_color.clone(), + }; + let result = state + .unsafe_local_helpers + .dispatch_launch(requester_uid, request) + .map_err(map_helper_registry_error)?; + Ok(match result.disposition { + HelperOperationDisposition::AlreadyCommitted => { + public_wire::LauncherExecDisposition::AlreadyCommitted + } + HelperOperationDisposition::Committed | HelperOperationDisposition::Completed => { + public_wire::LauncherExecDisposition::Committed + } + }) +} + +fn dispatch_local_vm_launcher( + state: &ServerState, + requester_uid: u32, + vm: &str, + operation_id: &d2b_realm_core::OperationId, + resolved: &workload_dispatch::ResolvedExec, +) -> Result<(public_wire::LauncherExecDisposition, Option), TypedError> { + ensure_vm_runtime_capability(state, vm, RuntimeCapabilityGate::Exec, "launch")?; + let request = public_wire::ExecStartArgs { + vm: vm.to_owned(), + argv: resolved.argv.as_slice().to_vec(), + tty: false, + detached: true, + env: None, + cwd: None, + term_size: None, + }; + let mut fingerprint = Sha256::new(); + fingerprint.update(requester_uid.to_le_bytes()); + fingerprint.update(operation_id.as_str().as_bytes()); + fingerprint.update(resolved.identity.canonical_target.to_canonical().as_bytes()); + fingerprint.update(resolved.item_id.as_str().as_bytes()); + let request_id = format!( + "workload-launch:{}", + hex_bytes(&fingerprint.finalize()[..16]) + ); + let result = exec_detached::create_idempotent(state, &request, request_id)?; + emit_detached_create_audit(state, requester_uid, vm, &result.exec_id); + Ok(( + public_wire::LauncherExecDisposition::Committed, + Some(result.exec_id), + )) +} + +fn map_workload_catalog_error(error: workload_dispatch::CatalogError) -> TypedError { + use typed_error::WorkloadLaunchErrorKind as Kind; + use workload_dispatch::CatalogError; + let kind = match error { + CatalogError::TargetNotFound => Kind::ItemNotFound, + CatalogError::LauncherDisabled => Kind::LauncherDisabled, + CatalogError::ItemNotFound => Kind::ItemNotFound, + CatalogError::ArtifactsUnavailable + | CatalogError::ConfiguredItemMissing + | CatalogError::ConfiguredItemMismatch + | CatalogError::ShellCapabilityUnavailable => Kind::ConfiguredItemMismatch, + CatalogError::AliasConflict => Kind::ItemNotFound, + CatalogError::OperationConflict => Kind::OperationConflict, + CatalogError::OperationInProgress => Kind::QueueFull, + }; + TypedError::WorkloadLaunchFailed { kind } +} + +fn map_helper_registry_error(error: unsafe_local_helper::HelperRegistryError) -> TypedError { + use d2b_contracts::unsafe_local_wire::HelperFailureCode; + use typed_error::WorkloadLaunchErrorKind as Kind; + use unsafe_local_helper::HelperRegistryError; + let kind = match error { + HelperRegistryError::HelperUnavailable => Kind::HelperUnavailable, + HelperRegistryError::HelperStale | HelperRegistryError::GenerationSuperseded => { + Kind::HelperStale + } + HelperRegistryError::QueueFull | HelperRegistryError::OperationInProgress => { + Kind::QueueFull + } + HelperRegistryError::Timeout => Kind::Timeout, + HelperRegistryError::OperationIdConflict => Kind::OperationConflict, + HelperRegistryError::OperationRejected(code) => match code { + HelperFailureCode::UserManagerUnavailable => Kind::UserManagerUnavailable, + HelperFailureCode::GraphicalSessionInactive => Kind::GraphicalSessionInactive, + HelperFailureCode::WaylandUnavailable => Kind::WaylandUnavailable, + HelperFailureCode::ProxyUnavailable | HelperFailureCode::FirstClientTimeout => { + Kind::ProxyUnavailable + } + HelperFailureCode::OperationIdConflict => Kind::OperationConflict, + HelperFailureCode::QueueFull => Kind::QueueFull, + HelperFailureCode::Timeout => Kind::Timeout, + _ => Kind::Internal, + }, + _ => Kind::Internal, + }; + TypedError::WorkloadLaunchFailed { kind } +} + +fn workload_provider_label(provider: d2b_realm_core::WorkloadProviderKind) -> &'static str { + match provider { + d2b_realm_core::WorkloadProviderKind::LocalVm => "local-vm", + d2b_realm_core::WorkloadProviderKind::QemuMedia => "qemu-media", + d2b_realm_core::WorkloadProviderKind::ProviderManaged => "provider-managed", + d2b_realm_core::WorkloadProviderKind::UnsafeLocal => "unsafe-local", + } +} + +const WORKLOAD_PROVIDERS: [d2b_realm_core::WorkloadProviderKind; 4] = [ + d2b_realm_core::WorkloadProviderKind::LocalVm, + d2b_realm_core::WorkloadProviderKind::QemuMedia, + d2b_realm_core::WorkloadProviderKind::ProviderManaged, + d2b_realm_core::WorkloadProviderKind::UnsafeLocal, +]; +const WORKLOAD_COMPONENTS: [&str; 5] = ["helper", "scope", "proxy", "launcher", "shell"]; +const WORKLOAD_AVAILABILITY_STATES: [&str; 9] = [ + "ready", + "helper-unavailable", + "helper-stale", + "user-manager-unavailable", + "graphical-session-inactive", + "wayland-unavailable", + "proxy-unavailable", + "degraded", + "not-applicable", +]; + +fn workload_availability_label(availability: public_wire::WorkloadAvailability) -> &'static str { + match availability { + public_wire::WorkloadAvailability::Ready => "ready", + public_wire::WorkloadAvailability::HelperUnavailable => "helper-unavailable", + public_wire::WorkloadAvailability::HelperStale => "helper-stale", + public_wire::WorkloadAvailability::UserManagerUnavailable => "user-manager-unavailable", + public_wire::WorkloadAvailability::GraphicalSessionInactive => "graphical-session-inactive", + public_wire::WorkloadAvailability::WaylandUnavailable => "wayland-unavailable", + public_wire::WorkloadAvailability::ProxyUnavailable => "proxy-unavailable", + public_wire::WorkloadAvailability::Degraded => "degraded", + } +} + +fn record_workload_availability_metrics( + registry: &metrics::Registry, + observed: &[ObservedWorkload<'_>], +) { + let mut counts = BTreeMap::new(); + for provider in WORKLOAD_PROVIDERS { + for component in WORKLOAD_COMPONENTS { + for state in WORKLOAD_AVAILABILITY_STATES { + counts.insert((workload_provider_label(provider), component, state), 0u64); + } + } + } + for observation in observed { + let provider = workload_provider_label(observation.entry.metadata.provider_kind); + let state_label = workload_availability_label(observation.availability); + for component in WORKLOAD_COMPONENTS { + let items = &observation.entry.metadata.items; + let applicable = match component { + "shell" => items + .iter() + .any(|item| item.kind == d2b_realm_core::LauncherItemKind::Shell), + "helper" | "scope" | "proxy" => provider == "unsafe-local", + _ => true, + }; + let selected = if applicable { + state_label + } else { + "not-applicable" + }; + *counts + .get_mut(&(provider, component, selected)) + .expect("bounded workload availability tuple") += 1; + } + } + let samples = counts + .into_iter() + .map(|((provider, component, state), count)| { + ( + vec![ + ("provider", provider), + ("component", component), + ("state", state), + ], + count as f64, + ) + }) + .collect::>(); + registry.gauge_family_replace("d2b_daemon_workload_availability", &samples); +} + +fn workload_lifecycle_metric(state: &ServerState, provider: &str, operation: &str, outcome: &str) { + state.metrics_registry.counter_inc( + "d2b_daemon_workload_lifecycle_total", + &[ + ("provider", provider), + ("operation", operation), + ("outcome", outcome), + ], + ); +} + +#[derive(Debug)] +struct WorkloadLaunchAuditContext { + target: String, + item_id: String, + provider_label: &'static str, + audit_provider: daemon_audit::WorkloadLaunchProvider, +} + +impl WorkloadLaunchAuditContext { + fn from_entry( + entry: &workload_dispatch::CatalogEntry, + requested_item: &d2b_realm_core::ProtocolToken, + ) -> Self { + let (provider_label, audit_provider) = match entry.route { + workload_dispatch::WorkloadRoute::LocalVm { .. } => { + ("local-vm", daemon_audit::WorkloadLaunchProvider::LocalVm) + } + workload_dispatch::WorkloadRoute::UnsafeLocal => ( + "unsafe-local", + daemon_audit::WorkloadLaunchProvider::UnsafeLocal, + ), + workload_dispatch::WorkloadRoute::CapabilityUnavailable { .. } => { + unreachable!("unsupported routes reject before launch audit context") + } + }; + let item_id = entry + .metadata + .items + .iter() + .find(|item| item.id == *requested_item) + .map_or_else(|| "unknown".to_owned(), |item| item.id.as_str().to_owned()); + Self { + target: entry.metadata.identity.canonical_target.to_canonical(), + item_id, + provider_label, + audit_provider, + } + } +} + +#[derive(Clone, Copy)] +enum WorkloadLaunchResult { + Committed, + AlreadyCommitted, + Refused, + Failed, +} + +fn record_workload_launch_result( + state: &ServerState, + peer_uid: u32, + context: &WorkloadLaunchAuditContext, + operation_id: &d2b_realm_core::OperationId, + exec_id: Option<&str>, + result: WorkloadLaunchResult, +) { + let (outcome, audit_result) = match result { + WorkloadLaunchResult::Committed => { + ("committed", daemon_audit::WorkloadLaunchResult::Committed) + } + WorkloadLaunchResult::AlreadyCommitted => ( + "already-committed", + daemon_audit::WorkloadLaunchResult::AlreadyCommitted, + ), + WorkloadLaunchResult::Refused => ("refused", daemon_audit::WorkloadLaunchResult::Refused), + WorkloadLaunchResult::Failed => ("failed", daemon_audit::WorkloadLaunchResult::Failed), + }; + workload_lifecycle_metric(state, context.provider_label, "launcher-exec", outcome); + let _ = state + .daemon_audit + .write_event(&daemon_audit::DaemonEvent::WorkloadLauncher { + target: context.target.clone(), + item_id: context.item_id.clone(), + operation_id: operation_id.to_string(), + exec_id: exec_id.map(str::to_owned), + peer_uid, + provider: context.audit_provider, + result: audit_result, + }); +} + +#[cfg(test)] +mod workload_observability_tests { + use super::*; + use d2b_core::{ + configured_argv::ConfiguredArgv, + contract_id::ContractId, + realm_workloads_launcher::LauncherWorkloadSummary, + unsafe_local_workloads::{ + LocalVmConfiguredWorkload, UNSAFE_LOCAL_WORKLOADS_SCHEMA_VERSION, UnsafeLocalExecItem, + UnsafeLocalLauncherItem, UnsafeLocalWorkload, UnsafeLocalWorkloadsJson, + }, + workload_identity::{WorkloadIdentity, WorkloadTarget}, + }; + use d2b_realm_core::{ + CapabilitySet, DisplayEnvironmentPosture, EnvironmentPosture, ExecutionIdentityPosture, + IsolationPosture, LauncherIcon, LauncherItemKind, LauncherItemSummary, OperationId, + ProtocolToken, SessionPersistencePosture, WorkloadExecutionPosture, WorkloadProviderKind, + WorkloadState, + ids::{RealmId, WorkloadId}, + realm::RealmPath, + }; + + fn test_state() -> (ServerState, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("daemon test state"); + let broker_reap_log = BrokerReapLog::new(); + let state = ServerState { + config: DaemonConfig::default(), + daemon_uid: 0, + daemon_state_dir: dir.path().to_path_buf(), + pidfd_table: Arc::new( + PidfdTable::new(dir.path().join("pidfd-table.json")) + .with_broker_reap_log(Arc::clone(&broker_reap_log)), + ), + broker_reap_log, + metrics_registry: Arc::new(metrics::Registry::new()), + daemon_audit: Arc::new(daemon_audit::DaemonAuditLog::no_op()), + exec_sessions: Arc::new(exec_session::SessionTable::new( + exec_session::ExecSessionCaps::default(), + )), + gateway_display: new_gateway_display_runtime_for_tests(), + conn_semaphore: concurrency::ConnSemaphore::new(8), + op_locks: concurrency::OpLockManager::new(), + public_status_read_model: Arc::new(PublicStatusReadModel::new()), + console_sessions: Arc::new(Mutex::new(console_session::ConsoleSessionTable::default())), + security_key_sessions: Arc::new(parking_lot::Mutex::new( + security_key::SkSessionTable::default(), + )), + unsafe_local_helpers: Arc::new(unsafe_local_helper::HelperRegistry::new(0, [])), + }; + (state, dir) + } + + fn catalog_entry( + provider: WorkloadProviderKind, + workload_id: &str, + ) -> workload_dispatch::CatalogEntry { + let realm_id = RealmId::parse("work").unwrap(); + let mut identity = WorkloadIdentity::new( + WorkloadId::parse(workload_id).unwrap(), + realm_id.clone(), + RealmPath::new(vec![realm_id]).unwrap(), + WorkloadTarget::parse(&format!("{workload_id}.work.d2b")).unwrap(), + ); + let unsafe_local = provider == WorkloadProviderKind::UnsafeLocal; + if unsafe_local { + identity.runtime_kind = Some(ContractId::parse("unsafe-local").unwrap()); + identity.provider_id = Some(ContractId::parse("unsafe-local").unwrap()); + } else { + identity.legacy_vm_name = Some(ContractId::parse("corp-vm").unwrap()); + identity.runtime_kind = Some(ContractId::parse("nixos").unwrap()); + identity.provider_id = Some(ContractId::parse("local-cloud-hypervisor").unwrap()); + } + workload_dispatch::CatalogEntry { + metadata: LauncherWorkloadSummary { + identity, + provider_kind: provider, + execution_posture: WorkloadExecutionPosture { + isolation: if unsafe_local { + IsolationPosture::UnsafeLocal + } else { + IsolationPosture::VirtualMachine + }, + environment: if unsafe_local { + EnvironmentPosture::SystemdUserManagerAmbient + } else { + EnvironmentPosture::RuntimeManaged + }, + display_environment: if unsafe_local { + DisplayEnvironmentPosture::WaylandProxyOnly + } else { + DisplayEnvironmentPosture::RuntimeManaged + }, + execution_identity: if unsafe_local { + ExecutionIdentityPosture::AuthenticatedRequesterUid + } else { + ExecutionIdentityPosture::WorkloadUser + }, + session_persistence: if unsafe_local { + SessionPersistencePosture::UserManagerLifetime + } else { + SessionPersistencePosture::RuntimeManaged + }, + }, + label: "Configured workload".to_owned(), + icon: LauncherIcon::default(), + realm_accent_color: "#336699".to_owned(), + launcher_enabled: true, + default_item_id: Some(ProtocolToken::parse("launch").unwrap()), + capabilities: CapabilitySet::default(), + items: vec![LauncherItemSummary { + id: ProtocolToken::parse("launch").unwrap(), + name: "Launch".to_owned(), + icon: LauncherIcon::default(), + kind: LauncherItemKind::Exec, + graphical: false, + capabilities: CapabilitySet::default(), + }], + }, + route: if unsafe_local { + workload_dispatch::WorkloadRoute::UnsafeLocal + } else { + workload_dispatch::WorkloadRoute::LocalVm { + vm: "corp-vm".to_owned(), + } + }, + } + } + + fn private_artifact(entries: &[workload_dispatch::CatalogEntry]) -> UnsafeLocalWorkloadsJson { + let item = || { + UnsafeLocalLauncherItem::Exec(UnsafeLocalExecItem { + id: ProtocolToken::parse("launch").unwrap(), + name: "Launch".to_owned(), + icon: LauncherIcon::default(), + argv: ConfiguredArgv::new(vec!["configured-bin".to_owned()]).unwrap(), + graphical: false, + }) + }; + UnsafeLocalWorkloadsJson { + schema_version: UNSAFE_LOCAL_WORKLOADS_SCHEMA_VERSION.to_owned(), + workloads: entries + .iter() + .filter(|entry| { + matches!(entry.route, workload_dispatch::WorkloadRoute::UnsafeLocal) + }) + .map(|entry| UnsafeLocalWorkload { + identity: entry.metadata.identity.clone(), + default_item_id: Some(ProtocolToken::parse("launch").unwrap()), + items: vec![item()], + shell: None, + }) + .collect(), + local_vm_workloads: entries + .iter() + .filter(|entry| { + matches!( + entry.route, + workload_dispatch::WorkloadRoute::LocalVm { .. } + ) + }) + .map(|entry| LocalVmConfiguredWorkload { + identity: entry.metadata.identity.clone(), + default_item_id: Some(ProtocolToken::parse("launch").unwrap()), + items: vec![item()], + }) + .collect(), + } + } + + fn launch_args( + entry: &workload_dispatch::CatalogEntry, + item_id: &str, + operation_id: &str, + ) -> public_wire::LauncherExecArgs { + public_wire::LauncherExecArgs { + target: entry.metadata.identity.canonical_target.clone(), + item_id: ProtocolToken::parse(item_id).unwrap(), + operation_id: OperationId::parse(operation_id).unwrap(), + } + } + + fn current_process_entry() -> PidfdEntry { + let pid = std::process::id() as i32; + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).expect("read current stat"); + let start_time_ticks = + supervisor::state::parse_proc_stat_starttime(&stat).expect("parse start time"); + PidfdEntry { + pidfd: File::open("/dev/null").expect("dummy pidfd").into(), + pid, + start_time_ticks, + } + } + + fn captured_events(state: &ServerState) -> Vec { + state + .daemon_audit + .captured + .lock() + .expect("audit capture") + .iter() + .map(|line| serde_json::from_str(line).expect("captured audit json")) + .collect() + } + + #[test] + fn workload_availability_counts_inventory_and_clears_stale_tuples() { + let registry = metrics::Registry::new(); + let unsafe_a = catalog_entry(WorkloadProviderKind::UnsafeLocal, "browser"); + let unsafe_b = catalog_entry(WorkloadProviderKind::UnsafeLocal, "editor"); + let first = [ + ObservedWorkload { + entry: &unsafe_a, + state: WorkloadState::Stopped, + availability: public_wire::WorkloadAvailability::Ready, + }, + ObservedWorkload { + entry: &unsafe_b, + state: WorkloadState::Stopped, + availability: public_wire::WorkloadAvailability::Ready, + }, + ]; + record_workload_availability_metrics(®istry, &first); + let rendered = registry.render(); + assert!(rendered.contains( + "d2b_daemon_workload_availability{provider=\"unsafe-local\",component=\"launcher\",state=\"ready\"} 2" + )); + assert!(rendered.contains( + "d2b_daemon_workload_availability{provider=\"unsafe-local\",component=\"shell\",state=\"not-applicable\"} 2" + )); + + let local = catalog_entry(WorkloadProviderKind::LocalVm, "terminal"); + record_workload_availability_metrics( + ®istry, + &[ObservedWorkload { + entry: &local, + state: WorkloadState::Stopped, + availability: public_wire::WorkloadAvailability::Degraded, + }], + ); + let rendered = registry.render(); + assert!(rendered.contains( + "d2b_daemon_workload_availability{provider=\"unsafe-local\",component=\"launcher\",state=\"ready\"} 0" + )); + assert!(rendered.contains( + "d2b_daemon_workload_availability{provider=\"local-vm\",component=\"launcher\",state=\"degraded\"} 1" + )); + assert!(!rendered.contains("browser")); + assert!(!rendered.contains("editor")); + assert!(!rendered.contains("terminal")); + } + + #[test] + fn dispatch_workload_denies_before_catalog_or_backend_side_effects() { + let (mut state, _dir) = test_state(); + state.config.artifacts.bundle_path = + PathBuf::from("/does-not-exist/authz-must-short-circuit.json"); + let peer = PeerIdentity { + role: PeerRole::HostShutdown, + uid: 9001, + }; + let error = dispatch_workload( + &state, + &peer, + public_wire::WorkloadOp::List(public_wire::WorkloadListArgs { realm: None }), + ) + .expect_err("non-launcher is denied"); + assert!(matches!( + error, + TypedError::AuthzNotALauncher { peer_uid: 9001 } + )); + assert!( + state + .metrics_registry + .render() + .lines() + .all(|line| { !line.starts_with("d2b_daemon_workload_availability{") }) + ); + assert!(captured_events(&state).is_empty()); + } + + #[test] + fn workload_runtime_status_maps_local_runner_and_helper_availability() { + let (state, _dir) = test_state(); + let local = catalog_entry(WorkloadProviderKind::LocalVm, "browser"); + assert_eq!( + workload_runtime_status(&state, 1000, &local), + ( + WorkloadState::Stopped, + public_wire::WorkloadAvailability::Ready + ) + ); + state + .pidfd_table + .register( + "corp-vm".to_owned(), + "vm".to_owned(), + current_process_entry(), + ) + .expect("register live runner"); + assert_eq!( + workload_runtime_status(&state, 1000, &local), + ( + WorkloadState::Running, + public_wire::WorkloadAvailability::Ready + ) + ); + + let unsafe_local = catalog_entry(WorkloadProviderKind::UnsafeLocal, "editor"); + assert_eq!( + workload_runtime_status(&state, 1000, &unsafe_local), + ( + WorkloadState::Stopped, + public_wire::WorkloadAvailability::HelperUnavailable + ) + ); + assert_eq!( + unsafe_local_workload_availability( + unsafe_local_helper::HelperAvailability::Ready, + None + ), + public_wire::WorkloadAvailability::Ready + ); + assert_eq!( + unsafe_local_workload_availability( + unsafe_local_helper::HelperAvailability::Stale, + None + ), + public_wire::WorkloadAvailability::HelperStale + ); + assert_eq!( + unsafe_local_workload_availability( + unsafe_local_helper::HelperAvailability::Ready, + Some(d2b_contracts::unsafe_local_wire::HelperFailureCode::WaylandUnavailable) + ), + public_wire::WorkloadAvailability::WaylandUnavailable + ); + } + + fn assert_single_refusal( + state: &ServerState, + entry: workload_dispatch::CatalogEntry, + private: &UnsafeLocalWorkloadsJson, + args: public_wire::LauncherExecArgs, + uid: u32, + expected_kind: &str, + ) { + let catalog = workload_dispatch::WorkloadCatalog::from_test_entries([entry.clone()]); + let error = prepare_workload_launch(state, uid, &catalog, Some(private), &args) + .expect_err("launch preflight must refuse"); + assert_eq!(error.kind(), expected_kind); + let rendered = state.metrics_registry.render(); + assert!(rendered.contains( + "d2b_daemon_workload_lifecycle_total{provider=\"unsafe-local\",operation=\"launcher-exec\",outcome=\"refused\"} 1" + )); + let events = captured_events(state); + assert_eq!(events.len(), 1, "one launch refusal audit"); + assert_eq!(events[0]["event"]["kind"], "workload_launcher"); + assert_eq!(events[0]["event"]["result"], "refused"); + assert_eq!( + events[0]["event"]["target"], + entry.metadata.identity.canonical_target.to_canonical() + ); + } + + #[test] + fn launch_resolution_refusals_emit_exactly_one_bounded_signal_and_audit() { + let base = catalog_entry(WorkloadProviderKind::UnsafeLocal, "browser"); + + let (state, _dir) = test_state(); + let private = private_artifact(std::slice::from_ref(&base)); + let args = launch_args(&base, "missing", "item-refusal-observability"); + assert_single_refusal( + &state, + base.clone(), + &private, + args, + 61001, + "workload-launcher-item-not-found", + ); + let event = captured_events(&state).pop().unwrap(); + assert_eq!(event["event"]["item_id"], "unknown"); + assert!(!event.to_string().contains("missing")); + + let (state, _dir) = test_state(); + let mut disabled = base.clone(); + disabled.metadata.launcher_enabled = false; + assert_single_refusal( + &state, + disabled.clone(), + &private_artifact(&[disabled.clone()]), + launch_args(&disabled, "launch", "launcher-disabled-observability"), + 61002, + "workload-launcher-disabled", + ); + + let (state, _dir) = test_state(); + let mut mismatch = private_artifact(std::slice::from_ref(&base)); + let UnsafeLocalLauncherItem::Exec(exec) = &mut mismatch.workloads[0].items[0] else { + unreachable!() + }; + exec.graphical = true; + assert_single_refusal( + &state, + base.clone(), + &mismatch, + launch_args(&base, "launch", "configured-mismatch-observability"), + 61003, + "workload-configured-item-mismatch", + ); + } + + #[test] + fn launch_ledger_refusals_emit_once_for_conflict_in_progress_and_capacity() { + let entry = catalog_entry(WorkloadProviderKind::UnsafeLocal, "browser"); + let private = private_artifact(std::slice::from_ref(&entry)); + let target = &entry.metadata.identity.canonical_target; + let item = ProtocolToken::parse("launch").unwrap(); + + let (state, _dir) = test_state(); + let conflict_op = "observability-conflict"; + workload_dispatch::begin_launch( + 62001, + conflict_op, + target, + &ProtocolToken::parse("other").unwrap(), + ) + .unwrap(); + assert_single_refusal( + &state, + entry.clone(), + &private, + launch_args(&entry, "launch", conflict_op), + 62001, + "workload-operation-conflict", + ); + workload_dispatch::abort_launch(62001, conflict_op); + + let (state, _dir) = test_state(); + let active_op = "observability-in-progress"; + workload_dispatch::begin_launch(62002, active_op, target, &item).unwrap(); + assert_single_refusal( + &state, + entry.clone(), + &private, + launch_args(&entry, "launch", active_op), + 62002, + "workload-launch-queue-full", + ); + workload_dispatch::abort_launch(62002, active_op); + + let (state, _dir) = test_state(); + for index in 0..64 { + workload_dispatch::begin_launch( + 62003, + &format!("observability-capacity-{index}"), + target, + &item, + ) + .unwrap(); } - wire::Request::Switch(req) => dispatch_broker_switch(state, req), - wire::Request::Boot(req) => dispatch_broker_boot(state, req), - wire::Request::Test(req) => dispatch_broker_test(state, req), - wire::Request::Rollback(req) => dispatch_broker_rollback(state, req), - wire::Request::Gc(req) => dispatch_broker_gc(state, req), - wire::Request::KeysRotate(req) => dispatch_broker_keys_rotate(state, req), - wire::Request::Trust(req) => dispatch_broker_trust(state, req), - wire::Request::RotateKnownHost(req) => dispatch_broker_rotate_known_host(state, req), - wire::Request::UsbipBind(req) => dispatch_broker_usbip_bind(state, req), - wire::Request::UsbipUnbind(req) => dispatch_broker_usbip_unbind(state, req), - wire::Request::UsbipProbe => dispatch_broker_usbip_probe(state), - wire::Request::StoreVerify(req) => dispatch_broker_store_verify(state, req), - wire::Request::Migrate(req) => dispatch_broker_run_migrate(state, req), - wire::Request::HostPrepare(req) => dispatch_broker_host_prepare(state, req), - wire::Request::HostDestroy(req) => dispatch_broker_host_destroy(state, req), - wire::Request::HostInstall(req) => dispatch_broker_run_host_install(state, req), - wire::Request::HostReconcile(req) => dispatch_broker_host_reconcile(state, req), - wire::Request::ReadGuestConfig(req) => dispatch_read_guest_config(state, req), - // Attached Exec::Start is intercepted in `handle_connection` (it takes - // over the connection as the owner connection and is handled on a - // spawned worker off the serial accept loop). Detached management ops - // are ordinary one-shot requests. - wire::Request::Exec(op) => dispatch_exec_management(state, peer, op), - wire::Request::Shell(op) => dispatch_shell_management(state, peer, op), - wire::Request::Console(op) => dispatch_console(state, peer, op), - wire::Request::GatewayDisplay(op) => dispatch_gateway_display(state, peer, op), - wire::Request::Audio(op) => { - if !matches!(op, public_wire::AudioOp::Status(_)) - && !matches!(peer.role, PeerRole::Admin) - { - return Err(TypedError::AuthzNotAdmin { - verb: "audio".to_owned(), - }); - } - audio_dispatch::dispatch_audio(state, op) + assert_single_refusal( + &state, + entry, + &private, + launch_args( + &catalog_entry(WorkloadProviderKind::UnsafeLocal, "browser"), + "launch", + "observability-capacity-overflow", + ), + 62003, + "workload-launch-queue-full", + ); + for index in 0..64 { + workload_dispatch::abort_launch(62003, &format!("observability-capacity-{index}")); } } } @@ -6829,6 +7984,40 @@ const SHELL_POLL_SLACK: Duration = Duration::from_secs(2); const SHELL_OWNER_INFLIGHT_CAP: usize = 64; const SHELL_METRIC: &str = "d2b_daemon_guest_control_shell_total"; const SHELL_SUBSYSTEM: &str = "guest-control-shell"; +const SHELL_LIFECYCLE_METRIC: &str = "d2b_daemon_shell_lifecycle_total"; +const SHELL_LIFECYCLE_OPERATIONS: &[&str] = + &["list", "create", "attach", "detach", "kill", "close"]; +const SHELL_LIFECYCLE_OUTCOMES: &[&str] = + &["requested", "management", "established", "closed", "error"]; +const SHELL_LIFECYCLE_ERRORS: &[&str] = &[ + "none", + "transport", + "auth", + "protocol", + "timeout", + "capability", + "stale-session", + "capacity", + "already-attached", + "not-found", + "output-gap", + "offset-mismatch", + "terminal-closed", + "invalid-size", + "helper-unavailable", + "helper-stale", + "user-manager", + "environment", + "executable", + "scope-create", + "scope-identity", + "graphical-session", + "wayland", + "proxy", + "operation-conflict", + "guest", + "internal", +]; fn shell_failed(kind: crate::typed_error::GuestControlShellErrorKind) -> TypedError { TypedError::GuestControlShellFailed { kind } @@ -6943,10 +8132,76 @@ fn shell_error_kind_label(error: &TypedError) -> &'static str { K::GuestError => "guest", K::Internal => "internal", }, + TypedError::UnsafeLocalShellFailed { kind } => { + use crate::typed_error::UnsafeLocalShellErrorKind as UnsafeKind; + match kind { + UnsafeKind::FeatureUnavailable => "capability", + UnsafeKind::HelperUnavailable => "helper-unavailable", + UnsafeKind::HelperStale => "helper-stale", + UnsafeKind::QueueFull => "capacity", + UnsafeKind::Timeout | UnsafeKind::FirstClientTimeout => "timeout", + UnsafeKind::Protocol => "protocol", + UnsafeKind::OperationConflict => "operation-conflict", + UnsafeKind::UserManagerUnavailable => "user-manager", + UnsafeKind::EnvironmentInvalid => "environment", + UnsafeKind::ExecutableUnavailable => "executable", + UnsafeKind::ScopeCreateFailed => "scope-create", + UnsafeKind::ScopeIdentityMismatch => "scope-identity", + UnsafeKind::GraphicalSessionInactive => "graphical-session", + UnsafeKind::WaylandUnavailable => "wayland", + UnsafeKind::ProxyUnavailable => "proxy", + UnsafeKind::ShellUnavailable => "capability", + UnsafeKind::NotFound => "not-found", + UnsafeKind::AlreadyAttached => "already-attached", + UnsafeKind::OutputGap => "output-gap", + UnsafeKind::OffsetMismatch => "offset-mismatch", + UnsafeKind::TerminalClosed => "terminal-closed", + UnsafeKind::InvalidSize => "invalid-size", + UnsafeKind::StaleSession => "stale-session", + UnsafeKind::Internal => "internal", + } + } _ => "internal", } } +fn shell_metric_for_provider( + state: &ServerState, + provider: shell_backend::ShellProvider, + operation: &'static str, + outcome: &'static str, + error_kind: &'static str, +) { + debug_assert!(SHELL_LIFECYCLE_OPERATIONS.contains(&operation)); + debug_assert!(SHELL_LIFECYCLE_OUTCOMES.contains(&outcome)); + debug_assert!(SHELL_LIFECYCLE_ERRORS.contains(&error_kind)); + state.metrics_registry.counter_inc( + SHELL_LIFECYCLE_METRIC, + &[ + ("provider", provider.label()), + ("component", "shell"), + ("operation", operation), + ("outcome", outcome), + ("error_kind", error_kind), + ], + ); + if provider == shell_backend::ShellProvider::GuestControl { + shell_metric(state, outcome, error_kind); + } +} + +fn shell_provider_for_error(error: &TypedError) -> shell_backend::ShellProvider { + match error { + TypedError::UnsafeLocalShellFailed { .. } => shell_backend::ShellProvider::UnsafeLocal, + TypedError::RuntimeCapabilityUnsupported { runtime_kind, .. } + if runtime_kind == "unsafe-local" => + { + shell_backend::ShellProvider::UnsafeLocal + } + _ => shell_backend::ShellProvider::GuestControl, + } +} + fn shell_ref_digest(parts: &[&str]) -> String { let mut hasher = Sha256::new(); for part in parts { @@ -6956,82 +8211,349 @@ fn shell_ref_digest(parts: &[&str]) -> String { format!("{:x}", hasher.finalize())[..16].to_owned() } +fn unsafe_local_shell_feature_negotiated(capabilities: &[d2b_contracts::FeatureFlag]) -> bool { + capabilities + .iter() + .any(|feature| feature.known() == Some(KnownFeatureFlag::UnsafeLocalShellV1)) +} + +fn resolve_negotiated_unsafe_local_shell<'a>( + state: &ServerState, + op: &'a public_wire::ShellOp, +) -> Option<(&'a str, &'static str, workload_dispatch::ResolvedShell)> { + use workload_dispatch::WorkloadRoute; + + let (target, operation) = match op { + public_wire::ShellOp::Attach(args) => Some((args.vm.as_str(), "attach")), + public_wire::ShellOp::List(args) => Some((args.vm.as_str(), "list")), + public_wire::ShellOp::Detach(args) => Some((args.vm.as_str(), "detach")), + public_wire::ShellOp::Kill(args) => Some((args.vm.as_str(), "kill")), + public_wire::ShellOp::WriteStdin(_) + | public_wire::ShellOp::ReadOutput(_) + | public_wire::ShellOp::Resize(_) + | public_wire::ShellOp::Wait(_) + | public_wire::ShellOp::CloseStdin(_) + | public_wire::ShellOp::CloseAttach(_) => None, + }?; + let resolved = resolve_shell_target(state, target).ok()?; + matches!(resolved.route, WorkloadRoute::UnsafeLocal).then_some((target, operation, resolved)) +} + fn dispatch_shell_management( state: &ServerState, peer: &PeerIdentity, op: public_wire::ShellOp, ) -> Result { + use d2b_contracts::unsafe_local_wire::{HelperShellRequest, HelperShellResponse}; + use workload_dispatch::WorkloadRoute; + + if !matches!(peer.role, PeerRole::Admin) { + return Err(TypedError::AuthzNotAdmin { + verb: "shell".to_owned(), + }); + } let response = match op { public_wire::ShellOp::List(args) => { - let result = run_guest_shell_management(state, args.vm.as_str(), |client, metadata| { - let mut request = pb::ShellListRequest::new(); - request.metadata = protobuf::MessageField::some(metadata); - async move { - let response: pb::ShellListResponse = client - .unary_with_timeout("ShellList", request, SHELL_MANAGEMENT_TIMEOUT) - .await - .map_err(map_shell_health_error)?; - shell_error_to_typed(response.error.as_ref())?; - map_shell_list_response(response) + let resolved = resolve_shell_target(state, &args.vm).inspect_err(|error| { + record_unresolved_shell_failure(state, peer.uid, &args.vm, "list", error); + })?; + let (result, provider, target, operation_digest) = match resolved.route.clone() { + WorkloadRoute::LocalVm { vm } => { + let result = run_guest_shell_management(state, &vm, |client, metadata| { + let mut request = pb::ShellListRequest::new(); + request.metadata = protobuf::MessageField::some(metadata); + async move { + let response: pb::ShellListResponse = client + .unary_with_timeout("ShellList", request, SHELL_MANAGEMENT_TIMEOUT) + .await + .map_err(map_shell_health_error)?; + shell_error_to_typed(response.error.as_ref())?; + map_shell_list_response(response) + } + }) + .inspect_err(|error| { + record_shell_dispatch_failure( + state, + peer.uid, + &vm, + shell_backend::ShellProvider::GuestControl, + "list", + None, + error, + ); + })?; + (result, shell_backend::ShellProvider::GuestControl, vm, None) } - }) - .inspect_err(|error| shell_metric(state, "error", shell_error_kind_label(error)))?; - shell_metric(state, "management", "none"); + WorkloadRoute::UnsafeLocal => { + let (identity, policy) = unsafe_shell_request_parts(&resolved)?; + let operation_id = new_internal_shell_operation_id()?; + let operation_digest = shell_ref_digest(&[operation_id.as_str()]); + let target = identity.canonical_target.to_canonical(); + let request = HelperShellRequest::List { + request_id: next_internal_shell_request_id(), + operation_id, + workload: identity, + policy, + }; + let response = dispatch_unsafe_shell_management(state, peer.uid, request) + .inspect_err(|error| { + record_shell_dispatch_failure( + state, + peer.uid, + &target, + shell_backend::ShellProvider::UnsafeLocal, + "list", + Some(operation_digest.clone()), + error, + ); + })?; + let HelperShellResponse::List(response) = response else { + return Err(shell_backend::unsafe_shell_failed( + typed_error::UnsafeLocalShellErrorKind::Protocol, + )); + }; + ( + response.result, + shell_backend::ShellProvider::UnsafeLocal, + target, + Some(operation_digest), + ) + } + WorkloadRoute::CapabilityUnavailable { provider } => { + let error = shell_route_capability_error(&args.vm, provider); + record_resolved_shell_failure( + state, peer.uid, &resolved, &args.vm, "list", None, &error, + ); + return Err(error); + } + }; + emit_provider_shell_audit( + state, + ProviderShellAudit { + target: &target, + peer_uid: peer.uid, + provider, + action: daemon_audit::ShellAuditAction::List, + result: daemon_audit::ShellAuditResult::Listed, + force: None, + operation_digest, + session_digest: None, + }, + ); + shell_metric_for_provider(state, provider, "list", "management", "none"); public_wire::ShellOpResponse::List(result) } public_wire::ShellOp::Detach(args) => { - let vm = args.vm.clone(); - let result = run_guest_shell_management(state, args.vm.as_str(), |client, metadata| { - let mut request = pb::ShellDetachRequest::new(); - request.metadata = protobuf::MessageField::some(metadata); - request.name = args.name.map(|name| name.as_str().to_owned()); - async move { - let response: pb::ShellDetachResponse = client - .unary_with_timeout("ShellDetach", request, SHELL_MANAGEMENT_TIMEOUT) - .await - .map_err(map_shell_health_error)?; - shell_error_to_typed(response.error.as_ref())?; - map_shell_detach_response(response) + let requested_target = args.vm.clone(); + let resolved = resolve_shell_target(state, &requested_target).inspect_err(|error| { + record_unresolved_shell_failure( + state, + peer.uid, + &requested_target, + "detach", + error, + ); + })?; + let (result, provider, target, operation_digest) = match resolved.route.clone() { + WorkloadRoute::LocalVm { vm } => { + let result = run_guest_shell_management(state, &vm, |client, metadata| { + let mut request = pb::ShellDetachRequest::new(); + request.metadata = protobuf::MessageField::some(metadata); + request.name = args.name.map(|name| name.as_str().to_owned()); + async move { + let response: pb::ShellDetachResponse = client + .unary_with_timeout( + "ShellDetach", + request, + SHELL_MANAGEMENT_TIMEOUT, + ) + .await + .map_err(map_shell_health_error)?; + shell_error_to_typed(response.error.as_ref())?; + map_shell_detach_response(response) + } + }) + .inspect_err(|error| { + record_shell_dispatch_failure( + state, + peer.uid, + &vm, + shell_backend::ShellProvider::GuestControl, + "detach", + None, + error, + ); + })?; + (result, shell_backend::ShellProvider::GuestControl, vm, None) } - }) - .inspect_err(|error| shell_metric(state, "error", shell_error_kind_label(error)))?; - emit_shell_management_audit( + WorkloadRoute::UnsafeLocal => { + let (identity, policy) = unsafe_shell_request_parts(&resolved)?; + let name = args.name.unwrap_or_else(|| policy.default_name.clone()); + let operation_id = new_internal_shell_operation_id()?; + let operation_digest = shell_ref_digest(&[operation_id.as_str()]); + let target = identity.canonical_target.to_canonical(); + let request = HelperShellRequest::Detach { + request_id: next_internal_shell_request_id(), + operation_id, + workload: identity, + policy, + name, + }; + let response = dispatch_unsafe_shell_management(state, peer.uid, request) + .inspect_err(|error| { + record_shell_dispatch_failure( + state, + peer.uid, + &target, + shell_backend::ShellProvider::UnsafeLocal, + "detach", + Some(operation_digest.clone()), + error, + ); + })?; + let HelperShellResponse::Detach(response) = response else { + return Err(shell_backend::unsafe_shell_failed( + typed_error::UnsafeLocalShellErrorKind::Protocol, + )); + }; + ( + response.result, + shell_backend::ShellProvider::UnsafeLocal, + target, + Some(operation_digest), + ) + } + WorkloadRoute::CapabilityUnavailable { provider } => { + let error = shell_route_capability_error(&requested_target, provider); + record_resolved_shell_failure( + state, + peer.uid, + &resolved, + &requested_target, + "detach", + None, + &error, + ); + return Err(error); + } + }; + let digest = shell_ref_digest(&[&target, result.resolved_name.as_str()]); + emit_provider_shell_audit( state, - peer.uid, - &vm, - daemon_audit::ShellAuditAction::Detach, - daemon_audit::ShellAuditResult::Detached, - &shell_ref_digest(&[&vm, result.resolved_name.as_str()]), + ProviderShellAudit { + target: &target, + peer_uid: peer.uid, + provider, + action: daemon_audit::ShellAuditAction::Detach, + result: daemon_audit::ShellAuditResult::Detached, + force: None, + operation_digest, + session_digest: Some(digest), + }, ); - shell_metric(state, "management", "none"); + shell_metric_for_provider(state, provider, "detach", "management", "none"); public_wire::ShellOpResponse::Detach(result) } public_wire::ShellOp::Kill(args) => { - let vm = args.vm.clone(); - let digest = shell_ref_digest(&[&vm, args.name.as_str()]); - let result = run_guest_shell_management(state, args.vm.as_str(), |client, metadata| { - let mut request = pb::ShellKillRequest::new(); - request.metadata = protobuf::MessageField::some(metadata); - request.name = args.name.as_str().to_owned(); - async move { - let response: pb::ShellKillResponse = client - .unary_with_timeout("ShellKill", request, SHELL_MANAGEMENT_TIMEOUT) - .await - .map_err(map_shell_health_error)?; - shell_error_to_typed(response.error.as_ref())?; - map_shell_kill_response(response) + let requested_target = args.vm.clone(); + let requested_name = args.name; + let resolved = resolve_shell_target(state, &requested_target).inspect_err(|error| { + record_unresolved_shell_failure(state, peer.uid, &requested_target, "kill", error); + })?; + let (result, provider, target, operation_digest) = match resolved.route.clone() { + WorkloadRoute::LocalVm { vm } => { + let guest_name = requested_name.clone(); + let result = run_guest_shell_management(state, &vm, |client, metadata| { + let mut request = pb::ShellKillRequest::new(); + request.metadata = protobuf::MessageField::some(metadata); + request.name = guest_name.as_str().to_owned(); + async move { + let response: pb::ShellKillResponse = client + .unary_with_timeout("ShellKill", request, SHELL_MANAGEMENT_TIMEOUT) + .await + .map_err(map_shell_health_error)?; + shell_error_to_typed(response.error.as_ref())?; + map_shell_kill_response(response) + } + }) + .inspect_err(|error| { + record_shell_dispatch_failure( + state, + peer.uid, + &vm, + shell_backend::ShellProvider::GuestControl, + "kill", + None, + error, + ); + })?; + (result, shell_backend::ShellProvider::GuestControl, vm, None) } - }) - .inspect_err(|error| shell_metric(state, "error", shell_error_kind_label(error)))?; - emit_shell_management_audit( + WorkloadRoute::UnsafeLocal => { + let (identity, policy) = unsafe_shell_request_parts(&resolved)?; + let operation_id = new_internal_shell_operation_id()?; + let operation_digest = shell_ref_digest(&[operation_id.as_str()]); + let target = identity.canonical_target.to_canonical(); + let request = HelperShellRequest::Kill { + request_id: next_internal_shell_request_id(), + operation_id, + workload: identity, + policy, + name: requested_name.clone(), + }; + let response = dispatch_unsafe_shell_management(state, peer.uid, request) + .inspect_err(|error| { + record_shell_dispatch_failure( + state, + peer.uid, + &target, + shell_backend::ShellProvider::UnsafeLocal, + "kill", + Some(operation_digest.clone()), + error, + ); + })?; + let HelperShellResponse::Kill(response) = response else { + return Err(shell_backend::unsafe_shell_failed( + typed_error::UnsafeLocalShellErrorKind::Protocol, + )); + }; + ( + response.result, + shell_backend::ShellProvider::UnsafeLocal, + target, + Some(operation_digest), + ) + } + WorkloadRoute::CapabilityUnavailable { provider } => { + let error = shell_route_capability_error(&requested_target, provider); + record_resolved_shell_failure( + state, + peer.uid, + &resolved, + &requested_target, + "kill", + None, + &error, + ); + return Err(error); + } + }; + let digest = shell_ref_digest(&[&target, requested_name.as_str()]); + emit_provider_shell_audit( state, - peer.uid, - &vm, - daemon_audit::ShellAuditAction::Kill, - daemon_audit::ShellAuditResult::Killed, - &digest, + ProviderShellAudit { + target: &target, + peer_uid: peer.uid, + provider, + action: daemon_audit::ShellAuditAction::Kill, + result: daemon_audit::ShellAuditResult::Killed, + force: None, + operation_digest, + session_digest: Some(digest), + }, ); - shell_metric(state, "management", "none"); + shell_metric_for_provider(state, provider, "kill", "management", "none"); public_wire::ShellOpResponse::Kill(result) } public_wire::ShellOp::Attach(_) @@ -7042,26 +8564,256 @@ fn dispatch_shell_management( | public_wire::ShellOp::CloseStdin(_) | public_wire::ShellOp::CloseAttach(_) => return Err(shell_protocol_failed()), }; - Ok(wire::shell_response(&response)) + Ok(wire::shell_response(&response)) +} + +fn unsafe_shell_request_parts( + resolved: &workload_dispatch::ResolvedShell, +) -> Result< + ( + d2b_core::workload_identity::WorkloadIdentity, + d2b_contracts::unsafe_local_wire::HelperShellPolicy, + ), + TypedError, +> { + let identity = resolved.identity.clone().ok_or_else(|| { + shell_backend::unsafe_shell_failed(typed_error::UnsafeLocalShellErrorKind::Protocol) + })?; + let policy = resolved.policy.clone().ok_or_else(|| { + shell_backend::unsafe_shell_failed(typed_error::UnsafeLocalShellErrorKind::Protocol) + })?; + Ok((identity, policy)) +} + +fn dispatch_unsafe_shell_management( + state: &ServerState, + peer_uid: u32, + request: d2b_contracts::unsafe_local_wire::HelperShellRequest, +) -> Result { + match state + .unsafe_local_helpers + .dispatch_shell(peer_uid, request) + .map_err(map_shell_helper_registry_error)? + { + unsafe_local_helper::HelperShellReply::Management(response) => Ok(response), + unsafe_local_helper::HelperShellReply::Terminal { .. } => Err( + shell_backend::unsafe_shell_failed(typed_error::UnsafeLocalShellErrorKind::Protocol), + ), + } +} + +fn shell_route_capability_error( + target: &str, + provider: d2b_realm_core::WorkloadProviderKind, +) -> TypedError { + TypedError::RuntimeCapabilityUnsupported { + vm: target.to_owned(), + runtime_kind: workload_provider_label(provider).to_owned(), + capability: "persistent-shell".to_owned(), + verb: "shell".to_owned(), + } +} + +fn record_shell_dispatch_failure( + state: &ServerState, + peer_uid: u32, + target: &str, + provider: shell_backend::ShellProvider, + operation: &'static str, + operation_digest: Option, + error: &TypedError, +) { + shell_metric_for_provider( + state, + provider, + operation, + "error", + shell_error_kind_label(error), + ); + emit_provider_shell_audit( + state, + ProviderShellAudit { + target, + peer_uid, + provider, + action: daemon_audit::ShellAuditAction::Failure, + result: daemon_audit::ShellAuditResult::Refused, + force: None, + operation_digest, + session_digest: None, + }, + ); +} + +fn record_unresolved_shell_failure( + state: &ServerState, + peer_uid: u32, + _requested_target: &str, + operation: &'static str, + error: &TypedError, +) { + let provider = shell_provider_for_error(error); + shell_metric_for_provider( + state, + provider, + operation, + "error", + shell_error_kind_label(error), + ); + emit_shell_failure_audit(state, peer_uid, _requested_target, error); +} + +fn record_resolved_shell_failure( + state: &ServerState, + peer_uid: u32, + resolved: &workload_dispatch::ResolvedShell, + _requested_target: &str, + operation: &'static str, + operation_digest: Option, + error: &TypedError, +) { + use workload_dispatch::WorkloadRoute; + + let (provider, target) = match &resolved.route { + WorkloadRoute::UnsafeLocal => ( + shell_backend::ShellProvider::UnsafeLocal, + resolved + .identity + .as_ref() + .map(|identity| identity.canonical_target.to_canonical()) + .unwrap_or_else(|| unresolved_shell_audit_target().to_owned()), + ), + WorkloadRoute::LocalVm { vm } => (shell_backend::ShellProvider::GuestControl, vm.clone()), + WorkloadRoute::CapabilityUnavailable { provider } => ( + if *provider == d2b_realm_core::WorkloadProviderKind::UnsafeLocal { + shell_backend::ShellProvider::UnsafeLocal + } else { + shell_backend::ShellProvider::GuestControl + }, + resolved + .identity + .as_ref() + .map(|identity| identity.canonical_target.to_canonical()) + .unwrap_or_else(|| unresolved_shell_audit_target().to_owned()), + ), + }; + record_shell_dispatch_failure( + state, + peer_uid, + &target, + provider, + operation, + operation_digest, + error, + ); } -fn emit_shell_management_audit( - state: &ServerState, +fn shell_audit_provider( + provider: shell_backend::ShellProvider, +) -> daemon_audit::ShellAuditProvider { + match provider { + shell_backend::ShellProvider::GuestControl => { + daemon_audit::ShellAuditProvider::GuestControl + } + shell_backend::ShellProvider::UnsafeLocal => daemon_audit::ShellAuditProvider::UnsafeLocal, + } +} + +struct ProviderShellAudit<'a> { + target: &'a str, peer_uid: u32, - vm: &str, + provider: shell_backend::ShellProvider, action: daemon_audit::ShellAuditAction, result: daemon_audit::ShellAuditResult, - shell_ref_digest: &str, -) { + force: Option, + operation_digest: Option, + session_digest: Option, +} + +fn emit_provider_shell_audit(state: &ServerState, event: ProviderShellAudit<'_>) { let _ = state .daemon_audit - .write_event(&daemon_audit::DaemonEvent::GuestControlShellDetached { - vm: vm.to_owned(), + .write_event(&daemon_audit::DaemonEvent::ShellLifecycle { + target: event.target.to_owned(), + peer_uid: event.peer_uid, + provider: shell_audit_provider(event.provider), + action: event.action, + result: event.result, + force: event.force, + operation_digest: event.operation_digest, + session_digest: event.session_digest, + }); +} + +fn emit_shell_attach_audit( + state: &ServerState, + peer_uid: u32, + established: &shell_backend::EstablishedShell, + shell_ref_digest: &str, + force: bool, +) { + emit_provider_shell_audit( + state, + ProviderShellAudit { + target: &established.target, + peer_uid, + provider: established.provider, + action: daemon_audit::ShellAuditAction::Attach, + result: daemon_audit::ShellAuditResult::Attached, + force: Some(force), + operation_digest: established.operation_digest.clone(), + session_digest: Some(shell_ref_digest.to_owned()), + }, + ); +} + +fn emit_shell_close_audit( + state: &ServerState, + peer_uid: u32, + established: &shell_backend::EstablishedShell, + result: daemon_audit::ShellAuditResult, + shell_ref_digest: &str, +) { + emit_provider_shell_audit( + state, + ProviderShellAudit { + target: &established.target, peer_uid, - action, + provider: established.provider, + action: daemon_audit::ShellAuditAction::Close, result, - shell_ref_digest: shell_ref_digest.to_owned(), - }); + force: None, + operation_digest: established.operation_digest.clone(), + session_digest: Some(shell_ref_digest.to_owned()), + }, + ); +} + +fn emit_shell_failure_audit( + state: &ServerState, + peer_uid: u32, + _requested_target: &str, + error: &TypedError, +) { + let provider = shell_provider_for_error(error); + let target = unresolved_shell_audit_target(); + emit_provider_shell_audit( + state, + ProviderShellAudit { + target, + peer_uid, + provider, + action: daemon_audit::ShellAuditAction::Failure, + result: daemon_audit::ShellAuditResult::Refused, + force: None, + operation_digest: None, + session_digest: None, + }, + ); +} + +fn unresolved_shell_audit_target() -> &'static str { + "unresolved" } fn run_shell_owner( @@ -7092,95 +8844,72 @@ fn run_shell_owner( stream.as_ref(), &wire::error_frame_with_id(first_op_id, &error), ); - shell_metric(&state, "error", shell_error_kind_label(&error)); - return; - } - }; - let (client, guest_boot_id, attach_response) = - match rt.block_on(establish_shell_owner_async(&state, &attach)) { - Ok(value) => value, - Err(error) => { - let _ = write_json_frame( - stream.as_ref(), - &wire::error_frame_with_id(first_op_id, &error), - ); - shell_metric(&state, "error", shell_error_kind_label(&error)); - return; - } - }; - if let Err(error) = shell_error_to_typed(attach_response.error.as_ref()) { - let _ = write_json_frame( - stream.as_ref(), - &wire::error_frame_with_id(first_op_id, &error), - ); - shell_metric(&state, "error", shell_error_kind_label(&error)); - return; - } - let session_id = match attach_response.session_id.clone() { - Some(session) => session, - None => { - tracing::warn!( - vm = %attach.vm, - resolved_name_valid = %public_wire::ShellName::new(&attach_response.resolved_name).is_ok(), - state = ?attach_response.state.enum_value(), - force_evicted = attach_response.force_evicted, - error_present = attach_response.error.is_some(), - "shell attach response missing session id" - ); - let _ = write_json_frame( - stream.as_ref(), - &wire::error_frame_with_id(first_op_id, &shell_protocol_failed()), - ); - shell_metric(&state, "error", "protocol"); + record_unresolved_shell_failure(&state, peer.uid, &attach.vm, "attach", &error); return; } }; - let initial_control_seq = attach_response.control_seq; - let owner_shell_ref_digest = shell_ref_digest(&[&attach.vm, &attach_response.resolved_name]); - let public_attach_result = match map_shell_attach_response(attach_response) { - Ok(value) => public_wire::ShellOpResponse::Attach(value), + let established = match rt.block_on(establish_shell_backend(&state, peer.uid, &attach)) { + Ok(value) => value, Err(error) => { let _ = write_json_frame( stream.as_ref(), &wire::error_frame_with_id(first_op_id, &error), ); - shell_metric(&state, "error", shell_error_kind_label(&error)); + match resolve_shell_target(&state, &attach.vm) { + Ok(resolved) => record_resolved_shell_failure( + &state, peer.uid, &resolved, &attach.vm, "attach", None, &error, + ), + Err(_) => { + record_unresolved_shell_failure(&state, peer.uid, &attach.vm, "attach", &error); + } + } return; } }; - let owner_resolved_name = match &public_attach_result { - public_wire::ShellOpResponse::Attach(result) => result.resolved_name.clone(), - _ => unreachable!("public_attach_result is always Attach"), - }; + let owner_resolved_name = established.attach.resolved_name.clone(); + let owner_shell_ref_digest = + shell_ref_digest(&[&established.target, owner_resolved_name.as_str()]); + let public_attach_result = public_wire::ShellOpResponse::Attach(established.attach.clone()); if write_json_frame( stream.as_ref(), &wire::shell_response_with_id(first_op_id, &public_attach_result), ) .is_err() { - shell_close_attach_best_effort_with_runtime( + let mut control_sequence = established.initial_control_sequence; + shell_backend::best_effort_close( + established.backend.as_ref(), rt.handle(), - &client, - &attach.vm, - &session_id, - &guest_boot_id, + &mut control_sequence, + ); + let error = shell_transport_failed(); + record_shell_dispatch_failure( + &state, + peer.uid, + &established.target, + established.provider, + "attach", + established.operation_digest.clone(), + &error, ); - shell_metric(&state, "error", "transport"); return; } - let _ = state - .daemon_audit - .write_event(&daemon_audit::DaemonEvent::GuestControlShellAttached { - vm: attach.vm.clone(), - peer_uid: peer.uid, - action: daemon_audit::ShellAuditAction::Attach, - result: daemon_audit::ShellAuditResult::Attached, - shell_ref_digest: owner_shell_ref_digest.clone(), - force: attach.force, - }); - shell_metric(&state, "established", "none"); + emit_shell_attach_audit( + &state, + peer.uid, + &established, + &owner_shell_ref_digest, + attach.force, + ); + shell_metric_for_provider( + &state, + established.provider, + "attach", + "established", + "none", + ); - let mut control_seq = initial_control_seq; + let mut control_seq = established.initial_control_sequence; let mut poll_tasks: Vec> = Vec::new(); let mut attachment_closed = false; while let Ok(frame) = read_frame(stream.as_ref()) { @@ -7197,7 +8926,10 @@ fn run_shell_owner( continue; } }; - if matches!(op, public_wire::ShellOp::ReadOutput(_)) { + if matches!( + op, + public_wire::ShellOp::ReadOutput(_) | public_wire::ShellOp::Wait(_) + ) { if poll_tasks.len() >= SHELL_OWNER_INFLIGHT_CAP { if write_json_frame( stream.as_ref(), @@ -7212,25 +8944,14 @@ fn run_shell_owner( } continue; } - let client = Arc::clone(&client); + let backend = Arc::clone(&established.backend); let poll_stream = Arc::clone(&stream); - let vm = attach.vm.clone(); - let session_id = session_id.clone(); - let guest_boot_id = guest_boot_id.clone(); let rt_handle = rt.handle().clone(); let task = std::thread::Builder::new() .name("d2b-shell-poll".to_owned()) .spawn(move || { let mut ignored_control_seq = 0; - let value = match handle_shell_owner_op( - &rt_handle, - client.as_ref(), - &vm, - &session_id, - &guest_boot_id, - &mut ignored_control_seq, - op, - ) { + let value = match backend.handle_op(&rt_handle, &mut ignored_control_seq, op) { Ok(Some(response)) => wire::shell_response_with_id(op_id, &response), Ok(None) => return, Err(error) => wire::error_frame_with_id(op_id, &error), @@ -7249,15 +8970,9 @@ fn run_shell_owner( continue; } let closes_owner = matches!(op, public_wire::ShellOp::CloseAttach(_)); - let response = handle_shell_owner_op( - rt.handle(), - &client, - &attach.vm, - &session_id, - &guest_boot_id, - &mut control_seq, - op, - ); + let response = established + .backend + .handle_op(rt.handle(), &mut control_seq, op); match response { Ok(Some(response)) => { if write_json_frame( @@ -7277,12 +8992,10 @@ fn run_shell_owner( Err(error) => { if closes_owner && matches!( - shell_close_attach_best_effort_with_runtime( + shell_backend::best_effort_close( + established.backend.as_ref(), rt.handle(), - &client, - &attach.vm, - &session_id, - &guest_boot_id + &mut control_seq ), daemon_audit::ShellAuditResult::Closed ) @@ -7312,24 +9025,20 @@ fn run_shell_owner( let close_result = if attachment_closed { daemon_audit::ShellAuditResult::Closed } else { - shell_close_attach_best_effort_with_runtime( + shell_backend::best_effort_close( + established.backend.as_ref(), rt.handle(), - &client, - &attach.vm, - &session_id, - &guest_boot_id, + &mut control_seq, ) }; - let _ = state - .daemon_audit - .write_event(&daemon_audit::DaemonEvent::GuestControlShellDetached { - vm: attach.vm.clone(), - peer_uid: peer.uid, - action: daemon_audit::ShellAuditAction::Detach, - result: close_result, - shell_ref_digest: owner_shell_ref_digest, - }); - shell_metric(&state, "closed", "none"); + emit_shell_close_audit( + &state, + peer.uid, + &established, + close_result, + &owner_shell_ref_digest, + ); + shell_metric_for_provider(&state, established.provider, "close", "closed", "none"); let _ = nix::sys::socket::shutdown( stream.as_ref().as_raw_fd(), nix::sys::socket::Shutdown::Both, @@ -7350,7 +9059,297 @@ fn synthetic_shell_close_attach_response( } } -async fn establish_shell_owner_async( +struct GuestShellBackend { + client: Arc, + vm: String, + session_id: String, + guest_boot_id: String, +} + +impl std::fmt::Debug for GuestShellBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GuestShellBackend") + .field("vm", &self.vm) + .field("session_id", &"") + .field("guest_boot_id", &"") + .finish_non_exhaustive() + } +} + +impl shell_backend::ShellBackend for GuestShellBackend { + fn handle_op( + &self, + runtime: &tokio::runtime::Handle, + control_sequence: &mut u64, + op: public_wire::ShellOp, + ) -> Result, TypedError> { + handle_shell_owner_op( + runtime, + self.client.as_ref(), + &self.vm, + &self.session_id, + &self.guest_boot_id, + control_sequence, + op, + ) + } + + fn close_attachment( + &self, + runtime: &tokio::runtime::Handle, + _control_sequence: &mut u64, + ) -> Result { + shell_close_attach_with_runtime( + runtime, + self.client.as_ref(), + &self.vm, + &self.session_id, + &self.guest_boot_id, + ) + } +} + +async fn establish_shell_backend( + state: &ServerState, + peer_uid: u32, + attach: &public_wire::ShellAttachArgs, +) -> Result { + use workload_dispatch::WorkloadRoute; + + let resolved = resolve_shell_target(state, &attach.vm)?; + + match resolved.route { + WorkloadRoute::LocalVm { vm } => { + let mut guest_attach = attach.clone(); + guest_attach.vm = vm.clone(); + let (client, guest_boot_id, response) = + establish_guest_shell_owner_async(state, &guest_attach).await?; + shell_error_to_typed(response.error.as_ref())?; + let session_id = response.session_id.clone().ok_or_else(|| { + tracing::warn!( + vm = %vm, + resolved_name_valid = %public_wire::ShellName::new(&response.resolved_name).is_ok(), + state = ?response.state.enum_value(), + force_evicted = response.force_evicted, + error_present = response.error.is_some(), + "shell attach response missing session id" + ); + shell_protocol_failed() + })?; + let initial_control_sequence = response.control_seq; + let attach = map_shell_attach_response(response)?; + Ok(shell_backend::EstablishedShell { + backend: Arc::new(GuestShellBackend { + client, + vm: vm.clone(), + session_id, + guest_boot_id, + }), + attach, + target: vm, + provider: shell_backend::ShellProvider::GuestControl, + operation_digest: None, + initial_control_sequence, + }) + } + WorkloadRoute::UnsafeLocal => { + let identity = resolved.identity.ok_or_else(|| { + shell_backend::unsafe_shell_failed(typed_error::UnsafeLocalShellErrorKind::Protocol) + })?; + let policy = resolved.policy.ok_or_else(|| { + shell_backend::unsafe_shell_failed(typed_error::UnsafeLocalShellErrorKind::Protocol) + })?; + let operation_id = new_internal_shell_operation_id()?; + let operation_digest = shell_ref_digest(&[operation_id.as_str()]); + emit_provider_shell_audit( + state, + ProviderShellAudit { + target: &identity.canonical_target.to_canonical(), + peer_uid, + provider: shell_backend::ShellProvider::UnsafeLocal, + action: daemon_audit::ShellAuditAction::Create, + result: daemon_audit::ShellAuditResult::Requested, + force: None, + operation_digest: Some(operation_digest.clone()), + session_digest: None, + }, + ); + shell_metric_for_provider( + state, + shell_backend::ShellProvider::UnsafeLocal, + "create", + "requested", + "none", + ); + let request_id = next_internal_shell_request_id(); + let request = d2b_contracts::unsafe_local_wire::HelperShellRequest::Attach { + request_id, + operation_id: operation_id.clone(), + workload: identity.clone(), + policy, + name: attach.name.clone(), + force: attach.force, + initial_terminal_size: attach.initial_terminal_size, + }; + let reply = state + .unsafe_local_helpers + .dispatch_shell(peer_uid, request) + .map_err(map_shell_helper_registry_error)?; + let unsafe_local_helper::HelperShellReply::Terminal { ready, fd } = reply else { + return Err(shell_backend::unsafe_shell_failed( + typed_error::UnsafeLocalShellErrorKind::Protocol, + )); + }; + let public_session = new_public_shell_session_handle()?; + let terminal = + unsafe_local_terminal::UnsafeLocalTerminalClient::new(fd).map_err(|_| { + shell_backend::unsafe_shell_failed( + typed_error::UnsafeLocalShellErrorKind::Protocol, + ) + })?; + let resolved_name = ready.result.resolved_name; + let backend = shell_backend::UnsafeLocalShellBackend::new( + public_session.clone(), + resolved_name.clone(), + terminal, + ); + Ok(shell_backend::EstablishedShell { + backend: Arc::new(backend), + attach: public_wire::ShellAttachResult { + session: public_session, + resolved_name, + state: ready.result.state, + force_evicted: ready.result.force_evicted, + }, + target: identity.canonical_target.to_canonical(), + provider: shell_backend::ShellProvider::UnsafeLocal, + operation_digest: Some(operation_digest), + initial_control_sequence: 0, + }) + } + WorkloadRoute::CapabilityUnavailable { provider } => { + Err(TypedError::RuntimeCapabilityUnsupported { + vm: attach.vm.clone(), + runtime_kind: workload_provider_label(provider).to_owned(), + capability: "persistent-shell".to_owned(), + verb: "shell".to_owned(), + }) + } + } +} + +fn resolve_shell_target( + state: &ServerState, + target: &str, +) -> Result { + use workload_dispatch::{CatalogError, ResolvedShell, WorkloadCatalog, WorkloadRoute}; + + let resolver = load_bundle_resolver(state)?; + match WorkloadCatalog::from_resolver(&resolver) { + Ok(catalog) => catalog + .resolve_shell(resolver.unsafe_local_workloads.as_ref(), target) + .map_err(|error| map_shell_catalog_error(error, target)), + Err(CatalogError::ArtifactsUnavailable) if !target.ends_with(".d2b") => Ok(ResolvedShell { + identity: None, + route: WorkloadRoute::LocalVm { + vm: target.to_owned(), + }, + policy: None, + }), + Err(error) => Err(map_shell_catalog_error(error, target)), + } +} + +fn map_shell_catalog_error(error: workload_dispatch::CatalogError, target: &str) -> TypedError { + use workload_dispatch::CatalogError; + match error { + CatalogError::TargetNotFound => TypedError::WorkloadTargetNotFound { + target: target.to_owned(), + }, + CatalogError::AliasConflict => TypedError::WorkloadAliasConflict { + workload_id: target.to_owned(), + detail: "multiple configured workloads share this short id".to_owned(), + }, + CatalogError::ShellCapabilityUnavailable => shell_backend::unsafe_shell_failed( + typed_error::UnsafeLocalShellErrorKind::ShellUnavailable, + ), + CatalogError::ArtifactsUnavailable + | CatalogError::ConfiguredItemMissing + | CatalogError::ConfiguredItemMismatch => { + shell_backend::unsafe_shell_failed(typed_error::UnsafeLocalShellErrorKind::Protocol) + } + CatalogError::LauncherDisabled + | CatalogError::ItemNotFound + | CatalogError::OperationConflict + | CatalogError::OperationInProgress => { + shell_backend::unsafe_shell_failed(typed_error::UnsafeLocalShellErrorKind::Internal) + } + } +} + +fn next_internal_helper_request_id() -> u64 { + static REQUEST_ID: AtomicU64 = AtomicU64::new(1); + loop { + let request_id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + if request_id != 0 { + return request_id; + } + } +} + +fn next_internal_shell_request_id() -> u64 { + next_internal_helper_request_id() +} + +fn new_internal_shell_operation_id() -> Result { + let mut bytes = [0u8; 16]; + getrandom::getrandom(&mut bytes).map_err(|_| { + shell_backend::unsafe_shell_failed(typed_error::UnsafeLocalShellErrorKind::Internal) + })?; + d2b_realm_core::OperationId::parse(format!("shell-{}", hex_bytes(&bytes))).map_err(|_| { + shell_backend::unsafe_shell_failed(typed_error::UnsafeLocalShellErrorKind::Internal) + }) +} + +fn new_public_shell_session_handle() -> Result { + let mut bytes = [0u8; 16]; + getrandom::getrandom(&mut bytes).map_err(|_| { + shell_backend::unsafe_shell_failed(typed_error::UnsafeLocalShellErrorKind::Internal) + })?; + Ok(format!("shell-{}", hex_bytes(&bytes))) +} + +fn map_shell_helper_registry_error(error: unsafe_local_helper::HelperRegistryError) -> TypedError { + use typed_error::UnsafeLocalShellErrorKind as UnsafeKind; + use unsafe_local_helper::HelperRegistryError as H; + match error { + H::HelperUnavailable | H::Io => { + shell_backend::unsafe_shell_failed(UnsafeKind::HelperUnavailable) + } + H::HelperStale | H::GenerationSuperseded => { + shell_backend::unsafe_shell_failed(UnsafeKind::HelperStale) + } + H::QueueFull | H::OperationInProgress => { + shell_backend::unsafe_shell_failed(UnsafeKind::QueueFull) + } + H::Timeout => shell_backend::unsafe_shell_failed(UnsafeKind::Timeout), + H::OperationIdConflict => shell_backend::unsafe_shell_failed(UnsafeKind::OperationConflict), + H::OperationRejected(code) => shell_backend::map_helper_failure(code), + H::InvalidFrame + | H::InvalidRequest + | H::FrameTooLarge + | H::ProtocolMismatch + | H::RequestCorrelationMismatch + | H::InvalidTerminalFd + | H::SocketBufferTooSmall + | H::SnapshotTooLarge => shell_backend::unsafe_shell_failed(UnsafeKind::Protocol), + H::UnauthorizedPeer | H::InvalidPeer => { + shell_backend::unsafe_shell_failed(UnsafeKind::Internal) + } + } +} + +async fn establish_guest_shell_owner_async( state: &ServerState, attach: &public_wire::ShellAttachArgs, ) -> Result< @@ -7630,22 +9629,6 @@ fn shell_close_attach_with_runtime( map_shell_detach_response(response) } -fn shell_close_attach_best_effort_with_runtime( - rt: &tokio::runtime::Handle, - client: &guest_control_health::TtrpcGuestControlClient, - vm: &str, - session_id: &str, - guest_boot_id: &str, -) -> daemon_audit::ShellAuditResult { - match shell_close_attach_with_runtime(rt, client, vm, session_id, guest_boot_id) { - Ok(_) => daemon_audit::ShellAuditResult::Closed, - Err(TypedError::GuestControlShellFailed { - kind: crate::typed_error::GuestControlShellErrorKind::Timeout, - }) => daemon_audit::ShellAuditResult::Timeout, - Err(_) => daemon_audit::ShellAuditResult::Error, - } -} - fn run_guest_shell_management( state: &ServerState, vm: &str, @@ -8223,6 +10206,7 @@ fn run_exec_owner( let spec = exec_session::ExecStartSpec { vm: start.vm.clone(), + request_id: None, argv: start.argv.clone(), tty: start.tty, detached: start.detached, @@ -20623,6 +22607,7 @@ mod detached_exec_routing_tests { request, exec_detached::DetachedTestRequest::Create { vm: "work".to_owned(), + request_id: None, argv_len: 1, env_len: 1, has_cwd: true, @@ -20679,6 +22664,48 @@ mod detached_exec_routing_tests { assert_eq!(record["event"]["exec_id"].as_str(), Some("exec-detached-1")); } + #[test] + fn idempotent_detached_create_forwards_guest_request_id() { + let state = test_state(exec_session::ExecSessionCaps::default()); + let expected = "workload-launch:0123456789abcdef0123456789abcdef"; + let hook = Arc::new(move |request| { + let exec_detached::DetachedTestRequest::Create { request_id, .. } = request else { + panic!("idempotent create hook received management request"); + }; + assert_eq!(request_id.as_deref(), Some(expected)); + Ok(exec_detached::DetachedTestResponse::Create( + public_wire::ExecDetachedCreateResult { + exec_id: "0123456789abcdef0123456789abcdef".to_owned(), + state: ExecState::Running, + }, + )) + }); + let _guard = exec_detached::set_test_hook(hook); + let ExecOp::Start(start) = detached_start("true") else { + unreachable!("detached_start always returns ExecOp::Start") + }; + + let result = exec_detached::create_idempotent(&state, &start, expected.to_owned()).unwrap(); + assert_eq!(result.exec_id, "0123456789abcdef0123456789abcdef"); + } + + #[test] + fn local_workload_launcher_wires_detached_create_audit() { + let source = include_str!("lib.rs"); + let start = source + .find("fn dispatch_local_vm_launcher(") + .expect("local VM launcher function"); + let end = source[start..] + .find("\nfn map_workload_catalog_error(") + .map(|offset| start + offset) + .expect("local VM launcher function end"); + let body = &source[start..end]; + + assert!(body.contains("exec_detached::create_idempotent")); + assert!(body.contains("emit_detached_create_audit")); + assert!(body.contains("&result.exec_id")); + } + #[test] fn detached_create_denies_launcher_before_owner_backend_or_session_table() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -21215,6 +23242,15 @@ mod accept_loop_concurrency_tests { .expect("encode hello frame") } + fn workload_list_frame() -> Vec { + serde_json::to_vec(&json!({ + "type": "workload", + "op": "list", + "args": {} + })) + .expect("encode workload list frame") + } + fn exec_start_frame(op_id: u64) -> Vec { let op = ExecOp::Start(ExecStartArgs { vm: "work".to_owned(), @@ -21277,6 +23313,43 @@ mod accept_loop_concurrency_tests { } } + #[test] + fn workload_request_without_negotiated_features_is_typed_unsupported() { + let (state, _state_dir) = admin_exec_state(); + let (server, client) = seqpacket_pair(); + let handle = std::thread::spawn(move || { + handle_connection_authorized(server, &state, admin_peer_identity(), None) + }); + + write_frame(&client, &hello_frame()).expect("send featureless hello"); + let hello_ok = read_frame(&client).expect("read hello response"); + let hello_ok: Value = serde_json::from_slice(&hello_ok).expect("hello response json"); + assert_eq!(hello_ok["type"], "helloOk"); + assert!( + hello_ok["capabilities"] + .as_array() + .is_some_and(Vec::is_empty), + "no workload feature was negotiated" + ); + + write_frame(&client, &workload_list_frame()).expect("send workload request"); + let response = read_frame(&client).expect("read workload rejection"); + let response: Value = serde_json::from_slice(&response).expect("rejection json"); + assert_eq!(response["type"], "error"); + assert_eq!(response["error"]["kind"], "wire-unsupported-request"); + assert!( + response["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("workload")) + ); + + drop(client); + handle + .join() + .expect("handler joins") + .expect("feature-skew connection closes cleanly"); + } + #[test] fn exec_dispatch_returns_to_the_accept_loop_and_a_second_request_is_served() { use std::sync::{Condvar, Mutex}; @@ -26731,9 +28804,9 @@ mod broker_dispatch_tests { #[test] fn shell_verb_is_admin_only() { use super::verb_requires_admin; - // Shell operations cross into the guest and can attach/detach/kill a - // workload-user terminal, so the daemon must deny launchers before any - // session lookup, guest-control probe, or owner reservation. + // Shell operations can attach/detach/kill a guest or unsafe-local + // workload-user terminal, so configured-launch authority never extends + // to shell and the daemon denies launchers before backend side effects. assert!(verb_requires_admin("shell")); } @@ -26847,6 +28920,31 @@ mod broker_dispatch_tests { ); } + #[test] + fn helper_request_ids_share_one_nonzero_correlation_namespace() { + let launch = super::next_internal_helper_request_id(); + let shell = super::next_internal_shell_request_id(); + assert_ne!(launch, 0); + assert_ne!(shell, 0); + assert_ne!(launch, shell); + } + + #[test] + fn unsafe_local_shell_feature_gate_is_explicit() { + assert!(!super::unsafe_local_shell_feature_negotiated(&[])); + assert!(super::unsafe_local_shell_feature_negotiated(&[ + d2b_contracts::KnownFeatureFlag::UnsafeLocalShellV1.wire_value() + ])); + } + + #[test] + fn unresolved_shell_audit_never_copies_public_target_text() { + let canary = "private-target-canary.work.d2b"; + let target = super::unresolved_shell_audit_target(); + assert_eq!(target, "unresolved"); + assert!(!target.contains(canary)); + } + #[test] fn shell_wait_is_explicitly_unsupported() { let err = super::unsupported_shell_wait_error(); diff --git a/packages/d2bd/src/metrics.rs b/packages/d2bd/src/metrics.rs index 5cbaa3313..ba098c7d8 100644 --- a/packages/d2bd/src/metrics.rs +++ b/packages/d2bd/src/metrics.rs @@ -167,6 +167,30 @@ pub const METRIC_INVENTORY: &[MetricDescriptor] = &[ labels: &["subsystem", "outcome", "error_kind"], buckets_seconds: &[], }, + MetricDescriptor { + name: "d2b_daemon_shell_lifecycle_total", + kind: MetricKind::Counter, + labels: &[ + "provider", + "component", + "operation", + "outcome", + "error_kind", + ], + buckets_seconds: &[], + }, + MetricDescriptor { + name: "d2b_daemon_workload_availability", + kind: MetricKind::Gauge, + labels: &["provider", "component", "state"], + buckets_seconds: &[], + }, + MetricDescriptor { + name: "d2b_daemon_workload_lifecycle_total", + kind: MetricKind::Counter, + labels: &["provider", "operation", "outcome"], + buckets_seconds: &[], + }, ]; /// Lookup a descriptor by name. `None` for any unknown name — @@ -257,6 +281,32 @@ impl Registry { entry.value = value; } + /// Atomically replace one bounded gauge family. Existing series in the + /// family are zeroed before the supplied snapshot is applied so a scrape + /// cannot observe a partially refreshed inventory or retain a stale value. + pub fn gauge_family_replace(&self, name: &'static str, samples: &[(Vec<(&str, &str)>, f64)]) { + let owned = samples + .iter() + .map(|(labels, value)| { + let labels = labels + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect::(); + Self::validate(name, MetricKind::Gauge, &labels); + (labels, *value) + }) + .collect::>(); + let mut registry = self.inner.lock().expect("metrics registry poisoned"); + for ((metric_name, _), sample) in &mut registry.gauges { + if *metric_name == name { + sample.value = 0.0; + } + } + for (labels, value) in owned { + registry.gauges.entry((name, labels)).or_default().value = value; + } + } + pub fn histogram_observe( &self, name: &'static str, @@ -407,6 +457,15 @@ fn help_text(name: &str) -> &'static str { "d2b_daemon_guest_control_shell_total" => { "Cumulative count of guest-control shell session/op outcomes by error_kind." } + "d2b_daemon_shell_lifecycle_total" => { + "Cumulative provider-neutral persistent-shell lifecycle outcomes." + } + "d2b_daemon_workload_availability" => { + "Observed workload inventory count by bounded provider, component, and state." + } + "d2b_daemon_workload_lifecycle_total" => { + "Configured workload lifecycle outcomes by provider and operation." + } _ => "", } } @@ -564,6 +623,9 @@ mod tests { "d2b_daemon_uptime_seconds", "d2b_daemon_guest_control_exec_total", "d2b_daemon_guest_control_shell_total", + "d2b_daemon_shell_lifecycle_total", + "d2b_daemon_workload_availability", + "d2b_daemon_workload_lifecycle_total", ] ); } @@ -668,6 +730,9 @@ mod tests { "reason", "subsystem", "error_kind", + "provider", + "component", + "operation", ]; // Populate one sample of EVERY inventory metric so render() emits @@ -724,7 +789,6 @@ mod tests { "environment", "cwd", "current_working_directory", - "provider", "stream", "stream_id", "terminal_stream_id", @@ -750,6 +814,41 @@ mod tests { } } + #[test] + fn workload_metrics_serialize_without_execution_details() { + let r = Registry::new(); + r.gauge_set( + "d2b_daemon_workload_availability", + &[ + ("provider", "unsafe-local"), + ("component", "proxy"), + ("state", "ready"), + ], + 1.0, + ); + r.counter_inc( + "d2b_daemon_workload_lifecycle_total", + &[ + ("provider", "unsafe-local"), + ("operation", "launcher-exec"), + ("outcome", "committed"), + ], + ); + let body = r.render(); + assert!(body.contains("d2b_daemon_workload_availability")); + assert!(body.contains("d2b_daemon_workload_lifecycle_total")); + for forbidden in [ + "argv=", + "environment=", + "cwd=", + "path=", + "pid=", + "unit_name=", + ] { + assert!(!body.contains(forbidden), "{forbidden}: {body}"); + } + } + #[test] fn metrics_handler_returns_text_format_on_get() { let r = Registry::new(); diff --git a/packages/d2bd/src/shell_backend.rs b/packages/d2bd/src/shell_backend.rs new file mode 100644 index 000000000..f75465c59 --- /dev/null +++ b/packages/d2bd/src/shell_backend.rs @@ -0,0 +1,464 @@ +use crate::{ + daemon_audit, + terminal_session::{OutputStreamSel, TerminalBackend, TerminalKind}, + typed_error::{TypedError, UnsafeLocalShellErrorKind}, + unsafe_local_terminal::{UnsafeLocalTerminalClient, UnsafeLocalTerminalError}, +}; +use d2b_contracts::{ + public_wire::{self, ShellOp, ShellOpResponse}, + terminal_wire as tw, +}; +use std::{fmt, sync::Arc, time::Duration}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ShellProvider { + GuestControl, + UnsafeLocal, +} + +impl ShellProvider { + pub(crate) fn label(self) -> &'static str { + match self { + Self::GuestControl => "guest-control", + Self::UnsafeLocal => "unsafe-local", + } + } +} + +pub(crate) trait ShellBackend: Send + Sync { + fn handle_op( + &self, + runtime: &tokio::runtime::Handle, + control_sequence: &mut u64, + op: ShellOp, + ) -> Result, TypedError>; + + fn close_attachment( + &self, + runtime: &tokio::runtime::Handle, + control_sequence: &mut u64, + ) -> Result; +} + +pub(crate) struct EstablishedShell { + pub(crate) backend: Arc, + pub(crate) attach: public_wire::ShellAttachResult, + pub(crate) target: String, + pub(crate) provider: ShellProvider, + pub(crate) operation_digest: Option, + pub(crate) initial_control_sequence: u64, +} + +impl fmt::Debug for EstablishedShell { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EstablishedShell") + .field("target", &self.target) + .field("provider", &self.provider) + .field("operation_digest", &self.operation_digest) + .field("initial_control_sequence", &self.initial_control_sequence) + .field("attach", &self.attach) + .finish_non_exhaustive() + } +} + +pub(crate) struct UnsafeLocalShellBackend { + public_session: String, + resolved_name: public_wire::ShellName, + terminal: UnsafeLocalTerminalClient, +} + +impl fmt::Debug for UnsafeLocalShellBackend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("UnsafeLocalShellBackend") + .field("public_session", &"") + .field("resolved_name", &"") + .field("terminal", &self.terminal) + .finish() + } +} + +impl UnsafeLocalShellBackend { + pub(crate) fn new( + public_session: String, + resolved_name: public_wire::ShellName, + terminal: UnsafeLocalTerminalClient, + ) -> Self { + Self { + public_session, + resolved_name, + terminal, + } + } + + fn ensure_session(&self, session: &str) -> Result<(), TypedError> { + if session == self.public_session { + Ok(()) + } else { + Err(unsafe_shell_failed(UnsafeLocalShellErrorKind::StaleSession)) + } + } +} + +impl ShellBackend for UnsafeLocalShellBackend { + fn handle_op( + &self, + runtime: &tokio::runtime::Handle, + control_sequence: &mut u64, + op: ShellOp, + ) -> Result, TypedError> { + match op { + ShellOp::WriteStdin(args) => { + self.ensure_session(&args.session)?; + let data = d2b_core::base64_codec::decode(&args.chunk_base64) + .map_err(|_| unsafe_shell_failed(UnsafeLocalShellErrorKind::Protocol))?; + let result = runtime + .block_on(self.terminal.write_stdin( + args.offset, + data, + args.eof, + shell_operation_timeout(), + )) + .map_err(map_terminal_error)?; + Ok(Some(ShellOpResponse::WriteStdin( + tw::TerminalWriteStdinResult { + accepted_len: result.accepted_len, + next_offset: result.next_offset, + backpressured: result.backpressured, + stdin_closed: result.stdin_closed, + }, + ))) + } + ShellOp::ReadOutput(args) => { + self.ensure_session(&args.session)?; + let (timeout_ms, deadline) = shell_poll_timeout(args.timeout_ms, args.wait); + let result = runtime + .block_on(self.terminal.read_output( + match args.stream { + tw::TerminalStream::Stdout => OutputStreamSel::Stdout, + tw::TerminalStream::Stderr => OutputStreamSel::Stderr, + }, + args.offset, + args.max_len, + args.wait, + timeout_ms, + deadline, + )) + .map_err(map_terminal_error)?; + Ok(Some(ShellOpResponse::ReadOutput( + tw::TerminalReadOutputChunk { + data_base64: d2b_core::base64_codec::encode(&result.data), + next_offset: result.next_offset, + eof: result.eof, + dropped_bytes: result.dropped_bytes, + truncated: result.truncated, + timed_out: result.timed_out, + }, + ))) + } + ShellOp::Resize(args) => { + self.ensure_session(&args.session)?; + *control_sequence = control_sequence.saturating_add(1); + runtime + .block_on(self.terminal.resize( + *control_sequence, + args.rows, + args.cols, + shell_operation_timeout(), + )) + .map_err(map_terminal_error)?; + Ok(Some(ShellOpResponse::Resize(tw::TerminalControlResult { + delivered: true, + }))) + } + ShellOp::Wait(args) => { + self.ensure_session(&args.session)?; + let (timeout_ms, deadline) = shell_poll_timeout(args.timeout_ms, true); + let result = runtime + .block_on(self.terminal.wait(timeout_ms, deadline)) + .map_err(map_terminal_error)?; + Ok(Some(ShellOpResponse::Wait(tw::TerminalWaitResult { + running: result.running, + terminal_status: result.terminal.map(|terminal| match terminal { + TerminalKind::Exited(code) => tw::TerminalStatus::Exited { code }, + TerminalKind::Signaled(signal) => tw::TerminalStatus::Signaled { signal }, + TerminalKind::Error(slug) => tw::TerminalStatus::Error { + slug: slug.to_owned(), + }, + }), + }))) + } + ShellOp::CloseStdin(args) => { + self.ensure_session(&args.session)?; + *control_sequence = control_sequence.saturating_add(1); + runtime + .block_on( + self.terminal + .close_stdin(*control_sequence, shell_operation_timeout()), + ) + .map_err(map_terminal_error)?; + Ok(Some(ShellOpResponse::CloseStdin(tw::TerminalCloseResult { + stdin_closed: true, + }))) + } + ShellOp::CloseAttach(args) => { + self.ensure_session(&args.session)?; + self.close_attachment(runtime, control_sequence) + .map(ShellOpResponse::CloseAttach) + .map(Some) + } + ShellOp::Attach(_) | ShellOp::List(_) | ShellOp::Detach(_) | ShellOp::Kill(_) => { + Err(unsafe_shell_failed(UnsafeLocalShellErrorKind::Protocol)) + } + } + } + + fn close_attachment( + &self, + _runtime: &tokio::runtime::Handle, + control_sequence: &mut u64, + ) -> Result { + *control_sequence = control_sequence.saturating_add(1); + let result = self + .terminal + .close_attachment(*control_sequence, shell_operation_timeout()) + .map_err(map_terminal_error)?; + Ok(public_wire::ShellDetachResult { + resolved_name: self.resolved_name.clone(), + detached: result.detached, + cause: result.cause, + }) + } +} + +pub(crate) fn best_effort_close( + backend: &dyn ShellBackend, + runtime: &tokio::runtime::Handle, + control_sequence: &mut u64, +) -> daemon_audit::ShellAuditResult { + match backend.close_attachment(runtime, control_sequence) { + Ok(_) => daemon_audit::ShellAuditResult::Closed, + Err(TypedError::UnsafeLocalShellFailed { + kind: UnsafeLocalShellErrorKind::Timeout, + }) + | Err(TypedError::GuestControlShellFailed { + kind: crate::typed_error::GuestControlShellErrorKind::Timeout, + }) => daemon_audit::ShellAuditResult::Timeout, + Err(_) => daemon_audit::ShellAuditResult::Error, + } +} + +fn shell_operation_timeout() -> Duration { + Duration::from_secs(3) +} + +fn shell_poll_timeout(requested_ms: u64, wait: bool) -> (u64, Duration) { + if !wait { + return (0, shell_operation_timeout()); + } + let timeout_ms = requested_ms.min(1_000); + (timeout_ms, Duration::from_millis(timeout_ms + 1_000)) +} + +fn map_terminal_error(error: UnsafeLocalTerminalError) -> TypedError { + use UnsafeLocalShellErrorKind as UnsafeKind; + let kind = match error { + UnsafeLocalTerminalError::Bounds + | UnsafeLocalTerminalError::Protocol + | UnsafeLocalTerminalError::ResponseMismatch => UnsafeKind::Protocol, + UnsafeLocalTerminalError::Capacity => UnsafeKind::QueueFull, + UnsafeLocalTerminalError::Timeout => UnsafeKind::Timeout, + UnsafeLocalTerminalError::Closed => UnsafeKind::TerminalClosed, + UnsafeLocalTerminalError::OutputGap => UnsafeKind::OutputGap, + UnsafeLocalTerminalError::OffsetMismatch => UnsafeKind::OffsetMismatch, + UnsafeLocalTerminalError::InvalidSize => UnsafeKind::InvalidSize, + UnsafeLocalTerminalError::Unsupported => UnsafeKind::Protocol, + UnsafeLocalTerminalError::Rejected(code) => return map_helper_failure(code), + }; + unsafe_shell_failed(kind) +} + +pub(crate) fn map_helper_failure( + code: d2b_contracts::unsafe_local_wire::HelperFailureCode, +) -> TypedError { + use UnsafeLocalShellErrorKind as UnsafeKind; + use d2b_contracts::unsafe_local_wire::HelperFailureCode as H; + let kind = match code { + H::InvalidRequest => UnsafeKind::Protocol, + H::OperationIdConflict => UnsafeKind::OperationConflict, + H::QueueFull => UnsafeKind::QueueFull, + H::Timeout => UnsafeKind::Timeout, + H::UserManagerUnavailable => UnsafeKind::UserManagerUnavailable, + H::EnvironmentInvalid => UnsafeKind::EnvironmentInvalid, + H::ExecutableUnavailable => UnsafeKind::ExecutableUnavailable, + H::ScopeCreateFailed => UnsafeKind::ScopeCreateFailed, + H::ScopeIdentityMismatch => UnsafeKind::ScopeIdentityMismatch, + H::GraphicalSessionInactive => UnsafeKind::GraphicalSessionInactive, + H::WaylandUnavailable => UnsafeKind::WaylandUnavailable, + H::ProxyUnavailable => UnsafeKind::ProxyUnavailable, + H::FirstClientTimeout => UnsafeKind::FirstClientTimeout, + H::ShellUnavailable => UnsafeKind::ShellUnavailable, + H::ShellNotFound => UnsafeKind::NotFound, + H::ShellAlreadyAttached => UnsafeKind::AlreadyAttached, + H::TerminalOutputGap => UnsafeKind::OutputGap, + H::TerminalOffsetMismatch => UnsafeKind::OffsetMismatch, + H::TerminalClosed => UnsafeKind::TerminalClosed, + H::InvalidTerminalSize => UnsafeKind::InvalidSize, + H::Internal => UnsafeKind::Internal, + }; + unsafe_shell_failed(kind) +} + +pub(crate) fn unsafe_shell_failed(kind: UnsafeLocalShellErrorKind) -> TypedError { + TypedError::UnsafeLocalShellFailed { kind } +} + +#[cfg(test)] +mod tests { + use super::*; + use d2b_contracts::unsafe_local_wire::{ + HelperTerminalAttachmentClosed, HelperTerminalControlResponse, + HelperTerminalOperationResult, HelperTerminalRequest, HelperTerminalResponse, + }; + use std::{ + io::{Read, Write}, + os::unix::net::UnixStream, + }; + + fn backend_pair() -> (UnsafeLocalShellBackend, UnixStream) { + let (client, peer) = UnixStream::pair().unwrap(); + ( + UnsafeLocalShellBackend::new( + "shell-public-handle".to_owned(), + public_wire::ShellName::new("primary").unwrap(), + UnsafeLocalTerminalClient::new(client.into()).unwrap(), + ), + peer, + ) + } + + fn read_request(peer: &mut UnixStream) -> HelperTerminalRequest { + let mut prefix = [0u8; 4]; + peer.read_exact(&mut prefix).unwrap(); + let length = u32::from_le_bytes(prefix) as usize; + let mut frame = Vec::from(prefix); + frame.resize(length + 4, 0); + peer.read_exact(&mut frame[4..]).unwrap(); + d2b_contracts::unsafe_local_wire::decode_unsafe_local_terminal_frame(&frame).unwrap() + } + + fn send_response(peer: &mut UnixStream, response: HelperTerminalResponse) { + peer.write_all( + &d2b_contracts::unsafe_local_wire::encode_unsafe_local_terminal_frame(&response) + .unwrap(), + ) + .unwrap(); + } + + #[test] + fn unsafe_backend_rejects_stale_public_handle_before_terminal_io() { + let (backend, _peer) = backend_pair(); + let debug = format!("{backend:?}"); + assert!(!debug.contains("shell-public-handle")); + assert!(!debug.contains("primary")); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let error = backend + .handle_op( + runtime.handle(), + &mut 0, + ShellOp::Wait(tw::TerminalWait { + session: "wrong-handle".to_owned(), + timeout_ms: 0, + }), + ) + .unwrap_err(); + assert!(matches!( + error, + TypedError::UnsafeLocalShellFailed { + kind: UnsafeLocalShellErrorKind::StaleSession + } + )); + } + + #[test] + fn unsafe_backend_supports_wait_and_close_detach_without_kill() { + let (backend, mut peer) = backend_pair(); + let backend = Arc::new(backend); + let wait_backend = Arc::clone(&backend); + let wait = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + wait_backend.handle_op( + runtime.handle(), + &mut 0, + ShellOp::Wait(tw::TerminalWait { + session: "shell-public-handle".to_owned(), + timeout_ms: 100, + }), + ) + }); + let wait_request = read_request(&mut peer); + let request_id = wait_request.request_id(); + send_response( + &mut peer, + HelperTerminalResponse::Wait(HelperTerminalOperationResult { + request_id, + result: tw::TerminalWaitResult { + running: true, + terminal_status: None, + }, + }), + ); + assert!(matches!( + wait.join().unwrap().unwrap(), + Some(ShellOpResponse::Wait(tw::TerminalWaitResult { + running: true, + .. + })) + )); + + let close_backend = Arc::clone(&backend); + let close = std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + close_backend.handle_op( + runtime.handle(), + &mut 0, + ShellOp::CloseAttach(public_wire::ShellCloseAttachArgs { + session: "shell-public-handle".to_owned(), + }), + ) + }); + let close_request = read_request(&mut peer); + let HelperTerminalRequest::CloseAttachment(control) = close_request else { + panic!("expected close attachment"); + }; + send_response( + &mut peer, + HelperTerminalResponse::CloseAttachment(HelperTerminalControlResponse { + request_id: control.request_id, + control_sequence: control.control_sequence, + result: HelperTerminalAttachmentClosed { + detached: true, + cause: Some(public_wire::ShellCloseCause::ClientDetach), + }, + }), + ); + assert!(matches!( + close.join().unwrap().unwrap(), + Some(ShellOpResponse::CloseAttach( + public_wire::ShellDetachResult { + detached: true, + cause: Some(public_wire::ShellCloseCause::ClientDetach), + .. + } + )) + )); + } +} diff --git a/packages/d2bd/src/typed_error.rs b/packages/d2bd/src/typed_error.rs index 61c622f1f..30d42c7f4 100644 --- a/packages/d2bd/src/typed_error.rs +++ b/packages/d2bd/src/typed_error.rs @@ -251,6 +251,151 @@ pub enum GuestControlShellErrorKind { Internal, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnsafeLocalShellErrorKind { + FeatureUnavailable, + HelperUnavailable, + HelperStale, + QueueFull, + Timeout, + Protocol, + OperationConflict, + UserManagerUnavailable, + EnvironmentInvalid, + ExecutableUnavailable, + ScopeCreateFailed, + ScopeIdentityMismatch, + GraphicalSessionInactive, + WaylandUnavailable, + ProxyUnavailable, + FirstClientTimeout, + ShellUnavailable, + NotFound, + AlreadyAttached, + OutputGap, + OffsetMismatch, + TerminalClosed, + InvalidSize, + StaleSession, + Internal, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkloadLaunchErrorKind { + RealmNotDirectLocal, + LauncherDisabled, + ItemNotFound, + ConfiguredItemMismatch, + HelperUnavailable, + HelperStale, + UserManagerUnavailable, + GraphicalSessionInactive, + WaylandUnavailable, + ProxyUnavailable, + OperationConflict, + QueueFull, + Timeout, + Internal, +} + +impl WorkloadLaunchErrorKind { + fn wire_kind(self) -> &'static str { + match self { + Self::RealmNotDirectLocal => "workload-realm-not-direct-local", + Self::LauncherDisabled => "workload-launcher-disabled", + Self::ItemNotFound => "workload-launcher-item-not-found", + Self::ConfiguredItemMismatch => "workload-configured-item-mismatch", + Self::HelperUnavailable => "unsafe-local-helper-unavailable", + Self::HelperStale => "unsafe-local-helper-stale", + Self::UserManagerUnavailable => "unsafe-local-user-manager-unavailable", + Self::GraphicalSessionInactive => "unsafe-local-graphical-session-inactive", + Self::WaylandUnavailable => "unsafe-local-wayland-unavailable", + Self::ProxyUnavailable => "unsafe-local-proxy-unavailable", + Self::OperationConflict => "workload-operation-conflict", + Self::QueueFull => "workload-launch-queue-full", + Self::Timeout => "workload-launch-timeout", + Self::Internal => "workload-launch-internal", + } + } + + fn exit_code(self) -> u8 { + match self { + Self::ItemNotFound | Self::LauncherDisabled => 2, + Self::RealmNotDirectLocal | Self::ConfiguredItemMismatch => 70, + Self::HelperUnavailable + | Self::HelperStale + | Self::UserManagerUnavailable + | Self::GraphicalSessionInactive + | Self::WaylandUnavailable + | Self::ProxyUnavailable => 69, + Self::OperationConflict => 76, + Self::QueueFull | Self::Timeout => 75, + Self::Internal => 42, + } + } + + fn human_message(self) -> &'static str { + match self { + Self::RealmNotDirectLocal => { + "the workload realm is not available through direct local access" + } + Self::LauncherDisabled => "the workload launcher is disabled", + Self::ItemNotFound => "the configured launcher item was not found", + Self::ConfiguredItemMismatch => { + "public launcher metadata does not match the private configured item" + } + Self::HelperUnavailable => "no same-UID unsafe-local helper is connected", + Self::HelperStale => "the same-UID unsafe-local helper heartbeat is stale", + Self::UserManagerUnavailable => "the user systemd manager is unavailable", + Self::GraphicalSessionInactive => "the graphical user session is inactive", + Self::WaylandUnavailable => "the user Wayland compositor is unavailable", + Self::ProxyUnavailable => "the workload Wayland proxy is unavailable", + Self::OperationConflict => "the operation id was reused for a different launch", + Self::QueueFull => "the unsafe-local helper queue is full", + Self::Timeout => "the configured launch timed out", + Self::Internal => "the daemon could not dispatch the configured launch", + } + } + + fn remediation(self) -> &'static str { + match self { + Self::RealmNotDirectLocal => { + "connect directly to the owning local realm or use its configured gateway" + } + Self::LauncherDisabled => { + "enable the workload launcher in the Nix configuration and rebuild" + } + Self::ItemNotFound => { + "retry with a launcher item id shown by the configured desktop launcher" + } + Self::ConfiguredItemMismatch => { + "rebuild the d2b bundle so public metadata and private configured items agree" + } + Self::HelperUnavailable | Self::HelperStale => { + "start or restart d2b-unsafe-local-helper in the requesting user's graphical session" + } + Self::UserManagerUnavailable => { + "log in through a PAM-backed graphical session and start the user systemd manager" + } + Self::GraphicalSessionInactive => "start a graphical user session and retry", + Self::WaylandUnavailable => { + "start the Wayland compositor in the requesting user's session and retry" + } + Self::ProxyUnavailable => { + "repair the d2b Wayland proxy prerequisites; direct compositor fallback is not permitted" + } + Self::OperationConflict => "retry with a fresh operation id", + Self::QueueFull => "wait for pending launches to complete and retry", + Self::Timeout => { + "inspect helper and user-manager health, then retry with a fresh operation id" + } + Self::Internal => { + "inspect the bounded daemon launch event and rebuild the trusted bundle if necessary" + } + } + } +} + impl GuestControlShellErrorKind { pub fn wire_kind(self) -> &'static str { match self { @@ -332,6 +477,160 @@ impl GuestControlShellErrorKind { } } +impl UnsafeLocalShellErrorKind { + pub fn wire_kind(self) -> &'static str { + match self { + Self::FeatureUnavailable => "unsafe-local-shell-feature-unavailable", + Self::HelperUnavailable => "unsafe-local-shell-helper-unavailable", + Self::HelperStale => "unsafe-local-shell-helper-stale", + Self::QueueFull => "unsafe-local-shell-capacity", + Self::Timeout => "unsafe-local-shell-timeout", + Self::Protocol => "unsafe-local-shell-protocol-error", + Self::OperationConflict => "unsafe-local-shell-operation-conflict", + Self::UserManagerUnavailable => "unsafe-local-shell-user-manager-unavailable", + Self::EnvironmentInvalid => "unsafe-local-shell-environment-invalid", + Self::ExecutableUnavailable => "unsafe-local-shell-executable-unavailable", + Self::ScopeCreateFailed => "unsafe-local-shell-scope-create-failed", + Self::ScopeIdentityMismatch => "unsafe-local-shell-scope-identity-mismatch", + Self::GraphicalSessionInactive => "unsafe-local-shell-graphical-session-inactive", + Self::WaylandUnavailable => "unsafe-local-shell-wayland-unavailable", + Self::ProxyUnavailable => "unsafe-local-shell-proxy-unavailable", + Self::FirstClientTimeout => "unsafe-local-shell-first-client-timeout", + Self::ShellUnavailable => "unsafe-local-shell-unavailable", + Self::NotFound => "unsafe-local-shell-not-found", + Self::AlreadyAttached => "unsafe-local-shell-already-attached", + Self::OutputGap => "unsafe-local-shell-output-gap", + Self::OffsetMismatch => "unsafe-local-shell-offset-mismatch", + Self::TerminalClosed => "unsafe-local-shell-terminal-closed", + Self::InvalidSize => "unsafe-local-shell-invalid-terminal-size", + Self::StaleSession => "unsafe-local-shell-stale-session", + Self::Internal => "unsafe-local-shell-internal", + } + } + + fn exit_code(self) -> u8 { + match self { + Self::FeatureUnavailable => 70, + Self::HelperUnavailable + | Self::HelperStale + | Self::Timeout + | Self::UserManagerUnavailable + | Self::GraphicalSessionInactive + | Self::WaylandUnavailable + | Self::ProxyUnavailable + | Self::FirstClientTimeout + | Self::ShellUnavailable + | Self::TerminalClosed => 69, + Self::EnvironmentInvalid + | Self::ExecutableUnavailable + | Self::ScopeIdentityMismatch => 70, + Self::QueueFull | Self::AlreadyAttached => 75, + Self::Protocol + | Self::OperationConflict + | Self::NotFound + | Self::OutputGap + | Self::OffsetMismatch + | Self::InvalidSize => 76, + Self::StaleSession => 77, + Self::ScopeCreateFailed | Self::Internal => 42, + } + } + + fn human_message(self) -> &'static str { + match self { + Self::FeatureUnavailable => { + "the client and daemon did not negotiate unsafe-local-shell-v1" + } + Self::HelperUnavailable => "no same-UID unsafe-local helper is connected", + Self::HelperStale => "the same-UID unsafe-local helper generation is stale", + Self::QueueFull => "the unsafe-local shell request queue is at capacity", + Self::Timeout => "the unsafe-local shell operation timed out", + Self::Protocol => "the unsafe-local shell helper returned a malformed response", + Self::OperationConflict => { + "the unsafe-local shell operation id conflicts with an earlier request" + } + Self::UserManagerUnavailable => "the user systemd manager is unavailable", + Self::EnvironmentInvalid => "the user-manager shell environment is invalid", + Self::ExecutableUnavailable => "the configured user login shell is unavailable", + Self::ScopeCreateFailed => "the user manager could not create the shell scope", + Self::ScopeIdentityMismatch => { + "the persistent shell scope identity could not be verified" + } + Self::GraphicalSessionInactive => "the graphical user session is inactive", + Self::WaylandUnavailable => "the user Wayland compositor is unavailable", + Self::ProxyUnavailable => "the workload Wayland proxy is unavailable", + Self::FirstClientTimeout => { + "the workload Wayland proxy did not accept its first client" + } + Self::ShellUnavailable => "the unsafe-local persistent shell is unavailable", + Self::NotFound => "the unsafe-local persistent shell was not found", + Self::AlreadyAttached => "the unsafe-local persistent shell is already attached", + Self::OutputGap => "the unsafe-local persistent shell output stream has a gap", + Self::OffsetMismatch => "the unsafe-local terminal input offset is stale", + Self::TerminalClosed => "the unsafe-local terminal attachment is closed", + Self::InvalidSize => "the unsafe-local terminal size is invalid", + Self::StaleSession => "the public unsafe-local shell attachment handle is stale", + Self::Internal => "the daemon failed to drive the unsafe-local shell", + } + } + + fn remediation(self) -> &'static str { + match self { + Self::FeatureUnavailable => { + "update d2b, d2bd, and d2b-unsafe-local-helper together; no host-shell fallback is permitted" + } + Self::HelperUnavailable | Self::HelperStale => { + "start or restart d2b-unsafe-local-helper in the requesting user's login session, then retry" + } + Self::QueueFull => "wait for pending shell operations to finish, then retry", + Self::Timeout => { + "inspect helper and user-manager health, then list shells before deciding whether to retry" + } + Self::Protocol => "update d2b, d2bd, and d2b-unsafe-local-helper together", + Self::OperationConflict => { + "retry the public shell operation; d2bd will derive a fresh internal operation id" + } + Self::UserManagerUnavailable => { + "log in through a PAM-backed session and start the user systemd manager; linger is intentionally not enabled" + } + Self::EnvironmentInvalid => { + "repair the user manager's XDG_RUNTIME_DIR and D-Bus environment, then restart the helper" + } + Self::ExecutableUnavailable => { + "configure an absolute executable login shell for the requesting user" + } + Self::ScopeCreateFailed => { + "inspect the user manager and retry after it can create transient user scopes" + } + Self::ScopeIdentityMismatch => { + "restart the helper and inspect the user scope; d2b will not adopt ambiguous scope metadata" + } + Self::GraphicalSessionInactive => "start a graphical login session and retry", + Self::WaylandUnavailable => { + "start the Wayland compositor in the login session and retry" + } + Self::ProxyUnavailable | Self::FirstClientTimeout => { + "repair the d2b Wayland proxy prerequisites; no direct compositor fallback is permitted" + } + Self::ShellUnavailable | Self::TerminalClosed | Self::StaleSession => { + "list unsafe-local shells and attach again; attachment loss does not kill the persistent shell" + } + Self::NotFound => "list unsafe-local shells and retry with a listed name", + Self::AlreadyAttached => "attach with --force or detach the existing attachment first", + Self::OutputGap => { + "reattach to redraw the shell; terminal output before the retained cursor is unavailable" + } + Self::OffsetMismatch => { + "retry after reattaching so terminal input offsets restart from the current attachment" + } + Self::InvalidSize => "retry from a terminal reporting non-zero rows and columns", + Self::Internal => { + "retry; if the failure persists inspect the bounded unsafe-local shell event" + } + } + } +} + #[derive(Debug, Clone)] pub enum TypedError { AuthzNotALauncher { @@ -543,6 +842,12 @@ pub enum TypedError { GuestControlShellFailed { kind: GuestControlShellErrorKind, }, + UnsafeLocalShellFailed { + kind: UnsafeLocalShellErrorKind, + }, + WorkloadLaunchFailed { + kind: WorkloadLaunchErrorKind, + }, /// The daemon refused a new connection because the bounded in-flight /// connection-handler pool is saturated. Returned immediately /// (non-blocking) from the accept path so a burst of clients cannot @@ -662,6 +967,8 @@ impl TypedError { Self::GuestControlReadFailed { kind } => kind.wire_kind(), Self::GuestControlExecFailed { kind } => kind.wire_kind(), Self::GuestControlShellFailed { kind } => kind.wire_kind(), + Self::UnsafeLocalShellFailed { kind } => kind.wire_kind(), + Self::WorkloadLaunchFailed { kind } => kind.wire_kind(), Self::DaemonBusy => "daemon-busy", Self::ConsoleVmNotFound { .. } => "console-vm-not-found", Self::ConsoleNotRunning { .. } => "console-vm-not-running", @@ -722,6 +1029,8 @@ impl TypedError { Self::GuestControlReadFailed { .. } => 70, Self::GuestControlExecFailed { kind } => kind.exit_code(), Self::GuestControlShellFailed { kind } => kind.exit_code(), + Self::UnsafeLocalShellFailed { kind } => kind.exit_code(), + Self::WorkloadLaunchFailed { kind } => kind.exit_code(), // Shares the EX_TEMPFAIL-class exit code with the other // transient back-pressure refusals (session-capacity, // rate-limited): a retry may succeed. @@ -877,6 +1186,8 @@ impl TypedError { Self::GuestControlReadFailed { kind } => kind.human_message().to_owned(), Self::GuestControlExecFailed { kind } => kind.human_message().to_owned(), Self::GuestControlShellFailed { kind } => kind.human_message().to_owned(), + Self::UnsafeLocalShellFailed { kind } => kind.human_message().to_owned(), + Self::WorkloadLaunchFailed { kind } => kind.human_message().to_owned(), Self::DaemonBusy => "the daemon is at its in-flight connection limit".to_owned(), Self::ConsoleVmNotFound { vm } => { format!("console: VM '{vm}' not found in the bundle") @@ -1046,6 +1357,8 @@ impl TypedError { Self::GuestControlReadFailed { kind } => kind.remediation().to_owned(), Self::GuestControlExecFailed { kind } => kind.remediation().to_owned(), Self::GuestControlShellFailed { kind } => kind.remediation().to_owned(), + Self::UnsafeLocalShellFailed { kind } => kind.remediation().to_owned(), + Self::WorkloadLaunchFailed { kind } => kind.remediation().to_owned(), Self::DaemonBusy => { "the daemon is briefly at capacity; retry the command shortly".to_owned() } @@ -1257,6 +1570,8 @@ impl TypedError { | Self::GuestControlReadFailed { .. } | Self::GuestControlExecFailed { .. } | Self::GuestControlShellFailed { .. } + | Self::UnsafeLocalShellFailed { .. } + | Self::WorkloadLaunchFailed { .. } | Self::DaemonBusy | Self::ConsoleVmNotFound { .. } | Self::ConsoleNotRunning { .. } @@ -1360,6 +1675,33 @@ mod tests { assert_no_path_leak("GuestShellDisabled", &err.remediation()); } + #[test] + fn workload_launch_errors_are_typed_actionable_and_redacted() { + let cases = [ + (WorkloadLaunchErrorKind::ItemNotFound, 2), + (WorkloadLaunchErrorKind::ConfiguredItemMismatch, 70), + (WorkloadLaunchErrorKind::HelperUnavailable, 69), + (WorkloadLaunchErrorKind::OperationConflict, 76), + (WorkloadLaunchErrorKind::QueueFull, 75), + (WorkloadLaunchErrorKind::Internal, 42), + ]; + for (kind, expected_exit) in cases { + let error = TypedError::WorkloadLaunchFailed { kind }; + assert_eq!(error.exit_code(), expected_exit); + assert!( + error.kind().starts_with("workload-") || error.kind().starts_with("unsafe-local-") + ); + assert!(!error.message().is_empty()); + assert!(!error.remediation().is_empty()); + assert_no_path_leak(error.kind(), &error.message()); + assert_no_path_leak(error.kind(), &error.remediation()); + for canary in ["argv-canary", "environment-canary", "unit-name-canary"] { + assert!(!error.message().contains(canary)); + assert!(!error.remediation().contains(canary)); + } + } + } + #[test] fn usbip_step_failed_public_envelope_redacts_sensitive_values() { let err = TypedError::UsbipStepFailed { @@ -1591,6 +1933,60 @@ mod tests { } } + #[test] + fn unsafe_local_shell_failed_kinds_are_closed_and_leak_free() { + use UnsafeLocalShellErrorKind as UnsafeKind; + let kinds = [ + UnsafeKind::FeatureUnavailable, + UnsafeKind::HelperUnavailable, + UnsafeKind::HelperStale, + UnsafeKind::QueueFull, + UnsafeKind::Timeout, + UnsafeKind::Protocol, + UnsafeKind::OperationConflict, + UnsafeKind::UserManagerUnavailable, + UnsafeKind::EnvironmentInvalid, + UnsafeKind::ExecutableUnavailable, + UnsafeKind::ScopeCreateFailed, + UnsafeKind::ScopeIdentityMismatch, + UnsafeKind::GraphicalSessionInactive, + UnsafeKind::WaylandUnavailable, + UnsafeKind::ProxyUnavailable, + UnsafeKind::FirstClientTimeout, + UnsafeKind::ShellUnavailable, + UnsafeKind::NotFound, + UnsafeKind::AlreadyAttached, + UnsafeKind::OutputGap, + UnsafeKind::OffsetMismatch, + UnsafeKind::TerminalClosed, + UnsafeKind::InvalidSize, + UnsafeKind::StaleSession, + UnsafeKind::Internal, + ]; + for kind in kinds { + let error = TypedError::UnsafeLocalShellFailed { kind }; + assert!(error.kind().starts_with("unsafe-local-shell-")); + assert!(!error.message().is_empty()); + assert!(!error.remediation().is_empty()); + for surface in [error.message(), error.remediation()] { + for forbidden in [ + "/run/", + "/home/", + "/nix/store", + "supervisor", + "InvocationID", + "terminal bytes", + ] { + assert!( + !surface.contains(forbidden), + "{} leaked {forbidden:?}: {surface:?}", + error.kind() + ); + } + } + } + } + #[test] fn envelope_kind_matches_expected_discriminant() { let cases: Vec<(TypedError, &str)> = vec![ diff --git a/packages/d2bd/src/unsafe_local_helper.rs b/packages/d2bd/src/unsafe_local_helper.rs index 16cf90c3e..a8c3ae99b 100644 --- a/packages/d2bd/src/unsafe_local_helper.rs +++ b/packages/d2bd/src/unsafe_local_helper.rs @@ -1,11 +1,13 @@ use d2b_contracts::unsafe_local_wire::{ DaemonToUnsafeLocalHelper, HELPER_SOCKET_BUFFER_REQUEST_BYTES, HelperFailureCode, HelperHeartbeat, HelperHelloAccepted, HelperLaunchRequest, HelperOperationDisposition, - HelperOperationRejected, HelperOperationResult, HelperSnapshot, HelperTerminalReady, - MAX_COMPLETED_OPERATION_AGE_SECS, MAX_COMPLETED_OPERATIONS_PER_UID, MAX_HELPER_FRAME_SIZE, - MAX_HELPER_QUEUE_DEPTH, MAX_HELPER_SNAPSHOT_SCOPES, MIN_EFFECTIVE_HELPER_SOCKET_BUFFER_BYTES, + HelperOperationRejected, HelperOperationResult, HelperShellRequest, HelperShellResponse, + HelperSnapshot, HelperTerminalReady, MAX_COMPLETED_OPERATION_AGE_SECS, + MAX_COMPLETED_OPERATIONS_PER_UID, MAX_HELPER_FRAME_SIZE, MAX_HELPER_QUEUE_DEPTH, + MAX_HELPER_SNAPSHOT_SCOPES, MIN_EFFECTIVE_HELPER_SOCKET_BUFFER_BYTES, UNSAFE_LOCAL_HELPER_PROTOCOL_VERSION, UNSAFE_LOCAL_TERMINAL_FD_COUNT, UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION, UnsafeLocalHelperToDaemon, + unsafe_local_helper_protocol_supported, }; use nix::cmsg_space; use nix::fcntl::{FcntlArg, FdFlag, fcntl}; @@ -36,6 +38,8 @@ pub const HELPER_STALE_AFTER: Duration = Duration::from_secs(15); pub const HELPER_OPERATION_TIMEOUT: Duration = Duration::from_secs(30); const HELPER_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30); const HELPER_LOOP_TICK: Duration = Duration::from_millis(200); +const HELPER_ACTIVE_OPERATION_RETENTION_SECS: u64 = 45; +const LATE_RESPONSE_RETENTION: Duration = Duration::from_secs(30); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HelperRegistryError { @@ -43,6 +47,7 @@ pub enum HelperRegistryError { InvalidPeer, SocketBufferTooSmall, InvalidFrame, + InvalidRequest, FrameTooLarge, ProtocolMismatch, SnapshotTooLarge, @@ -59,9 +64,17 @@ pub enum HelperRegistryError { Io, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HelperAvailability { + Ready, + Unavailable, + Stale, +} + pub enum HelperReply { Operation(HelperOperationResult), Rejected(HelperOperationRejected), + Shell(HelperShellResponse), Terminal { ready: HelperTerminalReady, fd: OwnedFd, @@ -73,6 +86,7 @@ impl fmt::Debug for HelperReply { match self { Self::Operation(result) => f.debug_tuple("Operation").field(result).finish(), Self::Rejected(rejected) => f.debug_tuple("Rejected").field(rejected).finish(), + Self::Shell(response) => f.debug_tuple("Shell").field(response).finish(), Self::Terminal { ready, .. } => f .debug_struct("Terminal") .field("ready", ready) @@ -82,17 +96,78 @@ impl fmt::Debug for HelperReply { } } +pub enum HelperShellReply { + Management(HelperShellResponse), + Terminal { + ready: HelperTerminalReady, + fd: OwnedFd, + }, +} + +impl fmt::Debug for HelperShellReply { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Management(response) => f.debug_tuple("Management").field(response).finish(), + Self::Terminal { ready, .. } => f + .debug_struct("Terminal") + .field("ready", ready) + .field("fd", &"") + .finish(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingKind { + Launch, + ShellList, + ShellAttach, + ShellDetach, + ShellKill, +} + +impl PendingKind { + fn for_shell(request: &HelperShellRequest) -> Self { + match request { + HelperShellRequest::List { .. } => Self::ShellList, + HelperShellRequest::Attach { .. } => Self::ShellAttach, + HelperShellRequest::Detach { .. } => Self::ShellDetach, + HelperShellRequest::Kill { .. } => Self::ShellKill, + } + } + + fn for_shell_response(response: &HelperShellResponse) -> Self { + match response { + HelperShellResponse::List(_) => Self::ShellList, + HelperShellResponse::Detach(_) => Self::ShellDetach, + HelperShellResponse::Kill(_) => Self::ShellKill, + } + } + + fn is_launch(self) -> bool { + self == Self::Launch + } +} + struct PendingRequest { operation_id: String, + kind: PendingKind, sender: mpsc::SyncSender>, } +struct AbandonedRequest { + operation_id: String, + kind: PendingKind, + expires_at: Instant, +} + struct HelperConnection { generation: u64, socket: Arc, outbound: mpsc::SyncSender, outbound_wakeup: Arc, pending: Mutex>, + abandoned: Mutex>, last_heartbeat_millis: AtomicU64, connected_at: Instant, closed: AtomicBool, @@ -138,12 +213,48 @@ impl HelperConnection { } Ok(()) } + + fn abandon_pending(&self, request_id: u64, operation_id: &str) { + let Some(pending) = self.pending.lock().remove(&request_id) else { + return; + }; + if pending.operation_id != operation_id { + return; + } + let now = Instant::now(); + let mut abandoned = self.abandoned.lock(); + abandoned.retain(|_, request| request.expires_at > now); + if abandoned.len() >= MAX_HELPER_QUEUE_DEPTH + && let Some(oldest) = abandoned + .iter() + .min_by_key(|(_, request)| request.expires_at) + .map(|(request_id, _)| *request_id) + { + abandoned.remove(&oldest); + } + abandoned.insert( + request_id, + AbandonedRequest { + operation_id: operation_id.to_owned(), + kind: pending.kind, + expires_at: now + LATE_RESPONSE_RETENTION, + }, + ); + } + + fn reap_abandoned(&self) { + let now = Instant::now(); + self.abandoned + .lock() + .retain(|_, request| request.expires_at > now); + } } #[derive(Default)] struct RegistryState { connections: HashMap>, snapshots: HashMap, + last_failures: HashMap<(u32, String), HelperFailureCode>, } pub struct HelperRegistry { @@ -218,6 +329,32 @@ impl HelperRegistry { self.state.lock().snapshots.get(&uid).cloned() } + pub fn availability(&self, uid: u32) -> HelperAvailability { + let state = self.state.lock(); + let Some(connection) = state.connections.get(&uid) else { + return HelperAvailability::Unavailable; + }; + if connection.closed.load(Ordering::Acquire) { + HelperAvailability::Unavailable + } else if connection.is_stale() { + HelperAvailability::Stale + } else { + HelperAvailability::Ready + } + } + + pub fn last_failure( + &self, + uid: u32, + target: &d2b_core::workload_identity::WorkloadTarget, + ) -> Option { + self.state + .lock() + .last_failures + .get(&(uid, target.to_canonical())) + .copied() + } + pub fn dispatch_launch( &self, requester_uid: u32, @@ -225,6 +362,7 @@ impl HelperRegistry { ) -> Result { let fingerprint = launch_fingerprint(&request)?; let operation_key = request.operation_id.to_string(); + let workload_target = request.workload.canonical_target.to_canonical(); let request_id = request.request_id; match self.operations.lock().begin( requester_uid, @@ -259,6 +397,9 @@ impl HelperRegistry { return Err(HelperRegistryError::HelperUnavailable); } if connection.is_stale() { + self.operations + .lock() + .abort_active(requester_uid, &operation_key); return Err(HelperRegistryError::HelperStale); } let (sender, receiver) = mpsc::sync_channel(1); @@ -275,6 +416,7 @@ impl HelperRegistry { request.request_id, PendingRequest { operation_id: operation_key.clone(), + kind: PendingKind::Launch, sender, }, ) @@ -302,6 +444,10 @@ impl HelperRegistry { result.clone(), now_epoch_seconds(), ); + self.state + .lock() + .last_failures + .remove(&(requester_uid, workload_target)); Ok(result) } Ok(Ok(HelperReply::Rejected(rejected))) => { @@ -311,13 +457,115 @@ impl HelperRegistry { rejected.code, now_epoch_seconds(), ); + self.state + .lock() + .last_failures + .insert((requester_uid, workload_target), rejected.code); Err(HelperRegistryError::OperationRejected(rejected.code)) } Ok(Ok(HelperReply::Terminal { .. })) => { + connection.pending.lock().remove(&request_id); + self.operations + .lock() + .abort_active(requester_uid, &operation_key); + Err(HelperRegistryError::RequestCorrelationMismatch) + } + Ok(Ok(HelperReply::Shell(_))) => { + connection.pending.lock().remove(&request_id); + self.operations + .lock() + .abort_active(requester_uid, &operation_key); + Err(HelperRegistryError::RequestCorrelationMismatch) + } + Ok(Err(error)) => { + connection.pending.lock().remove(&request_id); + self.operations + .lock() + .abort_active(requester_uid, &operation_key); + Err(error) + } + Err(_) => { + connection.abandon_pending(request_id, &operation_key); + Err(HelperRegistryError::Timeout) + } + } + } + + pub fn dispatch_shell( + &self, + requester_uid: u32, + request: HelperShellRequest, + ) -> Result { + self.dispatch_shell_with_timeout(requester_uid, request, HELPER_OPERATION_TIMEOUT) + } + + fn dispatch_shell_with_timeout( + &self, + requester_uid: u32, + request: HelperShellRequest, + timeout: Duration, + ) -> Result { + request + .validate_bounds() + .map_err(|_| HelperRegistryError::InvalidRequest)?; + let request_id = request.request_id(); + let operation_id = request.operation_id().to_string(); + let kind = PendingKind::for_shell(&request); + let connection = self + .state + .lock() + .connections + .get(&requester_uid) + .cloned() + .ok_or(HelperRegistryError::HelperUnavailable)?; + if connection.closed.load(Ordering::Acquire) { + return Err(HelperRegistryError::HelperUnavailable); + } + if connection.is_stale() { + return Err(HelperRegistryError::HelperStale); + } + + let (sender, receiver) = mpsc::sync_channel(1); + { + let mut pending = connection.pending.lock(); + if pending.len() >= MAX_HELPER_QUEUE_DEPTH { + return Err(HelperRegistryError::QueueFull); + } + if pending + .insert( + request_id, + PendingRequest { + operation_id: operation_id.clone(), + kind, + sender, + }, + ) + .is_some() + { + return Err(HelperRegistryError::RequestCorrelationMismatch); + } + } + if let Err(error) = connection.queue_outbound(DaemonToUnsafeLocalHelper::Shell(request)) { + connection.pending.lock().remove(&request_id); + return Err(error); + } + + match receiver.recv_timeout(timeout) { + Ok(Ok(HelperReply::Shell(response))) => Ok(HelperShellReply::Management(response)), + Ok(Ok(HelperReply::Terminal { ready, fd })) => { + Ok(HelperShellReply::Terminal { ready, fd }) + } + Ok(Ok(HelperReply::Rejected(rejected))) => { + Err(HelperRegistryError::OperationRejected(rejected.code)) + } + Ok(Ok(HelperReply::Operation(_))) => { Err(HelperRegistryError::RequestCorrelationMismatch) } Ok(Err(error)) => Err(error), - Err(_) => Err(HelperRegistryError::Timeout), + Err(_) => { + connection.abandon_pending(request_id, &operation_id); + Err(HelperRegistryError::Timeout) + } } } @@ -343,7 +591,8 @@ impl HelperRegistry { let UnsafeLocalHelperToDaemon::Hello(hello) = hello else { return Err(HelperRegistryError::ProtocolMismatch); }; - if hello.protocol_version != UNSAFE_LOCAL_HELPER_PROTOCOL_VERSION || hello.generation == 0 { + if !unsafe_local_helper_protocol_supported(hello.protocol_version) || hello.generation == 0 + { return Err(HelperRegistryError::ProtocolMismatch); } send_frame( @@ -387,6 +636,7 @@ impl HelperRegistry { outbound, outbound_wakeup: Arc::new(outbound_wakeup_write), pending: Mutex::new(HashMap::new()), + abandoned: Mutex::new(HashMap::new()), last_heartbeat_millis: AtomicU64::new(0), connected_at: Instant::now(), closed: AtomicBool::new(false), @@ -449,6 +699,7 @@ impl HelperRegistry { let mut heartbeat_sequence = 0u64; let mut next_heartbeat = Instant::now() + HELPER_HEARTBEAT_INTERVAL; loop { + connection.reap_abandoned(); if connection.closed.load(Ordering::Acquire) { return Err(HelperRegistryError::GenerationSuperseded); } @@ -484,11 +735,11 @@ impl HelperRegistry { match receive_frame::(&connection.socket, receive_buffer) { Ok((frame, fds)) => { if !self.is_active_generation(uid, connection) { - close_raw_fds(fds); + drop_received_fds(fds); return Err(HelperRegistryError::GenerationSuperseded); } connection.touch(); - self.handle_incoming(connection, frame, fds)?; + self.handle_incoming(uid, connection, frame, fds)?; } Err(error) => return Err(error), } @@ -505,9 +756,10 @@ impl HelperRegistry { fn handle_incoming( &self, + uid: u32, connection: &HelperConnection, frame: UnsafeLocalHelperToDaemon, - fds: Vec, + fds: Vec, ) -> Result<(), HelperRegistryError> { match frame { UnsafeLocalHelperToDaemon::Heartbeat(heartbeat) => { @@ -519,21 +771,42 @@ impl HelperRegistry { } UnsafeLocalHelperToDaemon::Operation(result) => { reject_unexpected_fds(fds)?; - complete_pending( + let completion = complete_pending( connection, result.request_id, result.operation_id.to_string(), - HelperReply::Operation(result), - ) + HelperReply::Operation(result.clone()), + Some(PendingKind::Launch), + )?; + if !completion.delivered { + let operation_id = result.operation_id.to_string(); + self.operations.lock().complete( + uid, + &operation_id, + result, + now_epoch_seconds(), + ); + } + Ok(()) } UnsafeLocalHelperToDaemon::Rejected(rejected) => { reject_unexpected_fds(fds)?; - complete_pending( + let completion = complete_pending( connection, rejected.request_id, rejected.operation_id.to_string(), - HelperReply::Rejected(rejected), - ) + HelperReply::Rejected(rejected.clone()), + None, + )?; + if !completion.delivered && completion.kind.is_launch() { + self.operations.lock().reject( + uid, + rejected.operation_id.as_str(), + rejected.code, + now_epoch_seconds(), + ); + } + Ok(()) } UnsafeLocalHelperToDaemon::TerminalReady(ready) => { let fd = validate_terminal_fd(&ready, fds)?; @@ -542,7 +815,21 @@ impl HelperRegistry { ready.request_id, ready.operation_id.to_string(), HelperReply::Terminal { ready, fd }, + Some(PendingKind::ShellAttach), + ) + .map(|_| ()) + } + UnsafeLocalHelperToDaemon::Shell(response) => { + reject_unexpected_fds(fds)?; + let kind = PendingKind::for_shell_response(&response); + complete_pending( + connection, + response.request_id(), + response.operation_id().to_string(), + HelperReply::Shell(response), + Some(kind), ) + .map(|_| ()) } UnsafeLocalHelperToDaemon::Hello(_) | UnsafeLocalHelperToDaemon::Snapshot(_) => { reject_unexpected_fds(fds)?; @@ -552,25 +839,47 @@ impl HelperRegistry { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PendingCompletion { + delivered: bool, + kind: PendingKind, +} + fn complete_pending( connection: &HelperConnection, request_id: u64, operation_id: String, reply: HelperReply, -) -> Result<(), HelperRegistryError> { - let Some(pending) = connection.pending.lock().remove(&request_id) else { - return Err(HelperRegistryError::RequestCorrelationMismatch); + expected_kind: Option, +) -> Result { + let pending = connection.pending.lock().remove(&request_id); + let Some(pending) = pending else { + let abandoned = connection.abandoned.lock().remove(&request_id); + return match abandoned { + Some(abandoned) + if abandoned.operation_id == operation_id + && expected_kind.is_none_or(|expected| expected == abandoned.kind) => + { + Ok(PendingCompletion { + delivered: false, + kind: abandoned.kind, + }) + } + _ => Err(HelperRegistryError::RequestCorrelationMismatch), + }; }; - if pending.operation_id != operation_id { + if pending.operation_id != operation_id + || expected_kind.is_some_and(|expected| expected != pending.kind) + { let _ = pending .sender .try_send(Err(HelperRegistryError::RequestCorrelationMismatch)); return Err(HelperRegistryError::RequestCorrelationMismatch); } - pending - .sender - .try_send(Ok(reply)) - .map_err(|_| HelperRegistryError::RequestCorrelationMismatch) + Ok(PendingCompletion { + delivered: pending.sender.try_send(Ok(reply)).is_ok(), + kind: pending.kind, + }) } pub fn bind_helper_socket( @@ -646,7 +955,7 @@ fn send_frame(socket: &Socket, frame: &T) -> Result<(), HelperRegi fn receive_frame( socket: &Socket, encoded: &mut [u8], -) -> Result<(T, Vec), HelperRegistryError> { +) -> Result<(T, Vec), HelperRegistryError> { if encoded.len() < MAX_HELPER_FRAME_SIZE + 5 { return Err(HelperRegistryError::FrameTooLarge); } @@ -662,6 +971,15 @@ fn receive_frame( Err(_) => return Err(HelperRegistryError::Io), }; let read = message.bytes; + let mut fds = Vec::new(); + for control in message + .cmsgs() + .map_err(|_| HelperRegistryError::InvalidFrame)? + { + if let ControlMessageOwned::ScmRights(rights) = control { + fds.extend(rights.into_iter().map(ReceivedFd)); + } + } if read == 0 { return Err(HelperRegistryError::Io); } @@ -671,17 +989,7 @@ fn receive_frame( { return Err(HelperRegistryError::FrameTooLarge); } - let mut fds = Vec::new(); - for control in message - .cmsgs() - .map_err(|_| HelperRegistryError::InvalidFrame)? - { - if let ControlMessageOwned::ScmRights(rights) = control { - fds.extend(rights); - } - } if read < 4 { - close_raw_fds(fds); return Err(HelperRegistryError::InvalidFrame); } let declared = u32::from_le_bytes( @@ -690,17 +998,13 @@ fn receive_frame( .map_err(|_| HelperRegistryError::InvalidFrame)?, ) as usize; if declared > MAX_HELPER_FRAME_SIZE { - close_raw_fds(fds); return Err(HelperRegistryError::FrameTooLarge); } if read != declared + 4 { - close_raw_fds(fds); return Err(HelperRegistryError::InvalidFrame); } - let frame = serde_json::from_slice(&encoded[4..read]).map_err(|_| { - close_raw_fds(fds.clone()); - HelperRegistryError::InvalidFrame - })?; + let frame = + serde_json::from_slice(&encoded[4..read]).map_err(|_| HelperRegistryError::InvalidFrame)?; Ok((frame, fds)) } @@ -775,25 +1079,42 @@ fn drain_outbound_wakeup(outbound_wakeup: &UnixStream) -> Result<(), HelperRegis } } +struct ReceivedFd(RawFd); + +impl AsRawFd for ReceivedFd { + fn as_raw_fd(&self) -> RawFd { + self.0 + } +} + +impl Drop for ReceivedFd { + fn drop(&mut self) { + let _ = unistd::close(self.0); + } +} + fn validate_terminal_fd( ready: &HelperTerminalReady, - fds: Vec, + mut fds: Vec, ) -> Result { if ready.terminal_protocol_version != UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION + || !matches!( + ready.transport, + d2b_contracts::unsafe_local_wire::HelperTerminalTransport::ConnectedUnixStream + ) || fds.len() != UNSAFE_LOCAL_TERMINAL_FD_COUNT { - close_raw_fds(fds); return Err(HelperRegistryError::InvalidTerminalFd); } - let raw = fds[0]; - let duplicated = duplicate_received_fd(raw)?; - close_raw_fds(fds); - let flags = fcntl(duplicated.as_raw_fd(), FcntlArg::F_GETFD) + let received = fds.pop().ok_or(HelperRegistryError::InvalidTerminalFd)?; + let flags = fcntl(received.as_raw_fd(), FcntlArg::F_GETFD) .map_err(|_| HelperRegistryError::InvalidTerminalFd)?; - if !FdFlag::from_bits_truncate(flags).contains(FdFlag::FD_CLOEXEC) - || getsockopt(&duplicated, SocketTypeOpt) - .map_err(|_| HelperRegistryError::InvalidTerminalFd)? - != nix::sys::socket::SockType::Stream + if !FdFlag::from_bits_truncate(flags).contains(FdFlag::FD_CLOEXEC) { + return Err(HelperRegistryError::InvalidTerminalFd); + } + let duplicated = duplicate_received_fd(received.as_raw_fd())?; + if getsockopt(&duplicated, SocketTypeOpt).map_err(|_| HelperRegistryError::InvalidTerminalFd)? + != nix::sys::socket::SockType::Stream || getsockopt(&duplicated, AcceptConn) .map_err(|_| HelperRegistryError::InvalidTerminalFd)? || getpeername::(duplicated.as_raw_fd()).is_err() @@ -819,15 +1140,13 @@ fn duplicate_received_fd(raw: RawFd) -> Result { Ok(duplicated) } -fn close_raw_fds(fds: Vec) { - for fd in fds { - let _ = unistd::close(fd); - } +fn drop_received_fds(fds: Vec) { + drop(fds); } -fn reject_unexpected_fds(fds: Vec) -> Result<(), HelperRegistryError> { +fn reject_unexpected_fds(fds: Vec) -> Result<(), HelperRegistryError> { let unexpected = !fds.is_empty(); - close_raw_fds(fds); + drop_received_fds(fds); if unexpected { Err(HelperRegistryError::InvalidTerminalFd) } else { @@ -837,7 +1156,9 @@ fn reject_unexpected_fds(fds: Vec) -> Result<(), HelperRegistryError> { #[derive(Clone)] enum LedgerState { - Active, + Active { + started_at: u64, + }, Completed { result: HelperOperationResult, completed_at: u64, @@ -883,17 +1204,25 @@ impl OperationLedger { return Err(HelperRegistryError::OperationIdConflict); } return match &entry.state { - LedgerState::Active => Err(HelperRegistryError::OperationInProgress), + LedgerState::Active { .. } => Err(HelperRegistryError::OperationInProgress), LedgerState::Completed { result, .. } => Ok(LedgerBegin::Completed(result.clone())), LedgerState::Rejected { code, .. } => Ok(LedgerBegin::Rejected(*code)), LedgerState::Adopted { .. } => Err(HelperRegistryError::OperationIdConflict), }; } + if entries + .values() + .filter(|entry| matches!(entry.state, LedgerState::Active { .. })) + .count() + >= MAX_HELPER_QUEUE_DEPTH + { + return Err(HelperRegistryError::QueueFull); + } entries.insert( operation_id, LedgerEntry { fingerprint: Some(fingerprint), - state: LedgerState::Active, + state: LedgerState::Active { started_at: now }, }, ); Ok(LedgerBegin::Started) @@ -919,7 +1248,7 @@ impl OperationLedger { }; if entries .get(operation_id) - .is_some_and(|entry| matches!(entry.state, LedgerState::Active)) + .is_some_and(|entry| matches!(entry.state, LedgerState::Active { .. })) { entries.remove(operation_id); } @@ -942,6 +1271,12 @@ impl OperationLedger { fn adopt_snapshot(&mut self, uid: u32, snapshot: &HelperSnapshot, now: u64) { let entries = self.by_uid.entry(uid).or_default(); for scope in &snapshot.scopes { + if scope.scope.kind + == d2b_contracts::unsafe_local_wire::HelperScopeKind::PersistentShell + || scope.persistent_shell.is_some() + { + continue; + } let operation_id = scope.operation_id.to_string(); let adopted_result = HelperOperationResult { request_id: 0, @@ -951,7 +1286,7 @@ impl OperationLedger { }; match entries.entry(operation_id) { std::collections::hash_map::Entry::Occupied(mut occupied) => { - if matches!(occupied.get().state, LedgerState::Active) { + if matches!(occupied.get().state, LedgerState::Active { .. }) { occupied.get_mut().state = LedgerState::Completed { result: adopted_result, completed_at: now, @@ -974,7 +1309,9 @@ impl OperationLedger { return; }; entries.retain(|_, entry| match &entry.state { - LedgerState::Active => true, + LedgerState::Active { started_at } => { + now.saturating_sub(*started_at) < HELPER_ACTIVE_OPERATION_RETENTION_SECS + } LedgerState::Completed { completed_at, .. } | LedgerState::Rejected { completed_at, .. } | LedgerState::Adopted { completed_at, .. } => { @@ -984,7 +1321,7 @@ impl OperationLedger { let mut completed: Vec<(String, u64)> = entries .iter() .filter_map(|(id, entry)| match &entry.state { - LedgerState::Active => None, + LedgerState::Active { .. } => None, LedgerState::Completed { completed_at, .. } | LedgerState::Rejected { completed_at, .. } | LedgerState::Adopted { completed_at, .. } => Some((id.clone(), *completed_at)), @@ -1008,6 +1345,7 @@ fn launch_fingerprint(request: &HelperLaunchRequest) -> Result<[u8; 32], HelperR &request.item_id, &request.argv, request.graphical, + &request.realm_accent_color, )) .map_err(|_| HelperRegistryError::InvalidFrame)?; Ok(Sha256::digest(encoded).into()) @@ -1023,6 +1361,12 @@ fn now_epoch_seconds() -> u64 { #[cfg(test)] mod tests { use super::*; + use d2b_contracts::public_wire::{ + ShellDetachResult, ShellListResult, ShellName, ShellSessionState, + }; + use d2b_contracts::unsafe_local_wire::{ + HelperShellDetachResponse, HelperShellListResponse, HelperShellPolicy, + }; use d2b_core::configured_argv::ConfiguredArgv; use d2b_realm_core::{ids::OperationId, token::ProtocolToken}; use nix::sys::socket::{AddressFamily, SockFlag, socketpair}; @@ -1042,6 +1386,8 @@ mod tests { item_id: ProtocolToken::parse("browser").unwrap(), argv: ConfiguredArgv::new(vec![arg.to_owned()]).unwrap(), graphical: false, + realm_accent_color: d2b_contracts::unsafe_local_wire::RealmAccentColor::new("#336699") + .unwrap(), } } @@ -1054,6 +1400,50 @@ mod tests { } } + fn shell_list(request_id: u64, operation_id: &str) -> HelperShellRequest { + HelperShellRequest::List { + request_id, + operation_id: OperationId::parse(operation_id).unwrap(), + workload: launch(1, "shell-workload", "true").workload, + policy: HelperShellPolicy { + default_name: ShellName::new("primary").unwrap(), + max_sessions: 4, + }, + } + } + + fn shell_attach(request_id: u64, operation_id: &str) -> HelperShellRequest { + let HelperShellRequest::List { + workload, policy, .. + } = shell_list(request_id, operation_id) + else { + unreachable!() + }; + HelperShellRequest::Attach { + request_id, + operation_id: OperationId::parse(operation_id).unwrap(), + workload, + policy, + name: None, + force: false, + initial_terminal_size: d2b_contracts::terminal_wire::TerminalSize { + rows: 24, + cols: 80, + }, + } + } + + fn shell_list_response(request: &HelperShellRequest) -> HelperShellResponse { + HelperShellResponse::List(HelperShellListResponse { + request_id: request.request_id(), + operation_id: request.operation_id().clone(), + result: ShellListResult { + default_name: ShellName::new("primary").unwrap(), + sessions: Vec::new(), + }, + }) + } + #[test] fn queued_launch_wakes_idle_connection_loop() { let (control, _peer) = seqpacket_pair(); @@ -1116,17 +1506,38 @@ mod tests { } #[test] - fn operation_ledger_never_reaps_active_entries() { + fn operation_ledger_rejects_reuse_with_changed_realm_accent() { let mut ledger = OperationLedger::default(); - let active = launch(1, "active-op", "program"); - ledger - .begin( + let mut first = launch(1, "accent-op", "program"); + first.graphical = true; + let fingerprint = launch_fingerprint(&first).unwrap(); + assert!(matches!( + ledger + .begin(1000, "accent-op".to_owned(), fingerprint, 1) + .unwrap(), + LedgerBegin::Started + )); + ledger.complete(1000, "accent-op", completed(&first), 2); + + let mut changed = first.clone(); + changed.request_id = 2; + changed.realm_accent_color = + d2b_contracts::unsafe_local_wire::RealmAccentColor::new("#cc3300").unwrap(); + + assert!(matches!( + ledger.begin( 1000, - "active-op".to_owned(), - launch_fingerprint(&active).unwrap(), - 0, - ) - .unwrap(); + "accent-op".to_owned(), + launch_fingerprint(&changed).unwrap(), + 3 + ), + Err(HelperRegistryError::OperationIdConflict) + )); + } + + #[test] + fn operation_ledger_keeps_unexpired_active_entries() { + let mut ledger = OperationLedger::default(); for index in 0..=MAX_COMPLETED_OPERATIONS_PER_UID { let request = launch(index as u64 + 2, &format!("done-{index}"), "program"); let key = request.operation_id.to_string(); @@ -1140,20 +1551,143 @@ mod tests { .unwrap(); ledger.complete(1000, &key, completed(&request), index as u64 + 1); } + let active = launch(1, "active-op", "program"); + ledger + .begin( + 1000, + "active-op".to_owned(), + launch_fingerprint(&active).unwrap(), + MAX_COMPLETED_OPERATIONS_PER_UID as u64 + 2, + ) + .unwrap(); let entries = ledger.by_uid.get(&1000).unwrap(); assert!(matches!( entries.get("active-op").map(|entry| &entry.state), - Some(LedgerState::Active) + Some(LedgerState::Active { .. }) )); assert!( entries .values() - .filter(|entry| !matches!(entry.state, LedgerState::Active)) + .filter(|entry| !matches!(entry.state, LedgerState::Active { .. })) .count() <= MAX_COMPLETED_OPERATIONS_PER_UID ); } + #[test] + fn aborted_helper_operation_can_retry_same_id() { + let mut ledger = OperationLedger::default(); + let request = launch(1, "retry-op", "program"); + let fingerprint = launch_fingerprint(&request).unwrap(); + assert!(matches!( + ledger + .begin(1000, "retry-op".to_owned(), fingerprint, 1) + .unwrap(), + LedgerBegin::Started + )); + ledger.abort_active(1000, "retry-op"); + assert!(matches!( + ledger + .begin(1000, "retry-op".to_owned(), fingerprint, 2) + .unwrap(), + LedgerBegin::Started + )); + } + + #[test] + fn timed_out_operation_stays_ambiguous_until_active_expiry() { + let mut ledger = OperationLedger::default(); + let request = launch(1, "timed-out-op", "program"); + let fingerprint = launch_fingerprint(&request).unwrap(); + assert!(matches!( + ledger.begin(1000, "timed-out-op".to_owned(), fingerprint, 1), + Ok(LedgerBegin::Started) + )); + assert!(matches!( + ledger.begin( + 1000, + "timed-out-op".to_owned(), + fingerprint, + 1 + HELPER_OPERATION_TIMEOUT.as_secs() + ), + Err(HelperRegistryError::OperationInProgress) + )); + let changed = launch(2, "timed-out-op", "different"); + assert!(matches!( + ledger.begin( + 1000, + "timed-out-op".to_owned(), + launch_fingerprint(&changed).unwrap(), + 1 + HELPER_OPERATION_TIMEOUT.as_secs() + ), + Err(HelperRegistryError::OperationIdConflict) + )); + assert!(matches!( + ledger.begin( + 1000, + "timed-out-op".to_owned(), + fingerprint, + 1 + HELPER_ACTIVE_OPERATION_RETENTION_SECS + ), + Ok(LedgerBegin::Started) + )); + } + + #[test] + fn late_response_reconciles_abandoned_request_without_reopening_launch() { + let uid = 1000; + let registry = HelperRegistry::new(42, [uid]); + let request = launch(7, "late-op", "program"); + let fingerprint = launch_fingerprint(&request).unwrap(); + registry + .operations + .lock() + .begin(uid, "late-op".to_owned(), fingerprint, 1) + .unwrap(); + let (socket, _peer) = seqpacket_pair(); + let (outbound, _outbound_rx) = mpsc::sync_channel(1); + let (_wakeup_read, wakeup_write) = UnixStream::pair().unwrap(); + let connection = HelperConnection { + generation: 1, + socket: Arc::new(socket), + outbound, + outbound_wakeup: Arc::new(wakeup_write), + pending: Mutex::new(HashMap::new()), + abandoned: Mutex::new(HashMap::new()), + last_heartbeat_millis: AtomicU64::new(0), + connected_at: Instant::now(), + closed: AtomicBool::new(false), + }; + let (sender, receiver) = mpsc::sync_channel(1); + connection.pending.lock().insert( + request.request_id, + PendingRequest { + operation_id: request.operation_id.to_string(), + kind: PendingKind::Launch, + sender, + }, + ); + drop(receiver); + connection.abandon_pending(request.request_id, request.operation_id.as_str()); + + registry + .handle_incoming( + uid, + &connection, + UnsafeLocalHelperToDaemon::Operation(completed(&request)), + Vec::new(), + ) + .unwrap(); + assert!(matches!( + registry + .operations + .lock() + .begin(uid, "late-op".to_owned(), fingerprint, 2), + Ok(LedgerBegin::Completed(_)) + )); + assert!(connection.abandoned.lock().is_empty()); + } + #[test] fn reconnect_snapshot_reconciles_an_ambiguous_active_launch() { let mut ledger = OperationLedger::default(); @@ -1174,6 +1708,7 @@ mod tests { kind: d2b_contracts::unsafe_local_wire::HelperScopeKind::LauncherApp, }, state: d2b_contracts::unsafe_local_wire::HelperScopeState::Active, + persistent_shell: None, }], }, 2, @@ -1187,6 +1722,45 @@ mod tests { )); } + #[test] + fn reconnect_snapshot_does_not_adopt_shells_into_launcher_ledger() { + let mut ledger = OperationLedger::default(); + ledger.adopt_snapshot( + 1000, + &HelperSnapshot { + generation: 2, + scopes: vec![d2b_contracts::unsafe_local_wire::HelperScopeSnapshot { + operation_id: OperationId::parse("persistent-shell-operation").unwrap(), + workload: launch(1, "shell-snapshot-workload", "true").workload, + scope: d2b_contracts::unsafe_local_wire::ScopeIdentity { + invocation_id: "00112233445566778899aabbccddeeff".to_owned(), + kind: d2b_contracts::unsafe_local_wire::HelperScopeKind::PersistentShell, + }, + state: d2b_contracts::unsafe_local_wire::HelperScopeState::Active, + persistent_shell: Some( + d2b_contracts::unsafe_local_wire::HelperPersistentShellSnapshot { + name: ShellName::new("primary").unwrap(), + state: ShellSessionState::Detached, + attached: false, + supervisor_id: + d2b_contracts::unsafe_local_wire::HelperSupervisorId::new( + "opaque-supervisor", + ) + .unwrap(), + }, + ), + }], + }, + 2, + ); + assert!( + !ledger + .by_uid + .get(&1000) + .is_some_and(|entries| entries.contains_key("persistent-shell-operation")) + ); + } + #[test] fn registry_never_selects_a_different_uid_helper() { let registry = HelperRegistry::new(42, [1000, 1001]); @@ -1240,6 +1814,250 @@ mod tests { ); } + #[test] + fn registered_helper_dispatches_shell_without_launcher_ledger_crosstalk() { + if unistd::getuid().is_root() || !host_supports_helper_socket_buffers() { + return; + } + let uid = unistd::getuid().as_raw(); + let registry = Arc::new(HelperRegistry::new(uid.wrapping_add(1), [uid])); + let helper = register_helper(Arc::clone(®istry), 31); + let request = shell_list(301, "shell-list-correlated"); + let expected_operation = request.operation_id().clone(); + let dispatch_registry = Arc::clone(®istry); + let dispatch = std::thread::spawn(move || dispatch_registry.dispatch_shell(uid, request)); + let mut receive_buffer = vec![0u8; MAX_HELPER_FRAME_SIZE + 5]; + + loop { + let (frame, fds) = + receive_frame::(&helper, &mut receive_buffer).unwrap(); + reject_unexpected_fds(fds).unwrap(); + match frame { + DaemonToUnsafeLocalHelper::Shell(request) => { + assert_eq!(request.request_id(), 301); + assert_eq!(request.operation_id(), &expected_operation); + send_frame( + &helper, + &UnsafeLocalHelperToDaemon::Shell(shell_list_response(&request)), + ) + .unwrap(); + break; + } + DaemonToUnsafeLocalHelper::Heartbeat(heartbeat) => { + send_frame(&helper, &UnsafeLocalHelperToDaemon::Heartbeat(heartbeat)).unwrap(); + } + other => panic!("unexpected daemon frame: {other:?}"), + } + } + assert!(matches!( + dispatch.join().unwrap().unwrap(), + HelperShellReply::Management(HelperShellResponse::List(_)) + )); + assert!( + !registry + .operations + .lock() + .by_uid + .get(&uid) + .is_some_and(|entries| entries.contains_key("shell-list-correlated")) + ); + assert!(matches!( + registry.dispatch_shell(uid.wrapping_add(1), shell_list(1, "wrong-uid")), + Err(HelperRegistryError::HelperUnavailable) + )); + } + + #[test] + fn shell_timeout_is_ambiguous_only_in_shell_pending_state() { + let uid = 1000; + let registry = HelperRegistry::new(42, [uid]); + let (socket, peer) = seqpacket_pair(); + let (outbound, _outbound_rx) = mpsc::sync_channel(MAX_HELPER_QUEUE_DEPTH); + let (_wakeup_read, wakeup_write) = UnixStream::pair().unwrap(); + let connection = Arc::new(HelperConnection { + generation: 1, + socket: Arc::new(socket), + outbound, + outbound_wakeup: Arc::new(wakeup_write), + pending: Mutex::new(HashMap::new()), + abandoned: Mutex::new(HashMap::new()), + last_heartbeat_millis: AtomicU64::new(0), + connected_at: Instant::now(), + closed: AtomicBool::new(false), + }); + registry + .state + .lock() + .connections + .insert(uid, Arc::clone(&connection)); + assert!(matches!( + registry.dispatch_shell_with_timeout( + uid, + shell_list(9, "shell-timeout"), + Duration::from_millis(1) + ), + Err(HelperRegistryError::Timeout) + )); + assert_eq!( + connection + .abandoned + .lock() + .get(&9) + .map(|request| request.kind), + Some(PendingKind::ShellList) + ); + assert!(!registry.operations.lock().by_uid.contains_key(&uid)); + drop(peer); + } + + #[test] + fn late_terminal_ready_closes_fd_and_does_not_touch_launch_ledger() { + let uid = 1000; + let registry = HelperRegistry::new(42, [uid]); + let request = shell_attach(17, "late-shell-terminal"); + let (socket, _peer) = seqpacket_pair(); + let (outbound, _outbound_rx) = mpsc::sync_channel(1); + let (_wakeup_read, wakeup_write) = UnixStream::pair().unwrap(); + let connection = HelperConnection { + generation: 1, + socket: Arc::new(socket), + outbound, + outbound_wakeup: Arc::new(wakeup_write), + pending: Mutex::new(HashMap::new()), + abandoned: Mutex::new(HashMap::new()), + last_heartbeat_millis: AtomicU64::new(0), + connected_at: Instant::now(), + closed: AtomicBool::new(false), + }; + let (sender, receiver) = mpsc::sync_channel(1); + connection.pending.lock().insert( + request.request_id(), + PendingRequest { + operation_id: request.operation_id().to_string(), + kind: PendingKind::ShellAttach, + sender, + }, + ); + drop(receiver); + connection.abandon_pending(request.request_id(), request.operation_id().as_str()); + + let (stream, peer): (OwnedFd, OwnedFd) = socketpair( + AddressFamily::Unix, + nix::sys::socket::SockType::Stream, + None, + SockFlag::SOCK_CLOEXEC, + ) + .unwrap(); + let raw = fcntl(stream.as_raw_fd(), FcntlArg::F_DUPFD(512)).unwrap(); + fcntl(raw, FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC)).unwrap(); + let ready = HelperTerminalReady { + request_id: request.request_id(), + operation_id: request.operation_id().clone(), + terminal_protocol_version: UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION, + transport: + d2b_contracts::unsafe_local_wire::HelperTerminalTransport::ConnectedUnixStream, + scope: d2b_contracts::unsafe_local_wire::ScopeIdentity { + invocation_id: "00112233445566778899aabbccddeeff".to_owned(), + kind: d2b_contracts::unsafe_local_wire::HelperScopeKind::PersistentShell, + }, + result: d2b_contracts::unsafe_local_wire::HelperShellAttachResult { + resolved_name: ShellName::new("primary").unwrap(), + state: ShellSessionState::Attached, + force_evicted: false, + }, + }; + registry + .handle_incoming( + uid, + &connection, + UnsafeLocalHelperToDaemon::TerminalReady(ready), + vec![ReceivedFd(raw)], + ) + .unwrap(); + assert_eq!(fcntl(raw, FcntlArg::F_GETFD), Err(nix::errno::Errno::EBADF)); + assert!(!registry.operations.lock().by_uid.contains_key(&uid)); + drop(peer); + } + + #[test] + fn shell_response_kind_and_fd_count_are_fail_closed() { + let (socket, _peer) = seqpacket_pair(); + let (outbound, _outbound_rx) = mpsc::sync_channel(1); + let (_wakeup_read, wakeup_write) = UnixStream::pair().unwrap(); + let connection = HelperConnection { + generation: 1, + socket: Arc::new(socket), + outbound, + outbound_wakeup: Arc::new(wakeup_write), + pending: Mutex::new(HashMap::new()), + abandoned: Mutex::new(HashMap::new()), + last_heartbeat_millis: AtomicU64::new(0), + connected_at: Instant::now(), + closed: AtomicBool::new(false), + }; + let request = shell_list(23, "shell-kind-mismatch"); + let (sender, receiver) = mpsc::sync_channel(1); + connection.pending.lock().insert( + request.request_id(), + PendingRequest { + operation_id: request.operation_id().to_string(), + kind: PendingKind::ShellList, + sender, + }, + ); + let response = HelperShellResponse::Detach(HelperShellDetachResponse { + request_id: request.request_id(), + operation_id: request.operation_id().clone(), + result: ShellDetachResult { + resolved_name: ShellName::new("primary").unwrap(), + detached: true, + cause: None, + }, + }); + assert_eq!( + HelperRegistry::new(42, [1000]).handle_incoming( + 1000, + &connection, + UnsafeLocalHelperToDaemon::Shell(response), + Vec::new() + ), + Err(HelperRegistryError::RequestCorrelationMismatch) + ); + assert!(matches!( + receiver.recv().unwrap(), + Err(HelperRegistryError::RequestCorrelationMismatch) + )); + + let request = shell_list(24, "shell-unexpected-fd"); + let (sender, _receiver) = mpsc::sync_channel(1); + connection.pending.lock().insert( + request.request_id(), + PendingRequest { + operation_id: request.operation_id().to_string(), + kind: PendingKind::ShellList, + sender, + }, + ); + let (stream, _peer): (OwnedFd, OwnedFd) = socketpair( + AddressFamily::Unix, + nix::sys::socket::SockType::Stream, + None, + SockFlag::SOCK_CLOEXEC, + ) + .unwrap(); + let raw = unistd::dup(stream.as_raw_fd()).unwrap(); + assert_eq!( + HelperRegistry::new(42, [1000]).handle_incoming( + 1000, + &connection, + UnsafeLocalHelperToDaemon::Shell(shell_list_response(&request)), + vec![ReceivedFd(raw)] + ), + Err(HelperRegistryError::InvalidTerminalFd) + ); + assert_eq!(fcntl(raw, FcntlArg::F_GETFD), Err(nix::errno::Errno::EBADF)); + } + #[test] fn registered_helper_dispatches_correlated_launch_without_uid_in_frame() { if unistd::getuid().is_root() { @@ -1261,7 +2079,7 @@ mod tests { let (frame, fds) = receive_frame::(&helper, &mut receive_buffer) .expect("daemon request"); - close_raw_fds(fds); + drop_received_fds(fds); match frame { DaemonToUnsafeLocalHelper::Launch(request) => { assert_eq!(request.request_id, 91); @@ -1309,6 +2127,7 @@ mod tests { outbound, outbound_wakeup: Arc::new(outbound_wakeup_write), pending: Mutex::new(HashMap::new()), + abandoned: Mutex::new(HashMap::new()), last_heartbeat_millis: AtomicU64::new(0), connected_at: Instant::now(), closed: AtomicBool::new(false), @@ -1337,6 +2156,7 @@ mod tests { outbound, outbound_wakeup: Arc::new(outbound_wakeup_write), pending: Mutex::new(HashMap::new()), + abandoned: Mutex::new(HashMap::new()), last_heartbeat_millis: AtomicU64::new(0), connected_at: Instant::now() - HELPER_STALE_AFTER - Duration::from_millis(1), closed: AtomicBool::new(false), @@ -1345,6 +2165,24 @@ mod tests { drop(peer); } + #[test] + fn helper_failures_are_scoped_to_workload_target() { + let registry = HelperRegistry::new(999, [1000]); + let browser = + d2b_core::workload_identity::WorkloadTarget::parse("browser.host.d2b").unwrap(); + let editor = d2b_core::workload_identity::WorkloadTarget::parse("editor.host.d2b").unwrap(); + registry.state.lock().last_failures.insert( + (1000, browser.to_canonical()), + HelperFailureCode::ProxyUnavailable, + ); + + assert_eq!( + registry.last_failure(1000, &browser), + Some(HelperFailureCode::ProxyUnavailable) + ); + assert_eq!(registry.last_failure(1000, &editor), None); + } + #[test] fn terminal_fd_validation_accepts_only_connected_cloexec_stream() { let (stream, peer): (OwnedFd, OwnedFd) = socketpair( @@ -1355,6 +2193,7 @@ mod tests { ) .unwrap(); let raw = unistd::dup(stream.as_raw_fd()).unwrap(); + fcntl(raw, FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC)).unwrap(); let ready = HelperTerminalReady { request_id: 1, operation_id: OperationId::parse("op-terminal").unwrap(), @@ -1365,8 +2204,13 @@ mod tests { invocation_id: "00112233445566778899aabbccddeeff".to_owned(), kind: d2b_contracts::unsafe_local_wire::HelperScopeKind::PersistentShell, }, + result: d2b_contracts::unsafe_local_wire::HelperShellAttachResult { + resolved_name: d2b_contracts::public_wire::ShellName::new("default").unwrap(), + state: d2b_contracts::public_wire::ShellSessionState::Attached, + force_evicted: false, + }, }; - let validated = validate_terminal_fd(&ready, vec![raw]).unwrap(); + let validated = validate_terminal_fd(&ready, vec![ReceivedFd(raw)]).unwrap(); let flags = fcntl(validated.as_raw_fd(), FcntlArg::F_GETFD).unwrap(); assert!(FdFlag::from_bits_truncate(flags).contains(FdFlag::FD_CLOEXEC)); drop(peer); @@ -1379,13 +2223,63 @@ mod tests { ) .unwrap(); let raw = unistd::dup(datagram.as_raw_fd()).unwrap(); + fcntl(raw, FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC)).unwrap(); assert!(matches!( - validate_terminal_fd(&ready, vec![raw]), + validate_terminal_fd(&ready, vec![ReceivedFd(raw)]), Err(HelperRegistryError::InvalidTerminalFd) )); drop(datagram_peer); } + #[test] + fn terminal_fd_validation_checks_received_cloexec_and_closes_errors() { + let (stream, _peer): (OwnedFd, OwnedFd) = socketpair( + AddressFamily::Unix, + nix::sys::socket::SockType::Stream, + None, + SockFlag::empty(), + ) + .unwrap(); + let raw = unistd::dup(stream.as_raw_fd()).unwrap(); + let ready = HelperTerminalReady { + request_id: 1, + operation_id: OperationId::parse("op-terminal-flags").unwrap(), + terminal_protocol_version: UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION, + transport: + d2b_contracts::unsafe_local_wire::HelperTerminalTransport::ConnectedUnixStream, + scope: d2b_contracts::unsafe_local_wire::ScopeIdentity { + invocation_id: "00112233445566778899aabbccddeeff".to_owned(), + kind: d2b_contracts::unsafe_local_wire::HelperScopeKind::PersistentShell, + }, + result: d2b_contracts::unsafe_local_wire::HelperShellAttachResult { + resolved_name: d2b_contracts::public_wire::ShellName::new("default").unwrap(), + state: d2b_contracts::public_wire::ShellSessionState::Attached, + force_evicted: false, + }, + }; + + assert!(matches!( + validate_terminal_fd(&ready, vec![ReceivedFd(raw)]), + Err(HelperRegistryError::InvalidTerminalFd) + )); + assert_eq!(fcntl(raw, FcntlArg::F_GETFD), Err(nix::errno::Errno::EBADF)); + + let first = fcntl(stream.as_raw_fd(), FcntlArg::F_DUPFD(512)).unwrap(); + let second = fcntl(stream.as_raw_fd(), FcntlArg::F_DUPFD(512)).unwrap(); + assert!(matches!( + validate_terminal_fd(&ready, vec![ReceivedFd(first), ReceivedFd(second)]), + Err(HelperRegistryError::InvalidTerminalFd) + )); + assert_eq!( + fcntl(first, FcntlArg::F_GETFD), + Err(nix::errno::Errno::EBADF) + ); + assert_eq!( + fcntl(second, FcntlArg::F_GETFD), + Err(nix::errno::Errno::EBADF) + ); + } + fn register_helper(registry: Arc, generation: u64) -> Socket { let uid = unistd::getuid().as_raw(); let (server, client) = seqpacket_pair(); @@ -1407,7 +2301,7 @@ mod tests { let (accepted, fds) = receive_frame::(&client, &mut receive_buffer) .expect("hello accepted"); - close_raw_fds(fds); + drop_received_fds(fds); assert!(matches!( accepted, DaemonToUnsafeLocalHelper::HelloAccepted(HelperHelloAccepted { diff --git a/packages/d2bd/src/unsafe_local_terminal.rs b/packages/d2bd/src/unsafe_local_terminal.rs new file mode 100644 index 000000000..f2fddac05 --- /dev/null +++ b/packages/d2bd/src/unsafe_local_terminal.rs @@ -0,0 +1,738 @@ +use crate::terminal_session::{ + OutputStreamSel, ReadOutputOutcome, TerminalBackend, TerminalKind, WaitOutcome, + WriteStdinOutcome, +}; +use async_trait::async_trait; +use d2b_contracts::{ + terminal_wire::{TerminalStatus, TerminalStream}, + unsafe_local_wire::{ + HelperFailureCode, HelperTerminalAttachmentClosed, HelperTerminalChunkBase64, + HelperTerminalControl, HelperTerminalReadOutput, HelperTerminalRequest, + HelperTerminalResize, HelperTerminalResponse, HelperTerminalWait, HelperTerminalWriteStdin, + MAX_UNSAFE_LOCAL_TERMINAL_CHUNK_BYTES, MAX_UNSAFE_LOCAL_TERMINAL_FRAME_SIZE, + decode_unsafe_local_terminal_frame, encode_unsafe_local_terminal_frame, + }, +}; +use parking_lot::Mutex; +use std::{ + collections::{HashMap, VecDeque}, + io::{Read, Write}, + os::fd::OwnedFd, + os::unix::net::UnixStream, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, + }, + time::Duration, +}; + +const MAX_PENDING_TERMINAL_REQUESTS: usize = 32; +const MAX_ABANDONED_TERMINAL_REQUESTS: usize = MAX_PENDING_TERMINAL_REQUESTS * 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnsafeLocalTerminalError { + Bounds, + Capacity, + Timeout, + Closed, + Protocol, + ResponseMismatch, + OutputGap, + OffsetMismatch, + InvalidSize, + Unsupported, + Rejected(HelperFailureCode), +} + +type PendingSender = mpsc::SyncSender>; + +struct ClientState { + pending: Mutex>, + abandoned: Mutex>, + abandoned_evicted_through: AtomicU64, + closed: AtomicBool, +} + +impl ClientState { + fn close(&self, error: UnsafeLocalTerminalError) { + if self.closed.swap(true, Ordering::AcqRel) { + return; + } + let pending = std::mem::take(&mut *self.pending.lock()); + for (_, sender) in pending { + let _ = sender.try_send(Err(error)); + } + } + + fn abandon(&self, request_id: u64) { + let mut abandoned = self.abandoned.lock(); + if abandoned.len() >= MAX_ABANDONED_TERMINAL_REQUESTS + && let Some(evicted) = abandoned.pop_front() + { + self.abandoned_evicted_through + .fetch_max(evicted, Ordering::AcqRel); + } + abandoned.push_back(request_id); + } + + fn consume_abandoned(&self, request_id: u64) -> bool { + let mut abandoned = self.abandoned.lock(); + let Some(index) = abandoned.iter().position(|value| *value == request_id) else { + return false; + }; + abandoned.remove(index); + true + } + + fn was_evicted_abandoned(&self, request_id: u64) -> bool { + request_id <= self.abandoned_evicted_through.load(Ordering::Acquire) + } +} + +pub struct UnsafeLocalTerminalClient { + writer: Mutex, + state: Arc, + next_request_id: AtomicU64, +} + +impl std::fmt::Debug for UnsafeLocalTerminalClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UnsafeLocalTerminalClient") + .field("closed", &self.state.closed.load(Ordering::Acquire)) + .field("pending_count", &self.state.pending.lock().len()) + .finish() + } +} + +impl UnsafeLocalTerminalClient { + pub fn new(fd: OwnedFd) -> Result { + let stream = UnixStream::from(fd); + stream + .set_read_timeout(None) + .and_then(|()| stream.set_write_timeout(None)) + .map_err(|_| UnsafeLocalTerminalError::Closed)?; + let reader = stream + .try_clone() + .map_err(|_| UnsafeLocalTerminalError::Closed)?; + let state = Arc::new(ClientState { + pending: Mutex::new(HashMap::new()), + abandoned: Mutex::new(VecDeque::new()), + abandoned_evicted_through: AtomicU64::new(0), + closed: AtomicBool::new(false), + }); + let reader_state = Arc::clone(&state); + std::thread::Builder::new() + .name("d2b-unsafe-local-terminal-reader".to_owned()) + .spawn(move || terminal_reader(reader, reader_state)) + .map_err(|_| UnsafeLocalTerminalError::Closed)?; + Ok(Self { + writer: Mutex::new(stream), + state, + next_request_id: AtomicU64::new(1), + }) + } + + fn next_request_id(&self) -> u64 { + loop { + let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); + if request_id != 0 { + return request_id; + } + } + } + + fn round_trip( + &self, + build: impl FnOnce(u64) -> Result, + timeout: Duration, + ) -> Result { + if self.state.closed.load(Ordering::Acquire) { + return Err(UnsafeLocalTerminalError::Closed); + } + let request_id = self.next_request_id(); + let request = build(request_id)?; + request.validate_bounds().map_err(map_helper_failure_code)?; + let frame = encode_unsafe_local_terminal_frame(&request) + .map_err(|_| UnsafeLocalTerminalError::Bounds)?; + let (sender, receiver) = mpsc::sync_channel(1); + { + let mut pending = self.state.pending.lock(); + if self.state.closed.load(Ordering::Acquire) { + return Err(UnsafeLocalTerminalError::Closed); + } + if pending.len() >= MAX_PENDING_TERMINAL_REQUESTS { + return Err(UnsafeLocalTerminalError::Capacity); + } + if pending.insert(request_id, sender).is_some() { + return Err(UnsafeLocalTerminalError::Protocol); + } + } + + if self.writer.lock().write_all(&frame).is_err() { + self.state.pending.lock().remove(&request_id); + self.state.close(UnsafeLocalTerminalError::Closed); + let _ = self.writer.lock().shutdown(std::net::Shutdown::Both); + return Err(UnsafeLocalTerminalError::Closed); + } + match receiver.recv_timeout(timeout) { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => { + if self.state.pending.lock().remove(&request_id).is_some() { + self.state.abandon(request_id); + Err(UnsafeLocalTerminalError::Timeout) + } else { + receiver + .recv() + .unwrap_or(Err(UnsafeLocalTerminalError::Closed)) + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => Err(UnsafeLocalTerminalError::Closed), + } + } + + pub fn close_attachment( + &self, + control_sequence: u64, + timeout: Duration, + ) -> Result { + let response = self.round_trip( + |request_id| { + Ok(HelperTerminalRequest::CloseAttachment( + HelperTerminalControl { + request_id, + control_sequence, + }, + )) + }, + timeout, + )?; + match response { + HelperTerminalResponse::CloseAttachment(response) + if response.control_sequence == control_sequence => + { + Ok(response.result) + } + HelperTerminalResponse::Rejected(rejected) => { + Err(map_helper_failure_code(rejected.code)) + } + _ => Err(UnsafeLocalTerminalError::ResponseMismatch), + } + } +} + +impl Drop for UnsafeLocalTerminalClient { + fn drop(&mut self) { + self.state.close(UnsafeLocalTerminalError::Closed); + let _ = self.writer.get_mut().shutdown(std::net::Shutdown::Both); + } +} + +fn terminal_reader(mut stream: UnixStream, state: Arc) { + loop { + let response = match read_response(&mut stream) { + Ok(response) => response, + Err(error) => { + state.close(error); + let _ = stream.shutdown(std::net::Shutdown::Both); + return; + } + }; + let request_id = response.request_id(); + if let Some(sender) = state.pending.lock().remove(&request_id) { + let _ = sender.try_send(Ok(response)); + } else if !state.consume_abandoned(request_id) && !state.was_evicted_abandoned(request_id) { + state.close(UnsafeLocalTerminalError::Protocol); + let _ = stream.shutdown(std::net::Shutdown::Both); + return; + } + } +} + +fn read_response( + stream: &mut UnixStream, +) -> Result { + let mut prefix = [0u8; 4]; + stream + .read_exact(&mut prefix) + .map_err(|_| UnsafeLocalTerminalError::Closed)?; + let length = u32::from_le_bytes(prefix) as usize; + if length == 0 || length > MAX_UNSAFE_LOCAL_TERMINAL_FRAME_SIZE { + return Err(UnsafeLocalTerminalError::Bounds); + } + let mut frame = Vec::with_capacity(length + 4); + frame.extend_from_slice(&prefix); + frame.resize(length + 4, 0); + stream + .read_exact(&mut frame[4..]) + .map_err(|_| UnsafeLocalTerminalError::Protocol)?; + decode_unsafe_local_terminal_frame(&frame).map_err(|_| UnsafeLocalTerminalError::Protocol) +} + +fn map_helper_failure_code(code: HelperFailureCode) -> UnsafeLocalTerminalError { + match code { + HelperFailureCode::QueueFull => UnsafeLocalTerminalError::Capacity, + HelperFailureCode::Timeout => UnsafeLocalTerminalError::Timeout, + HelperFailureCode::TerminalOutputGap => UnsafeLocalTerminalError::OutputGap, + HelperFailureCode::TerminalOffsetMismatch => UnsafeLocalTerminalError::OffsetMismatch, + HelperFailureCode::TerminalClosed => UnsafeLocalTerminalError::Closed, + HelperFailureCode::InvalidTerminalSize => UnsafeLocalTerminalError::InvalidSize, + HelperFailureCode::InvalidRequest => UnsafeLocalTerminalError::Bounds, + other => UnsafeLocalTerminalError::Rejected(other), + } +} + +fn response_or_rejection( + response: HelperTerminalResponse, +) -> Result { + if let HelperTerminalResponse::Rejected(rejected) = response { + Err(map_helper_failure_code(rejected.code)) + } else { + Ok(response) + } +} + +#[async_trait] +impl TerminalBackend for UnsafeLocalTerminalClient { + type Error = UnsafeLocalTerminalError; + + async fn write_stdin( + &self, + offset: u64, + data: Vec, + eof: bool, + timeout: Duration, + ) -> Result { + if data.len() as u64 > MAX_UNSAFE_LOCAL_TERMINAL_CHUNK_BYTES { + return Err(UnsafeLocalTerminalError::Bounds); + } + let encoded = HelperTerminalChunkBase64::new(d2b_core::base64_codec::encode(&data)) + .map_err(map_helper_failure_code)?; + let response = response_or_rejection(self.round_trip( + |request_id| { + Ok(HelperTerminalRequest::WriteStdin( + HelperTerminalWriteStdin { + request_id, + offset, + chunk_base64: encoded, + eof, + }, + )) + }, + timeout, + )?)?; + let HelperTerminalResponse::WriteStdin(response) = response else { + return Err(UnsafeLocalTerminalError::ResponseMismatch); + }; + if response.result.accepted_len > data.len() as u64 + || offset.checked_add(response.result.accepted_len) != Some(response.result.next_offset) + { + return Err(UnsafeLocalTerminalError::OffsetMismatch); + } + Ok(WriteStdinOutcome { + accepted_len: response.result.accepted_len, + next_offset: response.result.next_offset, + backpressured: response.result.backpressured, + stdin_closed: response.result.stdin_closed, + }) + } + + async fn read_output( + &self, + stream: OutputStreamSel, + offset: u64, + max_len: u64, + wait: bool, + timeout_ms: u64, + timeout: Duration, + ) -> Result { + if max_len == 0 || max_len > MAX_UNSAFE_LOCAL_TERMINAL_CHUNK_BYTES { + return Err(UnsafeLocalTerminalError::Bounds); + } + let response = response_or_rejection(self.round_trip( + |request_id| { + Ok(HelperTerminalRequest::ReadOutput( + HelperTerminalReadOutput { + request_id, + stream: match stream { + OutputStreamSel::Stdout => TerminalStream::Stdout, + OutputStreamSel::Stderr => TerminalStream::Stderr, + }, + cursor: offset, + max_len, + wait, + timeout_ms, + }, + )) + }, + timeout, + )?)?; + let HelperTerminalResponse::ReadOutput(response) = response else { + return Err(UnsafeLocalTerminalError::ResponseMismatch); + }; + let data = d2b_core::base64_codec::decode(response.result.data_base64.as_str()) + .map_err(|_| UnsafeLocalTerminalError::Protocol)?; + let expected_next = offset + .checked_add(response.result.dropped_bytes) + .and_then(|cursor| cursor.checked_add(data.len() as u64)) + .ok_or(UnsafeLocalTerminalError::Protocol)?; + if data.len() as u64 > max_len || response.result.next_cursor != expected_next { + return Err(if response.result.dropped_bytes > 0 { + UnsafeLocalTerminalError::OutputGap + } else { + UnsafeLocalTerminalError::OffsetMismatch + }); + } + Ok(ReadOutputOutcome { + data, + next_offset: response.result.next_cursor, + eof: response.result.eof, + dropped_bytes: response.result.dropped_bytes, + truncated: response.result.truncated, + timed_out: response.result.timed_out, + }) + } + + async fn signal( + &self, + _control_seq: u64, + _signo: u32, + _timeout: Duration, + ) -> Result<(), Self::Error> { + Err(UnsafeLocalTerminalError::Unsupported) + } + + async fn resize( + &self, + control_seq: u64, + rows: u32, + cols: u32, + timeout: Duration, + ) -> Result<(), Self::Error> { + let response = response_or_rejection(self.round_trip( + |request_id| { + Ok(HelperTerminalRequest::Resize(HelperTerminalResize { + request_id, + control_sequence: control_seq, + rows, + cols, + })) + }, + timeout, + )?)?; + match response { + HelperTerminalResponse::Resize(response) + if response.control_sequence == control_seq && response.result.delivered => + { + Ok(()) + } + _ => Err(UnsafeLocalTerminalError::ResponseMismatch), + } + } + + async fn wait(&self, timeout_ms: u64, timeout: Duration) -> Result { + let response = response_or_rejection(self.round_trip( + |request_id| { + Ok(HelperTerminalRequest::Wait(HelperTerminalWait { + request_id, + timeout_ms, + })) + }, + timeout, + )?)?; + let HelperTerminalResponse::Wait(response) = response else { + return Err(UnsafeLocalTerminalError::ResponseMismatch); + }; + Ok(WaitOutcome { + running: response.result.running, + terminal: response.result.terminal_status.map(|status| match status { + TerminalStatus::Exited { code } => TerminalKind::Exited(code), + TerminalStatus::Signaled { signal } => TerminalKind::Signaled(signal), + TerminalStatus::Error { .. } => TerminalKind::Error("terminal-error"), + }), + }) + } + + async fn close_stdin(&self, control_seq: u64, timeout: Duration) -> Result<(), Self::Error> { + let response = response_or_rejection(self.round_trip( + |request_id| { + Ok(HelperTerminalRequest::CloseStdin(HelperTerminalControl { + request_id, + control_sequence: control_seq, + })) + }, + timeout, + )?)?; + match response { + HelperTerminalResponse::CloseStdin(response) + if response.control_sequence == control_seq && response.result.stdin_closed => + { + Ok(()) + } + _ => Err(UnsafeLocalTerminalError::ResponseMismatch), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use d2b_contracts::{ + terminal_wire::{TerminalControlResult, TerminalWriteStdinResult}, + unsafe_local_wire::{ + HelperTerminalControlResponse, HelperTerminalOperationResult, + HelperTerminalReadOutputResult, + }, + }; + + fn client_pair() -> (Arc, UnixStream) { + let (client, peer) = UnixStream::pair().unwrap(); + ( + Arc::new(UnsafeLocalTerminalClient::new(client.into()).unwrap()), + peer, + ) + } + + fn read_request(peer: &mut UnixStream) -> HelperTerminalRequest { + let mut prefix = [0u8; 4]; + peer.read_exact(&mut prefix).unwrap(); + let length = u32::from_le_bytes(prefix) as usize; + let mut frame = Vec::from(prefix); + frame.resize(length + 4, 0); + peer.read_exact(&mut frame[4..]).unwrap(); + decode_unsafe_local_terminal_frame(&frame).unwrap() + } + + fn write_response(peer: &mut UnixStream, response: &HelperTerminalResponse) { + peer.write_all(&encode_unsafe_local_terminal_frame(response).unwrap()) + .unwrap(); + } + + #[test] + fn debug_is_redacted_and_reader_close_wakes_pending() { + let (client, peer) = client_pair(); + assert!(!format!("{client:?}").contains("fd")); + drop(peer); + let error = futures_lite_block_on(client.wait(0, Duration::from_secs(1))).unwrap_err(); + assert_eq!(error, UnsafeLocalTerminalError::Closed); + } + + #[test] + fn multiplexes_out_of_order_write_and_resize_replies() { + let (client, mut peer) = client_pair(); + let write_client = Arc::clone(&client); + let write = std::thread::spawn(move || { + futures_lite_block_on(write_client.write_stdin( + 0, + b"abc".to_vec(), + false, + Duration::from_secs(2), + )) + }); + let resize_client = Arc::clone(&client); + let resize = std::thread::spawn(move || { + futures_lite_block_on(resize_client.resize(7, 40, 120, Duration::from_secs(2))) + }); + let first = read_request(&mut peer); + let second = read_request(&mut peer); + let (write_id, resize_id) = match (&first, &second) { + (HelperTerminalRequest::WriteStdin(write), HelperTerminalRequest::Resize(resize)) => { + (write.request_id, resize.request_id) + } + (HelperTerminalRequest::Resize(resize), HelperTerminalRequest::WriteStdin(write)) => { + (write.request_id, resize.request_id) + } + _ => panic!("unexpected requests"), + }; + write_response( + &mut peer, + &HelperTerminalResponse::Resize(HelperTerminalControlResponse { + request_id: resize_id, + control_sequence: 7, + result: TerminalControlResult { delivered: true }, + }), + ); + write_response( + &mut peer, + &HelperTerminalResponse::WriteStdin(HelperTerminalOperationResult { + request_id: write_id, + result: TerminalWriteStdinResult { + accepted_len: 3, + next_offset: 3, + backpressured: false, + stdin_closed: false, + }, + }), + ); + assert!(resize.join().unwrap().is_ok()); + assert_eq!(write.join().unwrap().unwrap().next_offset, 3); + } + + #[test] + fn output_cursor_and_gap_are_validated() { + let (client, mut peer) = client_pair(); + let reader_client = Arc::clone(&client); + let read = std::thread::spawn(move || { + futures_lite_block_on(reader_client.read_output( + OutputStreamSel::Stdout, + 4, + 32, + false, + 0, + Duration::from_secs(2), + )) + }); + let request = read_request(&mut peer); + let request_id = request.request_id(); + write_response( + &mut peer, + &HelperTerminalResponse::ReadOutput(HelperTerminalOperationResult { + request_id, + result: HelperTerminalReadOutputResult { + data_base64: HelperTerminalChunkBase64::new(d2b_core::base64_codec::encode( + b"ok", + )) + .unwrap(), + next_cursor: 9, + eof: false, + dropped_bytes: 3, + truncated: true, + timed_out: false, + }, + }), + ); + let outcome = read.join().unwrap().unwrap(); + assert_eq!(outcome.data, b"ok"); + assert_eq!(outcome.dropped_bytes, 3); + assert_eq!(outcome.next_offset, 9); + } + + #[test] + fn concurrent_long_read_does_not_block_stdin_write() { + let (client, mut peer) = client_pair(); + let read_client = Arc::clone(&client); + let read = std::thread::spawn(move || { + futures_lite_block_on(read_client.read_output( + OutputStreamSel::Stdout, + 0, + 32, + true, + 1_000, + Duration::from_secs(2), + )) + }); + let write_client = Arc::clone(&client); + let write = std::thread::spawn(move || { + futures_lite_block_on(write_client.write_stdin( + 0, + b"x".to_vec(), + false, + Duration::from_secs(2), + )) + }); + let first = read_request(&mut peer); + let second = read_request(&mut peer); + let mut read_id = None; + let mut write_id = None; + for request in [first, second] { + match request { + HelperTerminalRequest::ReadOutput(value) => read_id = Some(value.request_id), + HelperTerminalRequest::WriteStdin(value) => write_id = Some(value.request_id), + _ => panic!("unexpected terminal request"), + } + } + write_response( + &mut peer, + &HelperTerminalResponse::WriteStdin(HelperTerminalOperationResult { + request_id: write_id.unwrap(), + result: TerminalWriteStdinResult { + accepted_len: 1, + next_offset: 1, + backpressured: false, + stdin_closed: false, + }, + }), + ); + assert_eq!(write.join().unwrap().unwrap().next_offset, 1); + write_response( + &mut peer, + &HelperTerminalResponse::ReadOutput(HelperTerminalOperationResult { + request_id: read_id.unwrap(), + result: HelperTerminalReadOutputResult { + data_base64: HelperTerminalChunkBase64::new("").unwrap(), + next_cursor: 0, + eof: false, + dropped_bytes: 0, + truncated: false, + timed_out: true, + }, + }), + ); + assert!(read.join().unwrap().unwrap().timed_out); + } + + #[test] + fn malformed_reader_frame_wakes_pending_and_bounds_fail_before_write() { + let (client, mut peer) = client_pair(); + assert_eq!( + futures_lite_block_on(client.resize(1, 0, 80, Duration::from_millis(10))), + Err(UnsafeLocalTerminalError::InvalidSize) + ); + + let wait_client = Arc::clone(&client); + let wait = std::thread::spawn(move || { + futures_lite_block_on(wait_client.wait(100, Duration::from_secs(2))) + }); + let _ = read_request(&mut peer); + peer.write_all(&[1, 0, 0, 0, b'{']).unwrap(); + assert_eq!( + wait.join().unwrap(), + Err(UnsafeLocalTerminalError::Protocol) + ); + } + + #[test] + fn response_for_evicted_abandoned_id_does_not_close_session() { + let (client, mut peer) = client_pair(); + for request_id in 1..=(MAX_ABANDONED_TERMINAL_REQUESTS as u64 + 1) { + client.state.abandon(request_id); + } + write_response( + &mut peer, + &HelperTerminalResponse::Wait(HelperTerminalOperationResult { + request_id: 1, + result: d2b_contracts::terminal_wire::TerminalWaitResult { + running: true, + terminal_status: None, + }, + }), + ); + std::thread::sleep(Duration::from_millis(20)); + assert!(!client.state.closed.load(Ordering::Acquire)); + + let next_client = Arc::clone(&client); + let next = std::thread::spawn(move || { + futures_lite_block_on(next_client.wait(0, Duration::from_secs(2))) + }); + let request = read_request(&mut peer); + write_response( + &mut peer, + &HelperTerminalResponse::Wait(HelperTerminalOperationResult { + request_id: request.request_id(), + result: d2b_contracts::terminal_wire::TerminalWaitResult { + running: true, + terminal_status: None, + }, + }), + ); + assert!(next.join().unwrap().unwrap().running); + } + + fn futures_lite_block_on(future: F) -> F::Output { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(future) + } +} diff --git a/packages/d2bd/src/wire.rs b/packages/d2bd/src/wire.rs index c59e229d5..fd5dea334 100644 --- a/packages/d2bd/src/wire.rs +++ b/packages/d2bd/src/wire.rs @@ -58,6 +58,7 @@ pub enum Request { Shell(public_wire::ShellOp), Console(public_wire::ConsoleOp), GatewayDisplay(public_wire::GatewayDisplayOp), + Workload(public_wire::WorkloadOp), Audio(public_wire::AudioOp), } @@ -96,6 +97,7 @@ impl Request { Self::Shell(_) => "shell", Self::Console(_) => "console", Self::GatewayDisplay(_) => "gatewayDisplay", + Self::Workload(_) => "workload", Self::Audio(_) => "audio", } } @@ -147,6 +149,7 @@ impl Request { | Self::Shell(_) | Self::Console(_) | Self::GatewayDisplay(_) + | Self::Workload(_) | Self::Audio(public_wire::AudioOp::Status(_)) => OpLockClass::ReadOnly, } } @@ -367,6 +370,9 @@ pub fn parse_request(bytes: &[u8]) -> Result { "gatewayDisplay" => serde_json::from_value(Value::Object(object.clone())) .map(Request::GatewayDisplay) .map_err(map_parse_error), + "workload" => serde_json::from_value(Value::Object(object.clone())) + .map(Request::Workload) + .map_err(map_parse_error), "audio" => serde_json::from_value(Value::Object(object.clone())) .map(Request::Audio) .map_err(map_parse_error), @@ -644,6 +650,18 @@ pub fn shell_response(payload: &public_wire::ShellOpResponse) -> Value { value } +/// Serialize a `WorkloadOpResponse` as a `workloadResponse` daemon wire frame. +pub fn workload_response(payload: &public_wire::WorkloadOpResponse) -> Value { + let mut value = serde_json::to_value(payload).unwrap_or_else(|_| json!({})); + if let Some(obj) = value.as_object_mut() { + obj.insert( + "type".to_owned(), + Value::String("workloadResponse".to_owned()), + ); + } + value +} + /// `shellResponse` frame tagged with the correlating envelope `opId`. pub fn shell_response_with_id(op_id: u64, payload: &public_wire::ShellOpResponse) -> Value { let mut value = shell_response(payload); @@ -747,4 +765,21 @@ mod tests { assert_eq!(value["opId"], 42); assert_eq!(value["op"], "list"); } + + #[test] + fn workload_launcher_exec_rejects_public_argv() { + let frame = br#"{"type":"workload","op":"launcherExec","args":{"target":"browser.host.d2b","itemId":"browser","operationId":"launch-1","argv":["canary"]}}"#; + let error = parse_request(frame).expect_err("public argv must reject"); + assert_eq!(error.kind(), "wire-unknown-field"); + assert!(!error.message().contains("canary")); + } + + #[test] + fn workload_launcher_exec_accepts_only_reference_fields() { + let frame = br#"{"type":"workload","op":"launcherExec","args":{"target":"browser.host.d2b","itemId":"browser","operationId":"launch-1"}}"#; + assert!(matches!( + parse_request(frame).expect("workload request"), + Request::Workload(d2b_contracts::public_wire::WorkloadOp::LauncherExec(_)) + )); + } } diff --git a/packages/d2bd/src/workload_dispatch.rs b/packages/d2bd/src/workload_dispatch.rs new file mode 100644 index 000000000..6835bb5d0 --- /dev/null +++ b/packages/d2bd/src/workload_dispatch.rs @@ -0,0 +1,1086 @@ +use std::{ + collections::BTreeMap, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use d2b_contracts::{ + public_wire::{GraphicalLaunchPosture, ShellName, WorkloadAvailability, WorkloadPublicSummary}, + unsafe_local_wire::{HelperShellPolicy, RealmAccentColor}, +}; +use d2b_core::{ + bundle_resolver::BundleResolver, + configured_argv::ConfiguredArgv, + realm_controller_config::RealmControllerPlacement, + realm_workloads_launcher::LauncherWorkloadSummary, + unsafe_local_workloads::{ + UnsafeLocalLauncherItem, UnsafeLocalShellPolicy, UnsafeLocalWorkloadsJson, + }, + workload_identity::{WorkloadIdentity, WorkloadTarget}, +}; +use d2b_realm_core::{LauncherItemKind, ProtocolToken, WorkloadProviderKind, WorkloadState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WorkloadRoute { + LocalVm { vm: String }, + UnsafeLocal, + CapabilityUnavailable { provider: WorkloadProviderKind }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum CatalogError { + ArtifactsUnavailable, + TargetNotFound, + LauncherDisabled, + ItemNotFound, + ConfiguredItemMissing, + ConfiguredItemMismatch, + ShellCapabilityUnavailable, + AliasConflict, + OperationConflict, + OperationInProgress, +} + +#[derive(Debug, Clone)] +pub(crate) struct CatalogEntry { + pub metadata: LauncherWorkloadSummary, + pub route: WorkloadRoute, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LaunchLedgerBegin { + New, + AlreadyCommitted, +} + +#[derive(Debug, Clone)] +struct LaunchLedgerEntry { + fingerprint: String, + committed: bool, + updated_at: Instant, +} + +const MAX_LAUNCH_OPERATIONS_PER_UID: usize = 64; +const ACTIVE_LAUNCH_RETENTION: Duration = Duration::from_secs(45); +const COMMITTED_LAUNCH_RETENTION: Duration = Duration::from_secs(300); + +fn launch_ledger() -> &'static Mutex> { + static LEDGER: OnceLock>> = OnceLock::new(); + LEDGER.get_or_init(|| Mutex::new(BTreeMap::new())) +} + +pub(crate) fn begin_launch( + requester_uid: u32, + operation_id: &str, + target: &WorkloadTarget, + item_id: &ProtocolToken, +) -> Result { + let key = (requester_uid, operation_id.to_owned()); + let fingerprint = format!("{}:{}", target.to_canonical(), item_id.as_str()); + let mut ledger = launch_ledger().lock().expect("workload launch ledger"); + let now = Instant::now(); + ledger.retain(|_, entry| { + entry.updated_at.elapsed() + < if entry.committed { + COMMITTED_LAUNCH_RETENTION + } else { + ACTIVE_LAUNCH_RETENTION + } + }); + if let Some(entry) = ledger.get(&key) { + if entry.fingerprint != fingerprint { + return Err(CatalogError::OperationConflict); + } + return if entry.committed { + Ok(LaunchLedgerBegin::AlreadyCommitted) + } else { + Err(CatalogError::OperationInProgress) + }; + } + if ledger + .keys() + .filter(|(uid, _)| *uid == requester_uid) + .count() + >= MAX_LAUNCH_OPERATIONS_PER_UID + { + let oldest = ledger + .iter() + .filter(|((uid, _), entry)| *uid == requester_uid && entry.committed) + .min_by_key(|(_, entry)| entry.updated_at) + .map(|(key, _)| key.clone()) + .ok_or(CatalogError::OperationInProgress)?; + ledger.remove(&oldest); + } + ledger.insert( + key, + LaunchLedgerEntry { + fingerprint, + committed: false, + updated_at: now, + }, + ); + Ok(LaunchLedgerBegin::New) +} + +pub(crate) fn complete_launch(requester_uid: u32, operation_id: &str) { + if let Some(entry) = launch_ledger() + .lock() + .expect("workload launch ledger") + .get_mut(&(requester_uid, operation_id.to_owned())) + { + entry.committed = true; + entry.updated_at = Instant::now(); + } +} + +pub(crate) fn abort_launch(requester_uid: u32, operation_id: &str) { + launch_ledger() + .lock() + .expect("workload launch ledger") + .remove(&(requester_uid, operation_id.to_owned())); +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedExec { + pub identity: WorkloadIdentity, + pub route: WorkloadRoute, + pub item_id: ProtocolToken, + pub argv: ConfiguredArgv, + pub graphical: bool, + pub realm_accent_color: RealmAccentColor, +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedShell { + pub identity: Option, + pub route: WorkloadRoute, + pub policy: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct WorkloadCatalog { + entries: BTreeMap, + visible: std::collections::BTreeSet, + known_local_vms: std::collections::BTreeSet, +} + +impl WorkloadCatalog { + pub(crate) fn from_resolver(resolver: &BundleResolver) -> Result { + let public = resolver + .realm_workloads_launcher_v2 + .as_ref() + .ok_or(CatalogError::ArtifactsUnavailable)?; + let mut entries = BTreeMap::new(); + let mut visible = std::collections::BTreeSet::new(); + for metadata in &public.workloads { + let canonical = metadata.identity.canonical_target.to_canonical(); + let direct_local = realm_is_direct_local(resolver, &metadata.identity); + let route = if direct_local { + route_for_provider( + metadata.provider_kind, + metadata + .identity + .legacy_vm_name + .as_ref() + .map(|vm| vm.as_str()), + metadata.identity.workload_id.as_str(), + ) + } else { + WorkloadRoute::CapabilityUnavailable { + provider: metadata.provider_kind, + } + }; + if direct_local { + visible.insert(canonical.clone()); + } + entries.insert( + canonical, + CatalogEntry { + metadata: metadata.clone(), + route, + }, + ); + } + Ok(Self { + entries, + visible, + known_local_vms: resolver.manifest.vms.keys().cloned().collect(), + }) + } + + pub(crate) fn entries(&self) -> impl Iterator { + self.entries + .iter() + .filter(|(canonical, _)| self.visible.contains(*canonical)) + .map(|(_, entry)| entry) + } + + #[cfg(test)] + pub(crate) fn from_test_entries(entries: impl IntoIterator) -> Self { + let entries = entries + .into_iter() + .map(|entry| { + ( + entry.metadata.identity.canonical_target.to_canonical(), + entry, + ) + }) + .collect::>(); + let visible = entries.keys().cloned().collect(); + let known_local_vms = entries + .values() + .filter_map(|entry| match &entry.route { + WorkloadRoute::LocalVm { vm } => Some(vm.clone()), + _ => None, + }) + .collect(); + Self { + entries, + visible, + known_local_vms, + } + } + + pub(crate) fn resolve(&self, target: &WorkloadTarget) -> Result<&CatalogEntry, CatalogError> { + self.entries + .get(&target.to_canonical()) + .ok_or(CatalogError::TargetNotFound) + } + + pub(crate) fn public_summary( + entry: &CatalogEntry, + state: WorkloadState, + availability: WorkloadAvailability, + ) -> WorkloadPublicSummary { + let graphical_posture = match availability { + WorkloadAvailability::GraphicalSessionInactive => { + GraphicalLaunchPosture::GraphicalSessionInactive + } + WorkloadAvailability::WaylandUnavailable => GraphicalLaunchPosture::WaylandUnavailable, + WorkloadAvailability::ProxyUnavailable => GraphicalLaunchPosture::ProxyUnavailable, + _ if entry.metadata.items.iter().any(|item| item.graphical) => { + match entry.metadata.provider_kind { + WorkloadProviderKind::UnsafeLocal => GraphicalLaunchPosture::Proxied, + _ => GraphicalLaunchPosture::NotApplicable, + } + } + _ => GraphicalLaunchPosture::NotApplicable, + }; + WorkloadPublicSummary { + identity: entry.metadata.identity.clone(), + provider_kind: entry.metadata.provider_kind, + state, + execution_posture: entry.metadata.execution_posture.clone(), + availability, + graphical_posture, + capabilities: entry.metadata.capabilities.clone(), + launcher_items: entry.metadata.items.clone(), + default_item_id: entry.metadata.default_item_id.clone(), + } + } + + pub(crate) fn resolve_exec( + &self, + private: Option<&UnsafeLocalWorkloadsJson>, + target: &WorkloadTarget, + item_id: &ProtocolToken, + ) -> Result { + let entry = self.resolve(target)?; + if !entry.metadata.launcher_enabled { + return Err(CatalogError::LauncherDisabled); + } + let public_item = entry + .metadata + .items + .iter() + .find(|item| &item.id == item_id) + .ok_or(CatalogError::ItemNotFound)?; + if public_item.kind != LauncherItemKind::Exec { + return Err(CatalogError::ConfiguredItemMismatch); + } + let private = private.ok_or(CatalogError::ArtifactsUnavailable)?; + let private_workload = match &entry.route { + WorkloadRoute::UnsafeLocal => private + .workloads + .iter() + .find(|workload| workload.identity.canonical_target == *target) + .map(|workload| (&workload.identity, workload.items.as_slice())), + WorkloadRoute::LocalVm { .. } => private + .local_vm_workloads + .iter() + .find(|workload| workload.identity.canonical_target == *target) + .map(|workload| (&workload.identity, workload.items.as_slice())), + WorkloadRoute::CapabilityUnavailable { .. } => None, + } + .ok_or(CatalogError::ConfiguredItemMissing)?; + if private_workload.0 != &entry.metadata.identity { + return Err(CatalogError::ConfiguredItemMismatch); + } + let items = private_workload.1; + let private_item = items + .iter() + .find(|item| item.id() == item_id) + .ok_or(CatalogError::ConfiguredItemMissing)?; + let UnsafeLocalLauncherItem::Exec(private_exec) = private_item else { + return Err(CatalogError::ConfiguredItemMismatch); + }; + if private_exec.name != public_item.name + || private_exec.icon != public_item.icon + || private_exec.graphical != public_item.graphical + { + return Err(CatalogError::ConfiguredItemMismatch); + } + Ok(ResolvedExec { + identity: entry.metadata.identity.clone(), + route: entry.route.clone(), + item_id: item_id.clone(), + argv: private_exec.argv.clone(), + graphical: private_exec.graphical, + realm_accent_color: RealmAccentColor::new(entry.metadata.realm_accent_color.clone()) + .map_err(|_| CatalogError::ConfiguredItemMismatch)?, + }) + } + + pub(crate) fn resolve_shell( + &self, + private: Option<&UnsafeLocalWorkloadsJson>, + target: &str, + ) -> Result { + let Some(entry) = self.resolve_shell_entry(target)? else { + return Ok(ResolvedShell { + identity: None, + route: WorkloadRoute::LocalVm { + vm: target.to_owned(), + }, + policy: None, + }); + }; + + match &entry.route { + WorkloadRoute::LocalVm { .. } | WorkloadRoute::CapabilityUnavailable { .. } => { + Ok(ResolvedShell { + identity: Some(entry.metadata.identity.clone()), + route: entry.route.clone(), + policy: None, + }) + } + WorkloadRoute::UnsafeLocal => { + let private = private.ok_or(CatalogError::ArtifactsUnavailable)?; + let configured = private + .workloads + .iter() + .find(|workload| { + workload.identity.canonical_target + == entry.metadata.identity.canonical_target + }) + .ok_or(CatalogError::ConfiguredItemMissing)?; + if configured.identity != entry.metadata.identity { + return Err(CatalogError::ConfiguredItemMismatch); + } + validate_shell_item_parity(entry, configured.items.as_slice())?; + let policy = configured + .shell + .as_ref() + .ok_or(CatalogError::ShellCapabilityUnavailable) + .and_then(helper_shell_policy)?; + Ok(ResolvedShell { + identity: Some(entry.metadata.identity.clone()), + route: WorkloadRoute::UnsafeLocal, + policy: Some(policy), + }) + } + } + } + + fn resolve_shell_entry(&self, target: &str) -> Result, CatalogError> { + if target.ends_with(".d2b") { + return self + .entries + .get(target) + .map(Some) + .ok_or(CatalogError::TargetNotFound); + } + + if self.known_local_vms.contains(target) { + return Ok(None); + } + + let legacy = self + .entries + .values() + .filter(|entry| matches!(&entry.route, WorkloadRoute::LocalVm { vm } if vm == target)) + .collect::>(); + if let [entry] = legacy.as_slice() { + return Ok(Some(*entry)); + } + if legacy.len() > 1 { + return Err(CatalogError::AliasConflict); + } + + let aliases = self + .entries + .values() + .filter(|entry| entry.metadata.identity.workload_id.as_str() == target) + .collect::>(); + match aliases.as_slice() { + [] => Ok(None), + [entry] => Ok(Some(*entry)), + _ => Err(CatalogError::AliasConflict), + } + } +} + +fn helper_shell_policy(policy: &UnsafeLocalShellPolicy) -> Result { + let policy = HelperShellPolicy { + default_name: ShellName::new(policy.default_name.clone()) + .map_err(|_| CatalogError::ConfiguredItemMismatch)?, + max_sessions: policy.max_sessions, + }; + policy + .validate_bounds() + .map_err(|_| CatalogError::ConfiguredItemMismatch)?; + Ok(policy) +} + +fn validate_shell_item_parity( + entry: &CatalogEntry, + private_items: &[UnsafeLocalLauncherItem], +) -> Result<(), CatalogError> { + let public_shells = entry + .metadata + .items + .iter() + .filter(|item| item.kind == LauncherItemKind::Shell) + .collect::>(); + let private_shells = private_items + .iter() + .filter_map(|item| match item { + UnsafeLocalLauncherItem::Shell(shell) => Some(shell), + UnsafeLocalLauncherItem::Exec(_) => None, + }) + .collect::>(); + if public_shells.is_empty() || private_shells.is_empty() { + return Err(CatalogError::ShellCapabilityUnavailable); + } + if public_shells.len() != private_shells.len() + || public_shells.iter().any(|public| { + public.graphical + || !private_shells.iter().any(|private| { + private.id == public.id + && private.name == public.name + && private.icon == public.icon + }) + }) + { + return Err(CatalogError::ConfiguredItemMismatch); + } + Ok(()) +} + +fn route_for_provider( + provider: WorkloadProviderKind, + legacy_vm_name: Option<&str>, + workload_id: &str, +) -> WorkloadRoute { + match provider { + WorkloadProviderKind::LocalVm => WorkloadRoute::LocalVm { + vm: legacy_vm_name.unwrap_or(workload_id).to_owned(), + }, + WorkloadProviderKind::UnsafeLocal => WorkloadRoute::UnsafeLocal, + provider => WorkloadRoute::CapabilityUnavailable { provider }, + } +} + +fn realm_is_direct_local(resolver: &BundleResolver, identity: &WorkloadIdentity) -> bool { + resolver + .realm_controllers + .as_ref() + .is_some_and(|controllers| { + controllers.controllers.iter().any(|controller| { + controller_matches_direct_local( + controller.realm_path.as_str(), + controller.placement, + identity, + ) + }) + }) +} + +fn controller_matches_direct_local( + controller_realm_path: &str, + placement: RealmControllerPlacement, + identity: &WorkloadIdentity, +) -> bool { + controller_realm_path == identity.realm_path.target_form() + && placement == RealmControllerPlacement::HostLocal +} + +#[cfg(test)] +mod tests { + use super::*; + use d2b_core::{ + configured_argv::ConfiguredArgv, + contract_id::ContractId, + unsafe_local_workloads::{ + LocalVmConfiguredWorkload, UNSAFE_LOCAL_WORKLOADS_SCHEMA_VERSION, UnsafeLocalExecItem, + UnsafeLocalShellItem, UnsafeLocalShellPolicy, UnsafeLocalWorkload, + UnsafeLocalWorkloadsJson, + }, + }; + use d2b_realm_core::{ + CapabilitySet, DisplayEnvironmentPosture, EnvironmentPosture, ExecutionIdentityPosture, + IsolationPosture, LauncherIcon, LauncherItemSummary, SessionPersistencePosture, + WorkloadExecutionPosture, + ids::{RealmId, WorkloadId}, + realm::RealmPath, + }; + + fn workload_identity(realm: &str) -> WorkloadIdentity { + let realm_id = RealmId::parse(realm).unwrap(); + WorkloadIdentity::new( + WorkloadId::parse("browser").unwrap(), + realm_id.clone(), + RealmPath::new(vec![realm_id]).unwrap(), + WorkloadTarget::parse(&format!("browser.{realm}.d2b")).unwrap(), + ) + } + + fn catalog_entry(provider: WorkloadProviderKind) -> CatalogEntry { + let mut identity = workload_identity("work"); + match provider { + WorkloadProviderKind::LocalVm => { + identity.legacy_vm_name = Some(ContractId::parse("corp-vm").unwrap()); + identity.runtime_kind = Some(ContractId::parse("nixos").unwrap()); + identity.provider_id = Some(ContractId::parse("local-cloud-hypervisor").unwrap()); + } + WorkloadProviderKind::UnsafeLocal => { + identity.runtime_kind = Some(ContractId::parse("unsafe-local").unwrap()); + identity.provider_id = Some(ContractId::parse("unsafe-local").unwrap()); + } + _ => {} + } + let unsafe_local = provider == WorkloadProviderKind::UnsafeLocal; + CatalogEntry { + metadata: LauncherWorkloadSummary { + identity, + provider_kind: provider, + execution_posture: WorkloadExecutionPosture { + isolation: if unsafe_local { + IsolationPosture::UnsafeLocal + } else { + IsolationPosture::VirtualMachine + }, + environment: if unsafe_local { + EnvironmentPosture::SystemdUserManagerAmbient + } else { + EnvironmentPosture::RuntimeManaged + }, + display_environment: if unsafe_local { + DisplayEnvironmentPosture::WaylandProxyOnly + } else { + DisplayEnvironmentPosture::RuntimeManaged + }, + execution_identity: if unsafe_local { + ExecutionIdentityPosture::AuthenticatedRequesterUid + } else { + ExecutionIdentityPosture::WorkloadUser + }, + session_persistence: if unsafe_local { + SessionPersistencePosture::UserManagerLifetime + } else { + SessionPersistencePosture::RuntimeManaged + }, + }, + label: "Browser".to_owned(), + icon: LauncherIcon::default(), + realm_accent_color: "#336699".to_owned(), + launcher_enabled: true, + default_item_id: Some(ProtocolToken::parse("browser").unwrap()), + capabilities: CapabilitySet::default(), + items: vec![LauncherItemSummary { + id: ProtocolToken::parse("browser").unwrap(), + name: "Browser".to_owned(), + icon: LauncherIcon::default(), + kind: LauncherItemKind::Exec, + graphical: true, + capabilities: CapabilitySet::default(), + }], + }, + route: route_for_provider( + provider, + if provider == WorkloadProviderKind::LocalVm { + Some("corp-vm") + } else { + None + }, + "browser", + ), + } + } + + fn private_artifact(entries: &[CatalogEntry]) -> UnsafeLocalWorkloadsJson { + let exec = || { + UnsafeLocalLauncherItem::Exec(UnsafeLocalExecItem { + id: ProtocolToken::parse("browser").unwrap(), + name: "Browser".to_owned(), + icon: LauncherIcon::default(), + argv: ConfiguredArgv::new(vec![ + "browser-bin".to_owned(), + "--configured".to_owned(), + ]) + .unwrap(), + graphical: true, + }) + }; + UnsafeLocalWorkloadsJson { + schema_version: UNSAFE_LOCAL_WORKLOADS_SCHEMA_VERSION.to_owned(), + workloads: entries + .iter() + .filter(|entry| matches!(entry.route, WorkloadRoute::UnsafeLocal)) + .map(|entry| UnsafeLocalWorkload { + identity: entry.metadata.identity.clone(), + default_item_id: Some(ProtocolToken::parse("browser").unwrap()), + items: vec![exec()], + shell: None, + }) + .collect(), + local_vm_workloads: entries + .iter() + .filter(|entry| matches!(entry.route, WorkloadRoute::LocalVm { .. })) + .map(|entry| LocalVmConfiguredWorkload { + identity: entry.metadata.identity.clone(), + default_item_id: Some(ProtocolToken::parse("browser").unwrap()), + items: vec![exec()], + }) + .collect(), + } + } + + fn shell_entry(provider: WorkloadProviderKind, realm: &str) -> CatalogEntry { + let mut entry = catalog_entry(provider); + entry.metadata.identity = workload_identity(realm); + match provider { + WorkloadProviderKind::LocalVm => { + entry.metadata.identity.legacy_vm_name = + Some(ContractId::parse("corp-vm").unwrap()); + entry.metadata.identity.runtime_kind = Some(ContractId::parse("nixos").unwrap()); + entry.metadata.identity.provider_id = + Some(ContractId::parse("local-cloud-hypervisor").unwrap()); + } + WorkloadProviderKind::UnsafeLocal => { + entry.metadata.identity.runtime_kind = + Some(ContractId::parse("unsafe-local").unwrap()); + entry.metadata.identity.provider_id = + Some(ContractId::parse("unsafe-local").unwrap()); + } + _ => {} + } + entry.metadata.items.push(LauncherItemSummary { + id: ProtocolToken::parse("terminal").unwrap(), + name: "Terminal".to_owned(), + icon: LauncherIcon::default(), + kind: LauncherItemKind::Shell, + graphical: false, + capabilities: CapabilitySet::default(), + }); + entry.route = route_for_provider( + provider, + entry + .metadata + .identity + .legacy_vm_name + .as_ref() + .map(|value| value.as_str()), + entry.metadata.identity.workload_id.as_str(), + ); + entry + } + + fn shell_private(entry: &CatalogEntry) -> UnsafeLocalWorkloadsJson { + UnsafeLocalWorkloadsJson { + schema_version: UNSAFE_LOCAL_WORKLOADS_SCHEMA_VERSION.to_owned(), + workloads: vec![UnsafeLocalWorkload { + identity: entry.metadata.identity.clone(), + default_item_id: Some(ProtocolToken::parse("terminal").unwrap()), + items: vec![UnsafeLocalLauncherItem::Shell(UnsafeLocalShellItem { + id: ProtocolToken::parse("terminal").unwrap(), + name: "Terminal".to_owned(), + icon: LauncherIcon::default(), + })], + shell: Some(UnsafeLocalShellPolicy { + default_name: "primary".to_owned(), + max_sessions: 4, + }), + }], + local_vm_workloads: Vec::new(), + } + } + + #[test] + fn launch_ledger_is_idempotent_and_rejects_changed_fingerprint() { + let target = WorkloadTarget::parse("browser.host.d2b").unwrap(); + let item = ProtocolToken::parse("browser").unwrap(); + let other = ProtocolToken::parse("other").unwrap(); + let operation = "launch-ledger-parity-case"; + abort_launch(65001, operation); + assert_eq!( + begin_launch(65001, operation, &target, &item).unwrap(), + LaunchLedgerBegin::New + ); + assert_eq!( + begin_launch(65001, operation, &target, &item), + Err(CatalogError::OperationInProgress) + ); + complete_launch(65001, operation); + assert_eq!( + begin_launch(65001, operation, &target, &item).unwrap(), + LaunchLedgerBegin::AlreadyCommitted + ); + assert_eq!( + begin_launch(65001, operation, &target, &other), + Err(CatalogError::OperationConflict) + ); + abort_launch(65001, operation); + } + + #[test] + fn active_launch_capacity_isolated_per_uid() { + let target = WorkloadTarget::parse("browser.host.d2b").unwrap(); + let item = ProtocolToken::parse("browser").unwrap(); + let saturated_uid = 65002; + let other_uid = 65003; + for index in 0..MAX_LAUNCH_OPERATIONS_PER_UID { + begin_launch(saturated_uid, &format!("capacity-{index}"), &target, &item).unwrap(); + } + assert_eq!( + begin_launch(saturated_uid, "capacity-overflow", &target, &item), + Err(CatalogError::OperationInProgress) + ); + assert_eq!( + begin_launch(other_uid, "other-user", &target, &item), + Ok(LaunchLedgerBegin::New) + ); + for index in 0..MAX_LAUNCH_OPERATIONS_PER_UID { + abort_launch(saturated_uid, &format!("capacity-{index}")); + } + abort_launch(other_uid, "other-user"); + } + + #[test] + fn provider_routes_never_coerce_unsafe_local_to_vm() { + assert_eq!( + route_for_provider(WorkloadProviderKind::UnsafeLocal, Some("host"), "browser"), + WorkloadRoute::UnsafeLocal + ); + assert_eq!( + route_for_provider(WorkloadProviderKind::LocalVm, Some("corp-vm"), "browser"), + WorkloadRoute::LocalVm { + vm: "corp-vm".to_owned() + } + ); + assert_eq!( + route_for_provider(WorkloadProviderKind::LocalVm, None, "browser"), + WorkloadRoute::LocalVm { + vm: "browser".to_owned() + } + ); + } + + #[test] + fn only_matching_host_local_realm_is_direct() { + let identity = workload_identity("work"); + assert!(controller_matches_direct_local( + "work", + RealmControllerPlacement::HostLocal, + &identity + )); + assert!(!controller_matches_direct_local( + "work", + RealmControllerPlacement::GatewayVm, + &identity + )); + assert!(!controller_matches_direct_local( + "home", + RealmControllerPlacement::HostLocal, + &identity + )); + } + + #[test] + fn resolve_exec_returns_only_trusted_local_vm_and_unsafe_local_descriptors() { + for provider in [ + WorkloadProviderKind::LocalVm, + WorkloadProviderKind::UnsafeLocal, + ] { + let entry = catalog_entry(provider); + let target = entry.metadata.identity.canonical_target.clone(); + let catalog = WorkloadCatalog::from_test_entries([entry.clone()]); + let private = private_artifact(&[entry]); + let resolved = catalog + .resolve_exec( + Some(&private), + &target, + &ProtocolToken::parse("browser").unwrap(), + ) + .expect("matching configured descriptor resolves"); + assert_eq!( + resolved.argv.as_slice(), + ["browser-bin", "--configured"], + "argv comes from the private artifact" + ); + assert!(resolved.graphical); + assert_eq!(resolved.realm_accent_color.as_str(), "#336699"); + assert!( + matches!( + (&resolved.route, provider), + (WorkloadRoute::LocalVm { vm }, WorkloadProviderKind::LocalVm) + if vm == "corp-vm" + ) || matches!( + (&resolved.route, provider), + ( + WorkloadRoute::UnsafeLocal, + WorkloadProviderKind::UnsafeLocal + ) + ) + ); + } + } + + #[test] + fn resolve_exec_rejects_missing_mismatch_tamper_and_graphical_drift() { + let entry = catalog_entry(WorkloadProviderKind::UnsafeLocal); + let target = entry.metadata.identity.canonical_target.clone(); + let item = ProtocolToken::parse("browser").unwrap(); + + let mut disabled = entry.clone(); + disabled.metadata.launcher_enabled = false; + assert_eq!( + WorkloadCatalog::from_test_entries([disabled]) + .resolve_exec( + Some(&private_artifact(std::slice::from_ref(&entry))), + &target, + &item, + ) + .unwrap_err(), + CatalogError::LauncherDisabled + ); + assert_eq!( + WorkloadCatalog::from_test_entries([entry.clone()]) + .resolve_exec( + Some(&private_artifact(std::slice::from_ref(&entry))), + &target, + &ProtocolToken::parse("missing").unwrap(), + ) + .unwrap_err(), + CatalogError::ItemNotFound + ); + + let mut missing = private_artifact(std::slice::from_ref(&entry)); + missing.workloads[0].items.clear(); + assert_eq!( + WorkloadCatalog::from_test_entries([entry.clone()]) + .resolve_exec(Some(&missing), &target, &item) + .unwrap_err(), + CatalogError::ConfiguredItemMissing + ); + + let mut kind_mismatch = private_artifact(std::slice::from_ref(&entry)); + kind_mismatch.workloads[0].items[0] = UnsafeLocalLauncherItem::Shell( + d2b_core::unsafe_local_workloads::UnsafeLocalShellItem { + id: item.clone(), + name: "Browser".to_owned(), + icon: LauncherIcon::default(), + }, + ); + assert_eq!( + WorkloadCatalog::from_test_entries([entry.clone()]) + .resolve_exec(Some(&kind_mismatch), &target, &item) + .unwrap_err(), + CatalogError::ConfiguredItemMismatch + ); + + let mut tampered = private_artifact(std::slice::from_ref(&entry)); + tampered.workloads[0].identity.provider_id = + Some(ContractId::parse("tampered-provider").unwrap()); + assert_eq!( + WorkloadCatalog::from_test_entries([entry.clone()]) + .resolve_exec(Some(&tampered), &target, &item) + .unwrap_err(), + CatalogError::ConfiguredItemMismatch + ); + + let mut invalid_color = entry.clone(); + invalid_color.metadata.realm_accent_color = "#ABCDEF".to_owned(); + assert_eq!( + WorkloadCatalog::from_test_entries([invalid_color]) + .resolve_exec( + Some(&private_artifact(std::slice::from_ref(&entry))), + &target, + &item, + ) + .unwrap_err(), + CatalogError::ConfiguredItemMismatch + ); + + let mut graphical_drift = private_artifact(std::slice::from_ref(&entry)); + let UnsafeLocalLauncherItem::Exec(exec) = &mut graphical_drift.workloads[0].items[0] else { + unreachable!() + }; + exec.graphical = false; + assert_eq!( + WorkloadCatalog::from_test_entries([entry]) + .resolve_exec(Some(&graphical_drift), &target, &item) + .unwrap_err(), + CatalogError::ConfiguredItemMismatch + ); + } + + #[test] + fn resolve_shell_routes_bare_and_canonical_local_vm_compatibly() { + let legacy = shell_entry(WorkloadProviderKind::LocalVm, "work"); + let canonical = legacy.metadata.identity.canonical_target.to_canonical(); + let catalog = WorkloadCatalog::from_test_entries([legacy.clone()]); + for target in [canonical.as_str(), "corp-vm", "browser"] { + let resolved = catalog.resolve_shell(None, target).unwrap(); + assert_eq!( + resolved.route, + WorkloadRoute::LocalVm { + vm: "corp-vm".to_owned() + } + ); + assert!(resolved.policy.is_none()); + } + + let mut first_class = shell_entry(WorkloadProviderKind::LocalVm, "host"); + first_class.metadata.identity.legacy_vm_name = None; + first_class.route = route_for_provider( + WorkloadProviderKind::LocalVm, + None, + first_class.metadata.identity.workload_id.as_str(), + ); + let canonical = first_class + .metadata + .identity + .canonical_target + .to_canonical(); + let catalog = WorkloadCatalog::from_test_entries([first_class]); + assert_eq!( + catalog.resolve_shell(None, &canonical).unwrap().route, + WorkloadRoute::LocalVm { + vm: "browser".to_owned() + } + ); + + assert_eq!( + WorkloadCatalog::from_test_entries([]) + .resolve_shell(None, "legacy-vm") + .unwrap() + .route, + WorkloadRoute::LocalVm { + vm: "legacy-vm".to_owned() + } + ); + } + + #[test] + fn resolve_shell_keeps_unsafe_local_distinct_and_uses_private_policy() { + let entry = shell_entry(WorkloadProviderKind::UnsafeLocal, "host"); + let private = shell_private(&entry); + let canonical = entry.metadata.identity.canonical_target.to_canonical(); + let catalog = WorkloadCatalog::from_test_entries([entry]); + for target in [canonical.as_str(), "browser"] { + let resolved = catalog.resolve_shell(Some(&private), target).unwrap(); + assert_eq!(resolved.route, WorkloadRoute::UnsafeLocal); + let policy = resolved.policy.expect("trusted helper policy"); + assert_eq!(policy.default_name.as_str(), "primary"); + assert_eq!(policy.max_sessions, 4); + } + } + + #[test] + fn known_bare_vm_name_precedes_unsafe_local_short_alias() { + let entry = shell_entry(WorkloadProviderKind::UnsafeLocal, "host"); + let private = shell_private(&entry); + let mut catalog = WorkloadCatalog::from_test_entries([entry]); + catalog.known_local_vms.insert("browser".to_owned()); + let resolved = catalog.resolve_shell(Some(&private), "browser").unwrap(); + assert_eq!( + resolved.route, + WorkloadRoute::LocalVm { + vm: "browser".to_owned() + } + ); + assert!(resolved.identity.is_none()); + assert!(resolved.policy.is_none()); + } + + #[test] + fn resolve_shell_rejects_unsupported_remote_and_ambiguous_routes() { + let mut unsupported = shell_entry(WorkloadProviderKind::QemuMedia, "media"); + unsupported.route = WorkloadRoute::CapabilityUnavailable { + provider: WorkloadProviderKind::QemuMedia, + }; + let canonical = unsupported + .metadata + .identity + .canonical_target + .to_canonical(); + let catalog = WorkloadCatalog::from_test_entries([unsupported]); + assert_eq!( + catalog.resolve_shell(None, &canonical).unwrap().route, + WorkloadRoute::CapabilityUnavailable { + provider: WorkloadProviderKind::QemuMedia + } + ); + + let first = shell_entry(WorkloadProviderKind::UnsafeLocal, "work"); + let second = shell_entry(WorkloadProviderKind::UnsafeLocal, "personal"); + let catalog = WorkloadCatalog::from_test_entries([first, second]); + assert_eq!( + catalog.resolve_shell(None, "browser").unwrap_err(), + CatalogError::AliasConflict + ); + assert_eq!( + catalog.resolve_shell(None, "missing.host.d2b").unwrap_err(), + CatalogError::TargetNotFound + ); + } + + #[test] + fn resolve_shell_rejects_private_policy_and_item_drift() { + let entry = shell_entry(WorkloadProviderKind::UnsafeLocal, "host"); + let target = entry.metadata.identity.canonical_target.to_canonical(); + let catalog = WorkloadCatalog::from_test_entries([entry.clone()]); + + let mut missing_policy = shell_private(&entry); + missing_policy.workloads[0].shell = None; + assert_eq!( + catalog + .resolve_shell(Some(&missing_policy), &target) + .unwrap_err(), + CatalogError::ShellCapabilityUnavailable + ); + + let mut item_drift = shell_private(&entry); + let UnsafeLocalLauncherItem::Shell(item) = &mut item_drift.workloads[0].items[0] else { + unreachable!() + }; + item.name = "Tampered".to_owned(); + assert_eq!( + catalog + .resolve_shell(Some(&item_drift), &target) + .unwrap_err(), + CatalogError::ConfiguredItemMismatch + ); + + let mut identity_drift = shell_private(&entry); + identity_drift.workloads[0].identity.provider_id = + Some(ContractId::parse("tampered").unwrap()); + assert_eq!( + catalog + .resolve_shell(Some(&identity_drift), &target) + .unwrap_err(), + CatalogError::ConfiguredItemMismatch + ); + } +} diff --git a/packages/xtask/src/main.rs b/packages/xtask/src/main.rs index 2ea494e39..3de69ca15 100644 --- a/packages/xtask/src/main.rs +++ b/packages/xtask/src/main.rs @@ -14,12 +14,12 @@ use d2b_contracts::unsafe_local_wire::UnsafeLocalHelperWireSchema; use d2b_contracts::{ WireProtocolSchema, cli_output::{ - AuditOutputV2, AuthStatusOutputV2, HostCheckOutputV2, ListOutputV2, OpInspectOutputV1, - RealmInspectOutputV1, RealmListOutputV1, ShellDetachOutputV1, ShellKillOutputV1, - ShellListOutputV1, StatusOutputV2, StoreVerifyOutputV2, UsbProbeOutputV1, - VmAudioSetOutputV1, VmAudioStatusOutputV1, VmDisplayCloseOutputV1, VmDisplayListOutputV1, - VmExecCreateOutputV1, VmExecKillOutputV1, VmExecListOutputV1, VmExecLogsOutputV1, - VmExecStatusOutputV1, + AuditOutputV2, AuthStatusOutputV2, HostCheckOutputV2, LaunchOutputV1, ListOutputV2, + OpInspectOutputV1, RealmInspectOutputV1, RealmListOutputV1, ShellDetachOutputV1, + ShellKillOutputV1, ShellListOutputV1, StatusOutputV2, StoreVerifyOutputV2, + UsbProbeOutputV1, VmAudioSetOutputV1, VmAudioStatusOutputV1, VmDisplayCloseOutputV1, + VmDisplayListOutputV1, VmExecCreateOutputV1, VmExecKillOutputV1, VmExecListOutputV1, + VmExecLogsOutputV1, VmExecStatusOutputV1, }, }; use d2b_core::{ @@ -574,9 +574,10 @@ fn gen_cli_schemas() -> Result, Box> { let out_dir = repo_root.join("docs/reference/cli-output"); fs::create_dir_all(&out_dir)?; - let schemas: [(&str, RootSchema); 22] = [ + let schemas: [(&str, RootSchema); 23] = [ ("list.schema.json", schemars::schema_for!(ListOutputV2)), ("status.schema.json", schemars::schema_for!(StatusOutputV2)), + ("launch.schema.json", schemars::schema_for!(LaunchOutputV1)), ( "usb-probe.schema.json", schemars::schema_for!(UsbProbeOutputV1), @@ -716,8 +717,9 @@ fn gen_cli_shell_artifacts() -> Result, Box> .source(source) .manual("d2b CLI") .render(&mut man_buffer)?; - fs::write(&man_path, man_buffer)?; + write_manpage(&man_path, man_buffer)?; let host_man_path = write_subcommand_manpage(&man_dir, &["host"], "d2b-host")?; + let launch_man_path = write_subcommand_manpage(&man_dir, &["launch"], "d2b-launch")?; let shell_man_path = write_subcommand_manpage(&man_dir, &["shell"], "d2b-shell")?; let clipboard_man_path = write_subcommand_manpage(&man_dir, &["clipboard"], "d2b-clipboard")?; let clipboard_arm_man_path = @@ -746,6 +748,7 @@ fn gen_cli_shell_artifacts() -> Result, Box> Ok(vec![ man_path, host_man_path, + launch_man_path, shell_man_path, clipboard_man_path, clipboard_arm_man_path, @@ -777,10 +780,22 @@ fn write_subcommand_manpage( .source("d2b".to_owned()) .manual("d2b CLI") .render(&mut man_buffer)?; - fs::write(&man_path, man_buffer)?; + write_manpage(&man_path, man_buffer)?; Ok(man_path) } +fn write_manpage(path: &Path, rendered: Vec) -> Result<(), Box> { + let rendered = String::from_utf8(rendered)?; + let mut normalized = rendered + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + normalized.push('\n'); + fs::write(path, normalized)?; + Ok(()) +} + fn patch_vm_exec_logs_bash_completion( generated: String, ) -> Result> { diff --git a/packages/xtask/tests/policy_ci.rs b/packages/xtask/tests/policy_ci.rs index 06f287292..eb45dd804 100644 --- a/packages/xtask/tests/policy_ci.rs +++ b/packages/xtask/tests/policy_ci.rs @@ -9,6 +9,12 @@ const ALLOWLISTED_WORKFLOWS: &[&str] = &[ ".github/workflows/release-host-binaries.yml", ]; +const V3_PR_GATE_WORKFLOWS: &[&str] = &[ + ".github/workflows/eval-with-entra-id.yml", + ".github/workflows/pr-eval-shell-tests.yml", + ".github/workflows/pr-l1-static-fast.yml", +]; + const APPROVED_MAKE_TARGETS: &[&str] = &[ "check", "check-ci", @@ -121,3 +127,18 @@ fn ci_uses_make_allowlist_is_intentional_and_bounded() { "workflow make-target exceptions must stay reviewed and bounded" ); } + +#[test] +fn v3_pr_gates_are_enabled() { + for rel in V3_PR_GATE_WORKFLOWS { + let content = read_repo_file(rel); + assert!( + content.contains("pull_request:\n branches: [main, v3]"), + "{rel} must run for pull requests targeting main and v3" + ); + assert!( + content.contains("push:\n branches: [main, v3]"), + "{rel} must run for pushes to main and v3" + ); + } +} diff --git a/tests/ci/layer1-workflow.template.yml b/tests/ci/layer1-workflow.template.yml index 10b1a48e6..319e4cb1e 100644 --- a/tests/ci/layer1-workflow.template.yml +++ b/tests/ci/layer1-workflow.template.yml @@ -4,9 +4,9 @@ name: {{ workflow_name }} on: pull_request: - branches: [main] + branches: [main, v3] push: - branches: [main] + branches: [main, v3] permissions: contents: read diff --git a/tests/golden/cli-output/auth-status-human.golden b/tests/golden/cli-output/auth-status-human.golden index 18a6e2d92..131bf40bf 100644 --- a/tests/golden/cli-output/auth-status-human.golden +++ b/tests/golden/cli-output/auth-status-human.golden @@ -3,6 +3,6 @@ effective uid: 1000 sockets: - public: reachable (version 0.4.0-test) - broker: unreachable -allowed subcommands: audio, auth status, boot, build, console, down, gc, generations, host check, keys list, list, op inspect, realm enter, realm inspect, realm list, realm run, restart, rollback, rotate-known-host, status, switch, test, trust, up, usb +allowed subcommands: audio, auth status, boot, build, console, down, gc, generations, host check, keys list, launch, list, op inspect, realm enter, realm inspect, realm list, realm run, restart, rollback, rotate-known-host, status, switch, test, trust, up, usb denied subcommands: - audit: audit requires admin role in `d2b.site.adminUsers`. diff --git a/tests/golden/cli-output/auth-status-json.golden b/tests/golden/cli-output/auth-status-json.golden index c7cb5e2c4..899b7f045 100644 --- a/tests/golden/cli-output/auth-status-json.golden +++ b/tests/golden/cli-output/auth-status-json.golden @@ -26,6 +26,7 @@ "generations", "host check", "keys list", + "launch", "list", "op inspect", "realm enter", diff --git a/tests/host-integration/host-realm-isolation.nix b/tests/host-integration/host-realm-isolation.nix index 3c94d5cef..81e1e2efd 100644 --- a/tests/host-integration/host-realm-isolation.nix +++ b/tests/host-integration/host-realm-isolation.nix @@ -18,18 +18,24 @@ pkgs.testers.runNixOSTest { ]; d2b.site.usePrebuiltHostTools = false; - d2b.gateways.work = { + d2b.realms.work = { + placement = "gateway-vm"; env = "work"; - index = 20; - relay.namespace = "relns-example.servicebus.windows.net"; - relay.entity = "hc-d2b-display"; - aca = { - subscription = "00000000-0000-0000-0000-000000000000"; - resourceGroup = "rg-d2b-centralus"; - sandboxGroup = "casbx-d2b-demo"; - region = "centralus"; - image = "registry.example.invalid/d2b-wayland:mi"; - diskName = "d2b-wayland-mi"; + network = { + envs = [ "work" ]; + mode = "inherit-env"; + }; + relay = { + enable = true; + mode = "static"; + endpoints = [ "relns-example.servicebus.windows.net/hc-d2b-display" ]; + credentialRef = "gateway-state:work-relay"; + }; + providers.aca = { + kind = "aca"; + placement = "provider-agent"; + capabilityRefs = [ "aca" "relay" ]; + configRef = "gateway:work-aca-non-secret"; }; }; }; @@ -44,14 +50,15 @@ pkgs.testers.runNixOSTest { machine.succeed(f"test -r {policy}") machine.succeed( f"jq -e '.mode == \"host-realm-relay-deny\" " - f"and (.gatewayInterfaces == [\"work-l20\"]) " + f"and (.gatewayInterfaces == []) " f"and (.diagnostics.redacted == true) " f"and (.diagnostics.rateLimited == true)' {policy}" ) policy_forbidden = [ "relns-example.servicebus.windows.net", "hc-d2b-display", - "registry.example.invalid/d2b-wayland:mi", + "gateway-state:work-relay", + "gateway:work-aca-non-secret", "/var/lib/d2b/gateways/work/credential.sealed.json", "/var/lib/d2b/gateways/work/seal.key", "SharedAccessKey", diff --git a/tests/host-integration/unsafe-local-helper.nix b/tests/host-integration/unsafe-local-helper.nix index 1d07c0bda..2e52f7466 100644 --- a/tests/host-integration/unsafe-local-helper.nix +++ b/tests/host-integration/unsafe-local-helper.nix @@ -15,6 +15,8 @@ pkgs.testers.runNixOSTest { isNormalUser = true; uid = 1001; }; + d2b.site.adminUsers = [ "alice" ]; + systemd.services.d2bd.environment.D2B_SKIP_KERNEL_MODULE_CHECK = "1"; d2b.realms.host = { allowedUsers = [ "alice" ]; policy.allowUnsafeLocal = true; @@ -25,6 +27,15 @@ pkgs.testers.runNixOSTest { name = "Probe"; argv = [ "true" ]; }; + launcher.items.terminal = { + type = "shell"; + name = "Terminal"; + }; + shell = { + enable = true; + defaultName = "primary"; + maxSessions = 4; + }; }; }; environment.systemPackages = [ pkgs.jq pkgs.python3 ]; @@ -32,6 +43,7 @@ pkgs.testers.runNixOSTest { }; testScript = '' +if True: start_all() machine.wait_for_unit("d2bd.service") machine.wait_for_file("/run/d2b/unsafe-local-helper.sock", timeout=60) @@ -39,8 +51,32 @@ pkgs.testers.runNixOSTest { machine.succeed( "test \"$(stat -c %G /run/d2b/unsafe-local-helper.sock)\" = d2b-unsafe-local" ) - machine.succeed("id -nG alice | tr ' ' '\\n' | grep -qx d2b-unsafe-local") - machine.fail("id -nG bob | tr ' ' '\\n' | grep -qx d2b-unsafe-local") + machine.succeed("id -nG alice | tr ' ' '\n' | grep -qx d2b-unsafe-local") + machine.fail("id -nG bob | tr ' ' '\n' | grep -qx d2b-unsafe-local") + machine.succeed( + "jq --arg path \"$D2B_MANIFEST_PATH\" " + "'.publicManifestPath = $path' /etc/d2b/bundle.json " + "> /run/d2b/test-bundle.json && " + "python3 -c 'import hashlib,json,sys; " + "p=sys.argv[1]; d=json.load(open(p)); h=dict(d); " + "h.pop(\"bundleHash\",None); h[\"artifactHashes\"]=None; " + "d[\"bundleHash\"]=\"sha256:\"+hashlib.sha256(" + "json.dumps(h,sort_keys=True,separators=(\",\",\":\")).encode()" + ").hexdigest(); open(p,\"w\").write(" + "json.dumps(d,sort_keys=True,separators=(\",\",\":\")))' " + "/run/d2b/test-bundle.json && " + "install -o root -g d2bd -m 0640 " + "/run/d2b/test-bundle.json /etc/d2b/bundle.json" + ) + machine.succeed( + "jq --arg path \"$D2B_MANIFEST_PATH\" " + "'.artifacts.publicManifestPath = $path' /etc/d2b/daemon-config.json " + "> /run/d2b/test-daemon-config.json && " + "install -o root -g d2bd -m 0640 " + "/run/d2b/test-daemon-config.json /etc/d2b/daemon-config.json" + ) + machine.succeed("systemctl restart d2bd.service") + machine.wait_for_unit("d2bd.service") machine.succeed("systemctl start user@1000.service") alice_user = ( @@ -78,177 +114,320 @@ pkgs.testers.runNixOSTest { ) machine.fail(bob_user + " is-active d2b-unsafe-local-helper.service") + machine.succeed(r""" + cat > /run/d2b/shell-client.py <<'PY' +import base64 +import json +import os +import socket +import struct +import sys +import time + +SOCKET = "/run/d2b/public.sock" +TARGET = "tools.host.d2b" + +def send_frame(conn, value): + body = json.dumps(value, separators=(",", ":")).encode() + conn.sendall(struct.pack("=0.4.0, <0.5.0", + "supportedFeatures": [ + "typed-errors", + "configured-launch-v1", + "unsafe-local-provider-v1", + "unsafe-local-shell-v1" + ] + }) + hello = recv_frame(conn) + if hello.get("type") != "helloOk": + raise RuntimeError("hello rejected") + if "unsafe-local-shell-v1" not in hello.get("capabilities", []): + raise RuntimeError("unsafe-local shell feature missing") + return conn + +def shell(conn, op, args, op_id): + send_frame(conn, {"type": "shell", "op": op, "args": args, "opId": op_id}) + response = recv_frame(conn) + if response.get("type") == "error": + print(json.dumps(response), file=sys.stderr) + raise RuntimeError(response["error"]["kind"]) + if response.get("type") != "shellResponse": + raise RuntimeError("unexpected shell response") + return response["result"] + +def attach(mode): + conn = connect() + attached = shell(conn, "attach", { + "vm": TARGET, + "name": "primary", + "force": False, + "initialTerminalSize": {"rows": 24, "cols": 80} + }, 1) + session = attached["session"] + shell(conn, "resize", { + "session": session, "rows": 33, "cols": 101, "opId": 1 + }, 2) + if mode == "hold": + open("/run/user/1000/d2b-shell-hold.ready", "w").close() + cursor = 0 + op_id = 3 + while True: + result = shell(conn, "readOutput", { + "session": session, + "stream": "stdout", + "offset": cursor, + "maxLen": 65536, + "wait": True, + "timeoutMs": 250 + }, op_id) + cursor = result["nextOffset"] + op_id += 1 + command = os.environ.get("SHELL_COMMAND", "printf shell-roundtrip-canary") + expected = command.split()[-1].encode() + data = (command + "\n").encode() + shell(conn, "writeStdin", { + "session": session, + "offset": 0, + "chunkBase64": base64.b64encode(data).decode(), + "eof": False + }, 3) + cursor = 0 + output = bytearray() + deadline = time.monotonic() + 15 + op_id = 4 + while time.monotonic() < deadline: + chunk = shell(conn, "readOutput", { + "session": session, + "stream": "stdout", + "offset": cursor, + "maxLen": 65536, + "wait": True, + "timeoutMs": 250 + }, op_id) + output.extend(base64.b64decode(chunk["dataBase64"])) + cursor = chunk["nextOffset"] + op_id += 1 + if expected in output: + break + else: + raise RuntimeError("command output timeout") + print(output.decode(errors="replace")) + shell(conn, "closeAttach", {"session": session}, op_id) + +def management(op): + conn = connect() + args = {"vm": TARGET} + if op in ("detach", "kill"): + args["name"] = "primary" + print(json.dumps(shell(conn, op, args, 1), sort_keys=True)) + +if sys.argv[1] in ("attach", "hold"): + attach(sys.argv[1]) +else: + management(sys.argv[1]) +PY + chmod 0755 /run/d2b/shell-client.py + """) + + shell_client = ( + "runuser -u alice -- env XDG_RUNTIME_DIR=/run/user/1000 " + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus " + "python3 /run/d2b/shell-client.py" + ) + machine.succeed(r""" + cat > /run/d2b/cli-shell-e2e.py <<'PY' +import errno +import os +import pty +import select +import signal +import time + +pid, master = pty.fork() +if pid == 0: + os.execv( + "/run/current-system/sw/bin/d2b", + ["d2b", "shell", "tools.host.d2b", "--name", "cli-e2e"], + ) + +output = bytearray() + +def read_until(marker, timeout): + deadline = time.monotonic() + timeout + while marker not in output and time.monotonic() < deadline: + readable, _, _ = select.select([master], [], [], 1) + if not readable: + continue + try: + chunk = os.read(master, 65536) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not chunk: + break + output.extend(chunk) + if marker not in output: + raise SystemExit( + f"real d2b shell CLI missed {marker!r}: {bytes(output)!r}" + ) + +read_until(b"attached to shell", 30) +os.write(master, b"stty -echo\\n") +time.sleep(0.5) +while select.select([master], [], [], 0)[0]: + output.extend(os.read(master, 65536)) +output.clear() +os.write(master, b"printf cli-shell-executed-canary\\n") +read_until(b"cli-shell-executed-canary", 30) + +os.kill(pid, signal.SIGTERM) +deadline = time.monotonic() + 15 +while time.monotonic() < deadline: + waited, status = os.waitpid(pid, os.WNOHANG) + if waited == pid: + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: + raise SystemExit( + f"real d2b shell CLI exited with status {status}: {bytes(output)!r}" + ) + break + time.sleep(0.05) +else: + os.kill(pid, 9) + os.waitpid(pid, 0) + raise SystemExit("real d2b shell CLI did not detach") +PY + chmod 0755 /run/d2b/cli-shell-e2e.py + """) + machine.succeed( + "runuser -u alice -- env XDG_RUNTIME_DIR=/run/user/1000 " + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus " + "python3 /run/d2b/cli-shell-e2e.py" + ) + machine.succeed( + "runuser -u alice -- env XDG_RUNTIME_DIR=/run/user/1000 " + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus " + "d2b shell tools.host.d2b kill --name cli-e2e --json " + "| jq -e '.result == \"killed\"'" + ) + machine.succeed( + "runuser -u alice -- sh -c 'setsid sleep 300 >/dev/null 2>&1 & " + "echo $! > /run/user/1000/unrelated-same-uid.pid'" + ) + unrelated_pid = machine.succeed( + "cat /run/user/1000/unrelated-same-uid.pid" + ).strip() + + machine.succeed( + "SHELL_COMMAND='printf shell-roundtrip-canary' " + + shell_client + " attach | grep -q shell-roundtrip-canary" + ) + machine.succeed( + shell_client + " list | jq -e " + "'.defaultName == \"primary\" and " + "(.sessions | any(.name == \"primary\" and .attached == false))'" + ) + machine.succeed( + "SHELL_COMMAND='printf reattach-continuity-canary' " + + shell_client + " attach | grep -q reattach-continuity-canary" + ) + + machine.succeed("rm -f /run/user/1000/d2b-shell-hold.ready") + machine.succeed( + shell_client + " hold >/run/user/1000/d2b-shell-hold.log 2>&1 & " + "echo $! > /run/user/1000/d2b-shell-hold.pid" + ) + machine.wait_for_file("/run/user/1000/d2b-shell-hold.ready", timeout=60) machine.succeed("systemctl restart d2bd.service") machine.wait_for_unit("d2bd.service") - machine.wait_for_file("/run/d2b/unsafe-local-helper.sock", timeout=60) + machine.wait_until_fails( + "kill -0 $(cat /run/user/1000/d2b-shell-hold.pid)", timeout=60 + ) machine.wait_until_succeeds( - alice_user + " is-active d2b-unsafe-local-helper.service", - timeout=60, + alice_user + " is-active d2b-unsafe-local-helper.service", timeout=60 ) machine.wait_until_succeeds( - "test \"$(journalctl -u d2bd.service --no-pager " - "| grep -c 'unsafe-local helper registered')\" -ge 2", + shell_client + " list | jq -e '.sessions | any(.name == \"primary\")'", timeout=60, ) - - machine.succeed("systemctl stop d2bd.service") - machine.succeed(alice_user + " stop d2b-unsafe-local-helper.service") - machine.succeed("rm -f /run/d2b/unsafe-local-helper.sock") - machine.succeed( - "cat > /run/d2b/helper-mock.py <<'PY'\n" - "import grp, json, os, socket, struct, time\n" - "path = '/run/d2b/unsafe-local-helper.sock'\n" - "def recv_frame(conn):\n" - " data = conn.recv(262149)\n" - " if len(data) < 4:\n" - " raise RuntimeError('short frame')\n" - " size = struct.unpack(' /run/user/1000/d2b-helper-child-state; sleep 60'],\n" - " 'graphical':False}}\n" - "send_frame(conn, request)\n" - "response = recv_frame(conn)\n" - "open('/run/d2b/helper-launch-response.json', 'w').write(json.dumps(response))\n" - "conn.close()\n" - "conn, snapshot = accept_helper(listener)\n" - "open('/run/d2b/helper-snapshot-2.json', 'w').write(json.dumps(snapshot))\n" - "while os.path.exists('/run/d2b/keep-helper-connected'):\n" - " send_frame(conn, {'type':'heartbeat','payload':{\n" - " 'generation':snapshot['payload']['generation'],'sequence':1}})\n" - " try:\n" - " recv_frame(conn)\n" - " except Exception:\n" - " break\n" - " time.sleep(1)\n" - "conn.close()\n" - "conn, snapshot = accept_helper(listener)\n" - "send_frame(conn, {'type':'launch','payload':{\n" - " 'requestId':42,'operationId':'op-no-user-manager',\n" - " 'workload':{'workloadId':'tools','realmId':'host',\n" - " 'realmPath':['host'],'canonicalTarget':'tools.host.d2b'},\n" - " 'itemId':'probe','argv':['true'],'graphical':False}})\n" - "response = recv_frame(conn)\n" - "open('/run/d2b/helper-no-manager-response.json', 'w').write(json.dumps(response))\n" - "conn.close()\n" - "PY\n" - "chown d2bd:d2bd /run/d2b/helper-mock.py" - ) - machine.succeed( - "runuser -u d2bd -- python3 /run/d2b/helper-mock.py " - ">/run/d2b/helper-mock.log 2>&1 & " - "echo $! > /run/d2b/helper-mock.pid" - ) - machine.wait_for_file("/run/d2b/helper-mock.ready", timeout=60) - machine.succeed( - alice_user + " set-environment D2B_MANAGER_CANARY=manager-canary" - ) - machine.succeed("touch /run/d2b/keep-helper-connected") - machine.succeed(alice_user + " start d2b-unsafe-local-helper.service") - machine.wait_for_file("/run/d2b/helper-launch-response.json", timeout=60) - machine.succeed( - "jq -e '.type == \"operation\" and " - ".payload.disposition == \"committed\" and " - ".payload.scope.kind == \"launcher-app\"' " - "/run/d2b/helper-launch-response.json " - "|| { cat /run/d2b/helper-launch-response.json >&2; exit 1; }" - ) - invocation = machine.succeed( - "jq -r .payload.scope.invocationId /run/d2b/helper-launch-response.json" - ).strip() - scope = machine.succeed( - alice_user - + " list-units --state=active --plain --no-legend " - "'d2b-unsafe-local-app-*.scope' | awk 'NR == 1 {print $1}'" - ).strip() - assert scope.endswith(".scope"), f"unsafe-local app scope missing: {scope!r}" machine.succeed( - alice_user + f" show -P InvocationID {scope} | grep -qx {invocation}" + "SHELL_COMMAND='printf daemon-restart-canary' " + + shell_client + " attach | grep -q daemon-restart-canary" ) + + machine.succeed(alice_user + " restart d2b-unsafe-local-helper.service") machine.wait_until_succeeds( - "grep -qx 'cwd=/home/alice' /run/user/1000/d2b-helper-child-state", - timeout=60, + alice_user + " is-active d2b-unsafe-local-helper.service", timeout=60 ) - machine.succeed( - "grep -qx 'stdout=/dev/null' /run/user/1000/d2b-helper-child-state" + machine.wait_until_succeeds( + shell_client + " list | jq -e '.sessions | any(.name == \"primary\")'", + timeout=60, ) machine.succeed( - "grep -qx 'stderr=/dev/null' /run/user/1000/d2b-helper-child-state" + "SHELL_COMMAND='printf helper-adoption-canary' " + + shell_client + " attach | grep -q helper-adoption-canary" ) + machine.succeed(shell_client + " kill | jq -e '.killed == true'") + machine.succeed(f"kill -0 {unrelated_pid}") + machine.succeed(shell_client + " list | jq -e '.sessions | length == 0'") + machine.succeed( - "grep -qx 'env=manager-canary' /run/user/1000/d2b-helper-child-state" + "SHELL_COMMAND='printf logout-canary' " + + shell_client + " attach >/run/user/1000/logout-shell.log" ) - control_group = machine.succeed( - alice_user + f" show -P ControlGroup {scope}" + shell_scope = machine.succeed( + alice_user + + " list-units --state=active --plain --no-legend " + "'d2b-unsafe-local-shell-*.scope' | awk 'NR == 1 {print $1}'" ).strip() - app_pid = machine.succeed( - f"awk 'NR == 1 {{print $1}}' /sys/fs/cgroup{control_group}/cgroup.procs" + assert shell_scope.endswith(".scope"), f"persistent shell scope missing: {shell_scope!r}" + shell_control_group = machine.succeed( + alice_user + f" show -P ControlGroup {shell_scope}" + ).strip() + shell_pid = machine.succeed( + f"awk 'NR == 1 {{print $1}}' /sys/fs/cgroup{shell_control_group}/cgroup.procs" ).strip() - machine.succeed(f"test -d /proc/{app_pid}") - machine.wait_for_file("/run/d2b/helper-snapshot-2.json", timeout=60) - machine.succeed( - f"jq -e --arg invocation {invocation} " - "'.payload.scopes | any(" - ".scope.invocationId == $invocation and .state == \"active\")' " - "/run/d2b/helper-snapshot-2.json" - ) - machine.succeed("loginctl show-user alice -p Linger --value | grep -qx no") - machine.succeed("rm -f /run/d2b/keep-helper-connected") machine.succeed("systemctl stop user@1000.service") - machine.wait_until_fails(f"test -d /proc/{app_pid}", timeout=60) + machine.wait_until_fails(f"test -d /proc/{shell_pid}", timeout=60) machine.succeed( + "install -d -o alice -g users -m 0700 /run/user/1000 && " "runuser -u alice -- env " "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/missing " "XDG_RUNTIME_DIR=/run/user/1000 " - "/run/current-system/sw/bin/d2b-unsafe-local-helper " - ">/run/d2b/no-manager-helper.log 2>&1 &" + "setsid -f /run/current-system/sw/bin/d2b-unsafe-local-helper " + "/run/d2b/no-manager-helper.log 2>&1" ) - machine.wait_for_file("/run/d2b/helper-no-manager-response.json", timeout=60) - machine.succeed( - "jq -e '.type == \"rejected\" and " - ".payload.code == \"user-manager-unavailable\"' " - "/run/d2b/helper-no-manager-response.json" + machine.wait_until_succeeds( + "! " + shell_client + " attach >/run/d2b/no-manager-client.log 2>&1 && " + "grep -q unsafe-local-shell-user-manager-unavailable " + "/run/d2b/no-manager-client.log", + timeout=60, ) + machine.succeed(f"kill {unrelated_pid}") - units = machine.succeed( - "systemctl list-unit-files --no-pager --no-legend " - "| awk '{print $1}' | grep -E '^(d2b|microvm)' | sort" - ).strip().split() - assert "d2b-unsafe-local-helper.service" not in units, ( - "unsafe-local helper must be a user unit, not a fourth root unit" + machine.succeed("systemctl show d2bd.service >/dev/null") + machine.succeed("systemctl show d2b-priv-broker.service >/dev/null") + machine.succeed("systemctl show d2b-priv-broker.socket >/dev/null") + machine.succeed( + "! systemctl list-units --all --no-pager --no-legend " + "| grep -E 'd2b-unsafe-local-(helper|shell)'" ) ''; } diff --git a/tests/unit/nix/cases/realm-workloads.nix b/tests/unit/nix/cases/realm-workloads.nix index 1118aa71f..fd64c5857 100644 --- a/tests/unit/nix/cases/realm-workloads.nix +++ b/tests/unit/nix/cases/realm-workloads.nix @@ -123,6 +123,86 @@ let wlCfg = (mkEval [ workloadFixture ]).config; wlIndex = wlCfg.d2b._index; workRealm = wlIndex.realms.byPath."work.home"; + firstClassLocalVmFixture = lib.recursiveUpdate workloadFixture { + d2b.realms.work.workloads.first-class = { + enable = true; + kind = "local-vm"; + launcher = { + enable = true; + label = "First-class VM"; + defaultItem = "probe"; + items.probe = { + type = "exec"; + name = "Probe"; + argv = [ "true" ]; + }; + }; + }; + }; + firstClassLocalVmCfg = (mkEval [ firstClassLocalVmFixture ]).config; + firstClassPrivateWorkloads = + firstClassLocalVmCfg.d2b._bundle.unsafeLocalWorkloadsJson.data.localVmWorkloads; + firstClassPrivateWorkload = lib.findFirst + (workload: workload.identity.workloadId == "first-class") + null + firstClassPrivateWorkloads; + + mixedPrivateData = (lib.evalModules { + modules = [ + ../../../../nixos-modules/unsafe-local-workloads-json.nix + ({ lib, ... }: { + options.d2b._index = lib.mkOption { + type = lib.types.attrs; + default = { }; + }; + options.d2b._bundle.unsafeLocalWorkloadsJson = lib.mkOption { + type = lib.types.attrs; + default = { }; + }; + options.d2b.realms = lib.mkOption { + type = lib.types.attrs; + default = { }; + }; + config.d2b.realms.work.workloads.mixed = { + launcher.items = { + run = { }; + docs = { }; + }; + shell.enable = false; + }; + config.d2b._index.realms.workloads.enabled = [{ + kind = "local-vm"; + launcherEnabled = true; + workloadId = "mixed"; + workloadName = "mixed"; + label = "Mixed"; + realmName = "work"; + realmId = "work"; + realmPath = "work"; + canonicalTarget = "mixed.work.d2b"; + legacyVmName = null; + runtimeKind = "nixos"; + runtimeProviderId = "local-cloud-hypervisor"; + defaultItemId = "run"; + launcherItems = [ + { + type = "exec"; + id = "run"; + name = "Run"; + argv = [ "true" ]; + graphical = false; + icon = { id = null; name = null; }; + } + { + type = "link"; + id = "docs"; + name = "Documentation"; + } + ]; + }]; + }) + ]; + }).config.d2b._bundle.unsafeLocalWorkloadsJson.data; unsafeLocalFixture = lib.recursiveUpdate hostBase { users.users.bob = { isNormalUser = true; uid = 1001; }; @@ -164,6 +244,38 @@ let unsafeCfg = (mkEval [ unsafeLocalFixture ]).config; unsafeRow = builtins.head unsafeCfg.d2b._index.realms.workloads.enabled; + workloadNames = prefix: count: + map (n: "${prefix}-${toString n}") (lib.range 1 count); + unsafeBoundFixture = lib.recursiveUpdate hostBase { + d2b.daemonExperimental.enable = true; + d2b.realms.host = { + allowedUsers = [ "alice" ]; + policy.allowUnsafeLocal = true; + workloads = lib.genAttrs (workloadNames "unsafe" 257) (_: { + kind = "unsafe-local"; + launcher.items.run = { + type = "exec"; + argv = [ "true" ]; + }; + }); + }; + }; + localVmBoundFixture = lib.recursiveUpdate hostBase { + d2b.realms.work = { + allowedUsers = [ "alice" ]; + workloads = lib.genAttrs (workloadNames "local" 257) (_: { + kind = "local-vm"; + launcher = { + enable = true; + items.run = { + type = "exec"; + argv = [ "true" ]; + }; + }; + }); + }; + }; + # ── helpers ───────────────────────────────────────────────────────────────── failureMessages = modules: map (a: a.message) @@ -257,6 +369,111 @@ let extNetMessages = failureMessages [ extNetConflictFixture ]; in { + "realm-workloads/first-class-local-vm-private-launcher" = { + expr = { + found = firstClassPrivateWorkload != null; + emittedCount = builtins.length firstClassPrivateWorkloads; + legacyVmNameAbsent = !(firstClassPrivateWorkload.identity ? legacyVmName); + legacyVmName = firstClassPrivateWorkload.identity.legacyVmName or null; + runtimeKind = firstClassPrivateWorkload.identity.runtimeKind; + defaultItemId = firstClassPrivateWorkload.defaultItemId; + item = builtins.head firstClassPrivateWorkload.items; + hasStateDir = firstClassPrivateWorkload ? stateDir; + }; + expected = { + found = true; + emittedCount = 1; + legacyVmNameAbsent = true; + legacyVmName = null; + runtimeKind = "nixos"; + defaultItemId = "probe"; + item = { + type = "exec"; + id = "probe"; + name = "Probe"; + argv = [ "true" ]; + graphical = false; + icon = { }; + }; + hasStateDir = false; + }; + }; + + "realm-workloads/local-vm-private-items-filter-unsupported-kinds" = { + expr = + let + row = builtins.head mixedPrivateData.localVmWorkloads; + in { + itemCount = builtins.length row.items; + itemTypes = map (item: item.type) row.items; + hasNull = lib.any (item: item == null) row.items; + encodedHasNull = lib.hasInfix "null" (builtins.toJSON row.items); + }; + expected = { + itemCount = 1; + itemTypes = [ "exec" ]; + hasNull = false; + encodedHasNull = false; + }; + }; + + "realm-workloads/empty-local-vm-launcher-stays-compatibility-only" = { + expr = + let + cfg = (mkEval [ + firstClassLocalVmFixture + { + d2b.realms.work.workloads.first-class.launcher = { + defaultItem = lib.mkForce null; + items = lib.mkForce { }; + }; + } + ]).config; + privateRows = + cfg.d2b._bundle.unsafeLocalWorkloadsJson.data.localVmWorkloads; + in { + assertionsPass = lib.all (assertion: assertion.assertion) cfg.assertions; + firstClassExcluded = !lib.any + (row: row.identity.workloadId == "first-class") + privateRows; + }; + expected = { + assertionsPass = true; + firstClassExcluded = true; + }; + }; + + "realm-workloads/empty-local-vm-configured-launch-rejected" = { + expr = + let + messages = failureMessages [ + firstClassLocalVmFixture + { + d2b.realms.work.workloads.first-class.launcher.items = + lib.mkForce { }; + } + ]; + in hasMessage + [ "first-class" "configured launch" "no supported exec or shell" ] + messages; + expected = true; + }; + + "realm-workloads/private-configured-workload-bounds" = { + expr = { + unsafeOverflow = hasMessage + [ "maximum of 256" "unsafe-local workloads" ] + (failureMessages [ unsafeBoundFixture ]); + localVmOverflow = hasMessage + [ "maximum of 256" "local-vm" "configured launch" ] + (failureMessages [ localVmBoundFixture ]); + }; + expected = { + unsafeOverflow = true; + localVmOverflow = true; + }; + }; + # ── workload index: accepted config with legacyVmName ───────────────────── "realm-workloads/index-accepted-with-legacyvmname" = { expr = @@ -876,6 +1093,15 @@ in rootUnits = lib.attrNames unsafeCfg.systemd.services ++ lib.attrNames unsafeCfg.systemd.sockets; + privileges = unsafeCfg.d2b._bundle.privilegesJson.data; + shellPrivilege = + lib.findFirst (row: row.operation == "shell") null privileges.publicOperations; + unsafeLocalShellBrokerOps = + builtins.filter + (row: + lib.hasInfix "unsafe-local" row.operation + && lib.hasInfix "shell" row.operation) + privileges.brokerOperations; in { helperGroupDeclared = unsafeCfg.users.groups ? d2b-unsafe-local; eligibleHasLifecycleGroup = @@ -887,6 +1113,13 @@ in restart = service.serviceConfig.Restart; execStartHasHelper = lib.hasInfix "/bin/d2b-unsafe-local-helper" service.serviceConfig.ExecStart; + execStartHasImmutableProxy = + lib.hasInfix + (builtins.unsafeDiscardStringContext + "--wayland-proxy ${unsafeCfg.d2b._hostToolPackages.d2bWaylandProxy}/bin/d2b-wayland-proxy") + (builtins.unsafeDiscardStringContext service.serviceConfig.ExecStart); + proxyPackageConfigured = + unsafeCfg.d2b._hostToolPackages.d2bWaylandProxy != null; daemonSocketPath = daemonConfig.unsafeLocalHelperSocketPath; daemonSocketGroup = daemonConfig.unsafeLocalHelperSocketGroup; daemonAllowedUsers = daemonConfig.unsafeLocalHelperUsers; @@ -894,6 +1127,10 @@ in !(builtins.elem "d2b-unsafe-local-helper" rootUnits); noHelperBrokerSocketUnit = !(builtins.elem "d2b-unsafe-local-helper" (lib.attrNames unsafeCfg.systemd.sockets)); + unsafeLocalShellRootUnits = + builtins.filter (name: lib.hasInfix "unsafe-local-shell" name) rootUnits; + shellBrokerRequired = shellPrivilege.brokerRequired; + unsafeLocalShellBrokerOps = unsafeLocalShellBrokerOps; }; expected = { helperGroupDeclared = true; @@ -903,11 +1140,16 @@ in conditionGroup = "d2b-unsafe-local"; restart = "on-failure"; execStartHasHelper = true; + execStartHasImmutableProxy = true; + proxyPackageConfigured = true; daemonSocketPath = "/run/d2b/unsafe-local-helper.sock"; daemonSocketGroup = "d2b-unsafe-local"; daemonAllowedUsers = [ "alice" ]; helperRootUnitAbsent = true; noHelperBrokerSocketUnit = true; + unsafeLocalShellRootUnits = [ ]; + shellBrokerRequired = "no"; + unsafeLocalShellBrokerOps = [ ]; }; }; @@ -979,7 +1221,7 @@ in expected = true; }; - "realm-workloads/unsafe-local-artifacts-and-bundle-v10" = { + "realm-workloads/unsafe-local-artifacts-and-bundle-v11" = { expr = { launcherV2File = unsafeCfg.d2b._bundle.realmWorkloadsLauncherV2Json.installFileName; @@ -995,6 +1237,8 @@ in unsafeCfg.environment.etc."d2b/realm-workloads-launcher-v2.json".mode; unsafeInstalled = unsafeCfg.environment.etc ? "d2b/unsafe-local-workloads.json"; + unsafeMode = + unsafeCfg.environment.etc."d2b/unsafe-local-workloads.json".mode; bundleVersion = unsafeCfg.d2b._bundle.bundle.data.bundleVersion; launcherV2BundlePath = unsafeCfg.d2b._bundle.bundle.data.realmWorkloadsLauncherV2Path; @@ -1008,7 +1252,8 @@ in launcherV2Installed = true; launcherV2Mode = "0640"; unsafeInstalled = true; - bundleVersion = 10; + unsafeMode = "0640"; + bundleVersion = 11; launcherV2BundlePath = "/etc/d2b/realm-workloads-launcher-v2.json"; bundlePath = "/etc/d2b/unsafe-local-workloads.json"; }; diff --git a/tests/unit/nix/pinned/common.txt b/tests/unit/nix/pinned/common.txt index 2ec540297..66770cc92 100644 --- a/tests/unit/nix/pinned/common.txt +++ b/tests/unit/nix/pinned/common.txt @@ -624,6 +624,9 @@ realm-workloads/cross-realm-ext-network-conflict-advisory-only realm-workloads/cross-realm-ext-network-conflict-in-index realm-workloads/cross-realm-same-vm-no-cid-collision realm-workloads/disabled-excluded-from-enabled +realm-workloads/empty-local-vm-configured-launch-rejected +realm-workloads/empty-local-vm-launcher-stays-compatibility-only +realm-workloads/first-class-local-vm-private-launcher realm-workloads/index-accepted-provider-placeholder realm-workloads/index-accepted-with-legacyvmname realm-workloads/index-all-includes-disabled @@ -643,10 +646,12 @@ realm-workloads/launcher-json-no-implicit-dedup realm-workloads/launcher-json-shape realm-workloads/launcher-json-workload-id-field realm-workloads/launcher-v2-public-metadata-hides-argv +realm-workloads/local-vm-private-items-filter-unsupported-kinds realm-workloads/persistent-shell-compatibility-item +realm-workloads/private-configured-workload-bounds realm-workloads/realm-row-workload-names realm-workloads/realm-with-no-workloads-empty-index -realm-workloads/unsafe-local-artifacts-and-bundle-v10 +realm-workloads/unsafe-local-artifacts-and-bundle-v11 realm-workloads/unsafe-local-helper-user-service-and-eligibility realm-workloads/unsafe-local-index-posture-and-items realm-workloads/unsafe-local-omitted-from-launcher-v1