From 8c038da1e0601f82bc30d52fefc1e9ef81a962bf Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:39:09 -0700 Subject: [PATCH 01/14] docs: propose workload-hosted realm controllers (#291) Define parent-owned workload-hosted realm controllers, type-first provider interfaces, Entra and YubiKey credential boundaries, and policy-authorized shared-relay shortcuts. --- CHANGELOG.md | 7 + ...d-realm-controllers-and-relay-shortcuts.md | 1210 +++++++++++++++++ docs/adr/README.md | 1 + 3 files changed, 1218 insertions(+) create mode 100644 docs/adr/0045-workload-hosted-realm-controllers-and-relay-shortcuts.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e64829e53..d956e72e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ deprecations ship one minor release before removal. ### Added +- Added proposed ADR 0045, defining parent-owned workload-hosted realm + controllers, explicit runtime/infrastructure/relay provider responsibilities, + type-first sortable provider crate names with mandatory standard interfaces + and conformance suites backed by canonical provider DTOs in `d2b-contracts`, + Entra and YubiKey credential placement, and policy-authorized peer shortcuts + over inherited shared relay fabrics for nested realms. + - 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 diff --git a/docs/adr/0045-workload-hosted-realm-controllers-and-relay-shortcuts.md b/docs/adr/0045-workload-hosted-realm-controllers-and-relay-shortcuts.md new file mode 100644 index 000000000..e4eaf84e5 --- /dev/null +++ b/docs/adr/0045-workload-hosted-realm-controllers-and-relay-shortcuts.md @@ -0,0 +1,1210 @@ +# ADR 0045: Workload-hosted realm controllers and shared-relay shortcuts + +- 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, or relay transport; +- the existing `d2b.realms..providers` records do not distinguish those + responsibilities; +- relay configuration says how a realm is reachable, but not which workload + owns interactive credentials or opens the connector; +- 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. | +| Relay provider | Supplies rendezvous and byte transport for authenticated d2b peer sessions. | +| 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 relay provider; +- 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-relay-` | `RelayProvider` | Opens relay sessions and creates or revokes scoped rendezvous bindings. | +| `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-relay-azure`; +- `d2b-provider-credential-entra`; +- `d2b-provider-substrate-nixos`; +- `d2b-provider-display-wayland`. + +For example, `d2b-provider-relay-azure` has +`ProviderType::Relay` and implementation id `azure`; its complete public +provider kind may still render as `azure-relay`. A configured deployment may +then assign instance ids such as `work-relay` or `payments-relay` 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 transports are relay providers, so they sort under the same type axes as +their peers. + +### 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 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, + Relay, + 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. | +| `RelayProvider` | Capability description; connect/listen; issue scoped rendezvous binding; revoke binding; 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. | + +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. + +`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`. + +### 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`, in-process traits/registries into `d2b-provider`, 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-relay-azure` | +| Provider conformance code in production crates | `d2b-provider-testkit` | +| Loopback relay implementation used only by tests | `d2b-provider-testkit` | +| Provider implementations in `d2b-realm-transport` | Move to the matching `d2b-provider-relay-` crate; protocol-neutral session DTOs remain in realm-core. | +| `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. + +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` | + +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"; + + roles.realmController = { + enable = true; + forRealm = "work"; + }; + + localVm = { + autostart = true; + tpm.enable = true; + graphics.enable = true; + usb.securityKey.enable = true; + }; +}; + +d2b.realms.work = { + parent = "local-root"; +}; +``` + +The option spelling above is the selected public shape. Implementation may add +typed sub-options, but it must not replace the role with an arbitrary command, +free-form service definition, or provider-specific controller option. + +The declaration has these invariants: + +1. The controller workload is owned by the direct parent realm of + `forRealm`. +2. A workload cannot control its owning realm, an ancestor, a sibling, or an + unrelated realm. +3. Exactly one controller workload is declared for a realm. 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. +4. `roles.realmController.forRealm` is a scalar realm path. Lists are rejected + at evaluation, so one declaration cannot collapse multiple realm credential + domains. +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 relay 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. + +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"; + + roles.realmController = { + enable = true; + forRealm = "work"; + }; +}; +``` + +`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 +handshake. 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 relay-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. | +| Relay connector | Relay authentication and outbound transport session. | + +Only `roles.realmController` is asserted directly by a generic workload. +Infrastructure-provider and relay-connector behavior is derived from the +provider or relay binding that references the executor workload. This 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 Relay 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 relay configuration remains the transport source of truth + +Relay placement is configured from `d2b.realms..relay`; a separate +gateway or realm-entrypoint object is not introduced. + +The relay schema is extended conceptually as follows. `relay.provider` is a +reference to a typed `relayProviders` instance, not an inline provider kind: + +```nix +d2b.realms.work.relayProviders.azure-work = { + kind = "azure-relay"; + + connector = { + workload = "work-connector.local-root.d2b"; + credentialRef = "entra-work-relay"; + authentication = "entra-user"; + }; + + controllerAuthentication = "managed-identity"; +}; + +d2b.realms.work.relay = { + enable = true; + provider = "azure-work"; + fabricRef = "work-relay"; + descendantAccess = "delegated"; + peerShortcuts.enable = true; +}; + +d2b.realms.payments.relay.inheritFrom = "work"; +``` + +`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 relay 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-relay peer shortcuts separate control and data paths + +Strict tree routing remains the authorization model. Shared-relay shortcuts +optimize only the data path: + +```text +control: + source -> source controller -> applicable ancestors -> target controller + +data after authorization: + source peer -> shared relay 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, Relay 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 relay adapter supplies an opaque, one-time rendezvous binding +below the policy DTO. Session keys and d2b identities authenticate and encrypt +the stream end to end; the relay authenticates transport access 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 relay and must not drive security policy, +billing, exfiltration detection, or compliance conclusions. A relay 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-relay +``` + +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-relay` shortcut is +allowed only when both peers already participate in the same configured relay +fabric and the relay 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 relay provider advertising `active-shortcut-revoke-v1` must close +the established relay binding when the authorizing ancestor revokes it, while +both peer muxes close the named stream. Providers without active revocation may +support shared-relay shortcuts only with a maximum 60-second session grant and +policy-authorized renewal; expiration closes the stream before renewal. Relay +token 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 relay 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 relay + +Eliminating controller hops requires the source and target workload agents, not +only their controllers, to participate in the relay fabric. + +A workload may advertise `relay-client-v1` only when its runtime supplies a +guestd-compatible or d2b peer agent. The controller delegates a short-lived, +workload-scoped relay access grant or the workload authenticates independently +through a provider-supported identity such as Azure Managed Identity. + +The workload never receives: + +- the controller's Relay 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 relay provider cannot issue or validate a workload-scoped transport +identity, that workload cannot use a direct shared-relay 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. Labels may include provider type, +realm class, and result class, but never a 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 relay-connector peer identities are + authenticated before any state lookup, token resolution, provisioning, or + route publication. +3. Provider and Relay tokens stay in the configured credential-owning workload. +4. Entra, managed identity, and Relay 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. One shared relay fabric 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 Relay 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. + +## 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. + +## 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 Relay 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 Relay is unavailable, no provider-native, SSH, or direct-network fallback + is attempted unless a separately configured and authorized transport 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. +- 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 `d2b-provider` base and specialized provider interfaces; +- type-first provider implementation crates and typed registries; +- `d2b-provider-testkit` conformance suites and provider naming policy; +- `d2b.realms..workloads..roles.realmController`; +- typed runtime and infrastructure provider bindings replacing the ambiguous + inert provider record; +- `d2b.realms..relay` provider, fabric inheritance, connector, and + shortcut policy; +- workload, controller, provider-executor, and relay-client capability + advertisements; +- controller-workload bootstrap and generation DTOs; +- peer shortcut authorization and transport-binding DTOs; +- realm-controller, workload, and relay 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. + +## 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 every + `d2b-provider--` crate is a leaf adapter that does not + depend on `d2bd`, `d2b-priv-broker`, or another provider implementation; +- 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; +- contract tests proving `ProviderOperationContext` remains serializable and + runtime cancellation/deadline state exists only in `ProviderCallContext`; +- Nix evaluation tests for one-controller-per-realm, direct-parent ownership, + cycle rejection, scalar-only `forRealm`, provider capability gating, typed + relay-provider references, ancestor-only relay 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; +- 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; +- Relay inheritance tests proving nested realms receive distinct identities and + no copied credential references; +- route-engine tests for shared-relay 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 relay route or returns a typed transport error without + probing direct networks, SSH, provider-native APIs, generic TCP, or tunnels; +- negative tests proving a relay-authenticated peer is not local `Admin`; +- negative tests proving no remote realm, workload, relay, 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; +- status/telemetry tests proving `interaction-required` is visible without + leaking user, token, RP, or endpoint identity; +- redaction tests covering provider ids, endpoints, Entra identities, Azure + resource ids, and shortcut metadata. + +## 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`. +- Local and remote realm controllers use one configuration and protocol model. +- Controller hosting lifecycle cannot become self-referential. +- Provider, Relay, and controller responsibilities have explicit credential and + authority boundaries. +- Nested realms can share one relay fabric without forcing stream bytes through + every controller. +- The existing route-shortcut policy engine remains useful and gains a + provider-neutral relay 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. +- Remote controllers need a parent-owned provider executor even after + enrollment. +- Direct workload shortcuts require relay-capable guest agents and scoped + transport identities. +- Shared-relay 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. + +### 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 Relay membership as authorization + +Rejected. Relay 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-relay 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..110119359 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. Workload-hosted realm controllers and shared-relay shortcuts](0045-workload-hosted-realm-controllers-and-relay-shortcuts.md) | Proposed | 2026-07-10 | Models gateway and remote realm controllers as parent-owned workloads, gives provider crates sortable type-first names and standard interfaces backed by `d2b-contracts`, and authorizes direct peer streams over inherited shared relay fabrics without bypassing realm-tree policy. | From 501eda131e34e69e4b5f50a69931922be16c496f Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:11:57 -0700 Subject: [PATCH 02/14] workloads: add provider-neutral launch and status (#292) * workloads: add provider-neutral launch and status ( W2 ) Resolve configured launcher items exclusively from bundle-hashed private metadata, route local VM and unsafe-local providers through their authenticated runtimes, and expose feature-negotiated workload list/status/launch through d2bd and the Rust CLI. Bundle format 11 extends trusted configured items to local VM workloads while public metadata remains argv-free. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * contracts: normalize generated workload artifacts ( W2 ) Regenerate bundle, wire, CLI schema, completion, manpage, daemon API, and evaluated bundle-version outputs, while normalizing generated manpage trailing whitespace and keeping test-only routing helpers out of production dead-code warnings. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * tests: expect bundle format 11 ( W2 ) Align the hashed configured-workload bundle resolver assertion with the provider-neutral local-VM item extension. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * unsafe-local: expose helper availability correctly ( W2 ) Keep availability and last-failure accessors as sibling registry methods so workload status can compile and report typed helper prerequisites. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * metrics: use approved workload outcome labels ( W2 ) Reuse the bounded outcome label vocabulary for workload lifecycle counters and assert exact sensitive label keys rather than matching OpenMetrics metadata substrings. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * tests: scope workload dashboard redaction ( W2 ) Inspect only the provider workload panels so unrelated pre-existing dashboard content cannot satisfy or invalidate the workload telemetry redaction contract. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * workloads: isolate launch ledger capacity per uid ( W2 ) Bound active and completed launch history per authenticated requester, expire stale entries, and prevent one stalled helper generation from exhausting launch capacity for every authorized user. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * workloads: keep inventory provider-neutral ( W2 ) List every direct-local workload regardless of launcher enablement, reject disabled launchers only at exec dispatch, and centralize provider routing so unsafe-local targets can never be coerced to VM names. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * workloads: scope helper failures by target ( W2 ) Track unsafe-local launch prerequisites per authenticated uid and canonical workload target so one graphical failure cannot degrade every local workload in that user session. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * workloads: close launch contract gaps ( W2fu1 H1 H2 H3 H4 H5 H6 H7 H8 ) Close H1 by preserving legacy VM resolution for shell/exec and using the trusted legacy binding for launch shell items; H2/H3 by normalizing nullable local-VM metadata and asserting private artifact mode; H4-H7 with target/item ambiguity, direct-local placement, feature-skew, and typed-remediation coverage; and H8 by documenting every bounded workload metric label. Grouping keeps the provider-neutral launch contract reviewable end to end. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * tests: pin bundle format 11 case ( W2fu1 H2 H3 ) Align the fail-closed nix-unit inventory with the renamed private configured-item materialization contract. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * tests: borrow generated workload targets ( W2fu1 H4 H5 ) Pass formatted canonical target fixtures through the parser\x27s borrowed-string API in CLI selection and direct-local placement coverage. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * tests: use gateway VM placement variant ( W2fu1 H5 ) Exercise remote realm denial with the canonical controller placement enum used by the committed DTO. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * workloads: make launch idempotency restart-stable ( W2fu2 H1 H2 ) Close H1 by releasing helper operation reservations on timeout, channel failure, and mismatched replies. Close H2 by deriving an opaque local-VM exec id from authenticated launch identity, forwarding it in guest request metadata, and replaying guestd\x27s durable detached record only when the trusted argv hash matches. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * tests: keep detached list assertions with creator ( W2fu2 H2 ) Preserve the existing create/list scope while adding deterministic replay coverage as a separate test. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * workloads: close replay allocation race ( W2fu3 H1 H2 ) Remove pending helper requests on every failed completion path, and recheck requested guest exec ids and tombstones while holding the slot-allocation lock so concurrent same-id creates cannot reserve duplicate slots. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * nix: normalize configured workload artifacts ( W2fu4 L1 L2 ) Align the local-VM private emitter and workload dashboard panels with surrounding Nix and JSON structure, and use the canonical outcome vocabulary in panel description text. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * docs: correct workload state cardinality ( W2fu5 L1 ) Include the explicit not-applicable sentinel alongside the eight WorkloadAvailability values in the metric bound. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * audit: correlate configured workload launches ( W2fu6 H1 H2 ) Record the authenticated peer uid on every workload-launch boundary and emit the standard detached-create audit with the durable opaque exec id for local-VM launches, without adding argv, environment, cwd, paths, or output. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * tests: cover configured launch audit wiring ( W2fu7 H1 H2 H3 ) Verify idempotent detached creation forwards the guest request id, local-VM launcher source wiring emits detached-create audit with the returned exec id, and the local-vm workload provider serializes canonically without execution details. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * audit: join workload and detached execution events ( W2fu8 M1 ) Carry the public operation id on every configured-launch event and the durable guest exec id on local-VM outcomes, creating a shared correlation key with detached-create audit while keeping execution details redacted. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * workloads: return provider launch audit context ( W2fu8 M1 ) Keep unsafe-local dispatch returning its wire disposition while local-VM dispatch also returns the durable exec id needed for audit correlation. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * workloads: document and support first-class local VMs ( W2fu9 H1 H2 H3 H4 H5 H6 ) Close the launch reference gaps with full synopsis/flags/arguments/exit/JSON documentation; preserve existing first-class local-VM semantics by using workload id when legacyVmName is absent instead of rejecting valid evals; and update AGENTS.md for bundle v11 configured items plus launcher-authorized detached guest-control execution. Copilot-Session: 354dd249-397d-4ae2-ab02-f451bef0f5fb * workloads: finish first-class local VM routing ( W2fu10 H1 H2 H3 H4 ) Close the four remaining first-class local-VM findings as one coherent contract correction: CLI shell dispatch and direct-local routing now use the workload id when no legacy binding exists, while schema prose and Nix artifact coverage assert the same rule. * guestd: coalesce detached create replays ( W2fu11 H3 ) * helper: preserve launch idempotency and fd ownership ( W2fu11 H1 H2 M1 ) This coherent batch closes the helper duplicate-launch, timeout ambiguity, and fd-ownership findings. Reservations now precede side effects, ambiguous timeouts remain bounded and non-retryable, and received terminal descriptors are RAII-owned and validated before duplication. * daemon: make workload signals coherent ( W2fu11 H4 H5 H8 H9 H10 H11 M3 L1 ) Batch these findings because inventory aggregation, launch-result accounting, and their authorization/runtime tests share one workload dispatch boundary. Atomic bounded gauge replacement prevents stale or last-row-wins state, while centralized launch outcomes ensure pre-dispatch refusals and backend results emit exactly once without exposing execution details. * workloads: harden private artifact emission ( W2fu11 H6 H7 M2 L2 ) Close H6/H7, M2, and L2 together because the eval predicate, private emitter, Rust allocation bounds, generated schema, and CLI fallback coverage form one reviewable artifact contract. Exclude compatibility-only local VM launcher metadata, reject included configured launches without supported private items, filter unsupported item kinds, split unsafe-local and local-VM limits, and remove the duplicate test-only route helper. * workloads: preserve unsafe-local workload capacity ( W2fu11 M2 ) Keep the existing 256-row unsafe-local limit while bounding local-VM configured launch independently at 256. The initial split incorrectly reduced an established contract to 16. * guestd: satisfy replay lint gate ( W2fu11 H3 ) Pass borrowed detached-create inputs directly so the workspace warnings-denied Clippy gate accepts the coalesced replay helper. * docs: fix workload metric heading levels ( W2fu12 L1 ) Remove accidental list indentation so both workload metric contracts render as first-class reference sections. --------- Co-authored-by: John Vicondoa --- AGENTS.md | 4 +- CHANGELOG.md | 24 +- docs/completions/d2b.bash | 42 +- docs/completions/d2b.fish | 66 +- docs/completions/d2b.zsh | 26 + docs/manpages/d2b-clipboard-arm.1 | 2 +- docs/manpages/d2b-launch.1 | 25 + docs/manpages/d2b-shell.1 | 2 +- docs/manpages/d2b.1 | 5 +- docs/reference/cli-contract.md | 94 +- docs/reference/cli-output/launch.schema.json | 57 + docs/reference/daemon-api.md | 192 +-- docs/reference/daemon-metrics.md | 26 + docs/reference/manifest-bundle.md | 6 +- docs/reference/naming-conventions.md | 2 +- docs/reference/schemas/v2/bundle.json | 2 +- docs/reference/schemas/v2/bundle.md | 2 +- .../schemas/v2/unsafe-local-workloads.json | 46 +- .../schemas/v2/unsafe-local-workloads.md | 20 +- docs/reference/schemas/v2/wire-protocol.json | 37 + docs/reference/schemas/v2/wire-protocol.md | 6 +- docs/reference/unsafe-local-provider.md | 31 +- nixos-modules/assertions.nix | 83 +- nixos-modules/bundle.nix | 2 +- .../dashboards/01-d2b-overview.json | 40 + nixos-modules/unsafe-local-workloads-json.nix | 44 +- packages/Cargo.lock | 1 + .../tests/realm_workload_schema_contract.rs | 13 + .../tests/workload_observability_contract.rs | 23 + packages/d2b-contracts/src/cli_output.rs | 10 + packages/d2b-contracts/src/public_wire.rs | 5 + packages/d2b-core/src/bundle.rs | 6 +- .../d2b-core/src/unsafe_local_workloads.rs | 216 ++- .../d2b-core/tests/bundle_resolver_tamper.rs | 4 +- packages/d2b-guestd/src/detached_registry.rs | 357 ++++- packages/d2b-guestd/src/service.rs | 24 +- packages/d2b-unsafe-local-helper/Cargo.toml | 1 + .../d2b-unsafe-local-helper/src/protocol.rs | 2 + .../d2b-unsafe-local-helper/src/runtime.rs | 335 ++++- packages/d2b/src/lib.rs | 405 +++++- packages/d2b/tests/launch_contract.rs | 34 + packages/d2bd/src/daemon_audit.rs | 76 + packages/d2bd/src/exec_detached.rs | 20 + packages/d2bd/src/exec_session.rs | 15 +- packages/d2bd/src/exec_session_real.rs | 15 +- packages/d2bd/src/lib.rs | 1229 ++++++++++++++++- packages/d2bd/src/metrics.rs | 85 +- packages/d2bd/src/typed_error.rs | 151 ++ packages/d2bd/src/unsafe_local_helper.rs | 475 ++++++- packages/d2bd/src/wire.rs | 35 + packages/d2bd/src/workload_dispatch.rs | 684 +++++++++ packages/xtask/src/main.rs | 33 +- tests/unit/nix/cases/realm-workloads.nix | 224 ++- tests/unit/nix/pinned/common.txt | 7 +- 54 files changed, 4985 insertions(+), 386 deletions(-) create mode 100644 docs/manpages/d2b-launch.1 create mode 100644 docs/reference/cli-output/launch.schema.json create mode 100644 packages/d2b-contract-tests/tests/workload_observability_contract.rs create mode 100644 packages/d2b/tests/launch_contract.rs create mode 100644 packages/d2bd/src/workload_dispatch.rs diff --git a/AGENTS.md b/AGENTS.md index 196e8ddff..809014b38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -954,13 +954,13 @@ 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 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. Bundle version 11 extends that private artifact with local-VM configured items/argv; local-VM dispatch uses `legacyVmName` when present and otherwise the first-class workload id. 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. | | 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. | | 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 d956e72e6..fda34864a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,19 @@ deprecations ship one minor release before removal. Entra and YubiKey credential placement, and policy-authorized peer shortcuts over inherited shared relay fabrics for nested realms. +- Added feature-negotiated provider-neutral workload list, status, and configured + launch dispatch. `d2b launch` resolves targets and default/sole items from + argv-free public metadata, while `d2bd` resolves execution solely from the + bundle-hashed private item contract. Local VMs launch through authenticated + guest-control as the workload user; unsafe-local launches require an exact + same-UID helper. Unsupported providers, remote access, missing prerequisites, + and every fallback path fail with typed remediation. New bounded audit and + low-cardinality dashboard signals expose provider readiness and launch results + without command, environment, path, process, or unit details. Bundle format 11 + extends the private configured-item artifact to local VM workloads. + First-class local-VM workloads use their workload id as the backing VM name + when no transition-only `legacyVmName` is configured. + - 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 @@ -44,8 +57,8 @@ deprecations ship one minor release before removal. 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. + Runtime dispatch is feature-gated and available through the daemon workload + operation family. - Added ADR 0044 for an explicit `unsafe-local` runtime provider that treats host user/session/network workloads as first-class realm workloads for @@ -118,6 +131,13 @@ deprecations ship one minor release before removal. ### Fixed +- Fixed configured-launch retry behavior across failure and restart boundaries. + Unsafe-local helper timeouts release their active operation reservation, and + local-VM launches use a deterministic opaque guest exec id backed by guestd's + durable detached record so daemon restart retries cannot spawn duplicates. + Audit records now correlate the authenticated peer, public operation id, and + local-VM detached exec without recording execution details. + - 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 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/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..ee3f631af 100644 --- a/docs/reference/cli-contract.md +++ b/docs/reference/cli-contract.md @@ -52,34 +52,98 @@ 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; unsafe-local shell execution remains unavailable until its dedicated +backend exists. 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`. Unsupported peers +return a typed capability refusal 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 unsafe-local shell requested. | +| `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 unsafe-local shell requested. | 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]` 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..33f38e041 100644 --- a/docs/reference/daemon-api.md +++ b/docs/reference/daemon-api.md @@ -92,6 +92,26 @@ compatibility surface. `/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. + +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 +199,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#L3690) | empty struct | +| `UsbSecurityKeySessionsRequest` | struct | [`UsbSecurityKeySessionsRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3776) | empty struct | +| `UsbSecurityKeyCancelRequest` | struct | [`UsbSecurityKeyCancelRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3830) | struct { `session_id`: `Option`; `current`: `bool` } | +| `UsbSecurityKeyTestRequest` | struct | [`UsbSecurityKeyTestRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3855) | struct { `vm`: `String` } | ### Broker socket request types @@ -305,28 +325,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#L3761) | struct { `host_proxy_enabled`: `bool`; `physical_keys`: `Vec`; `vm_devices`: `Vec`; `lease`: `UsbSkLeaseStatus` } | +| `UsbSecurityKeySessionsResponse` | struct | [`UsbSecurityKeySessionsResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3823) | struct { `sessions`: `Vec` } | +| `UsbSecurityKeyCancelResponse` | struct | [`UsbSecurityKeyCancelResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3841) | struct { `cancelled_session_id`: `Option`; `already_idle`: `bool` } | +| `UsbSecurityKeyTestResponse` | struct | [`UsbSecurityKeyTestResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3878) | struct { `vm`: `String`; `ok`: `bool`; `checks`: `Vec` } | ### Broker socket response types @@ -430,7 +450,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 @@ -483,41 +503,41 @@ running live guest activation. | `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` | +| `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#L3728) | `Idle`; `Active`; `Queued`; `Unknown` | +| `UsbSkSessionOutcome` | enum | [`UsbSkSessionOutcome`](../../packages/d2b-contracts/src/public_wire.rs#L3783) | `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` } | @@ -589,9 +609,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` } | diff --git a/docs/reference/daemon-metrics.md b/docs/reference/daemon-metrics.md index 530513f62..7fb823bd6 100644 --- a/docs/reference/daemon-metrics.md +++ b/docs/reference/daemon-metrics.md @@ -216,6 +216,27 @@ declared schema; see "Cardinality bounds" below. provider credentials, process environments, working directories, helper diagnostics, and terminal bytes are never metric labels. +### `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 +247,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 | 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/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/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..989aa2893 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 is feature-negotiated; +unsafe-local persistent shells remain unavailable until their dedicated backend +is enabled. ## Nix options @@ -91,12 +92,30 @@ The closed unsafe-local posture is: feature flags. Clients must hide or refuse unsupported operations; they must never fall back to unsafe-local. +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 `/run/d2b/unsafe-local-helper.sock` `SOCK_SEQPACKET` endpoint. Peer credentials, 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..e2a360003 100644 --- a/nixos-modules/components/observability/dashboards/01-d2b-overview.json +++ b/nixos-modules/components/observability/dashboards/01-d2b-overview.json @@ -169,6 +169,46 @@ "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}}" + } + ] } ] } 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..a71e0b71f 100644 --- a/packages/Cargo.lock +++ b/packages/Cargo.lock @@ -1155,6 +1155,7 @@ dependencies = [ "nix 0.29.0", "serde", "serde_json", + "sha2", "socket2 0.5.10", "uzers", "zbus", 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..8aadc7f9c 100644 --- a/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs +++ b/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs @@ -424,6 +424,19 @@ 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!(16) + ); + 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\"")); } 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..a64db3e11 --- /dev/null +++ b/packages/d2b-contract-tests/tests/workload_observability_contract.rs @@ -0,0 +1,23 @@ +#[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}"); + } +} 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/public_wire.rs b/packages/d2b-contracts/src/public_wire.rs index d7f83dc59..a8078a00c 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), 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/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-unsafe-local-helper/Cargo.toml b/packages/d2b-unsafe-local-helper/Cargo.toml index 960ebb770..577f55885 100644 --- a/packages/d2b-unsafe-local-helper/Cargo.toml +++ b/packages/d2b-unsafe-local-helper/Cargo.toml @@ -18,6 +18,7 @@ getrandom = "0.2" nix = { version = "0.29", features = ["fs", "poll", "socket", "uio", "user", "signal", "process"] } 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/protocol.rs b/packages/d2b-unsafe-local-helper/src/protocol.rs index 61614b389..dac28eb3b 100644 --- a/packages/d2b-unsafe-local-helper/src/protocol.rs +++ b/packages/d2b-unsafe-local-helper/src/protocol.rs @@ -420,6 +420,8 @@ fn failure_code(error: RuntimeError) -> HelperFailureCode { RuntimeError::ProxyUnavailable => HelperFailureCode::ProxyUnavailable, RuntimeError::ScopeCreateFailed => HelperFailureCode::ScopeCreateFailed, RuntimeError::ScopeIdentityMismatch => HelperFailureCode::ScopeIdentityMismatch, + RuntimeError::OperationIdConflict => HelperFailureCode::OperationIdConflict, + RuntimeError::OperationInProgress => HelperFailureCode::QueueFull, RuntimeError::Timeout => HelperFailureCode::Timeout, RuntimeError::Internal => HelperFailureCode::Internal, } diff --git a/packages/d2b-unsafe-local-helper/src/runtime.rs b/packages/d2b-unsafe-local-helper/src/runtime.rs index 83b3ee8de..31579c374 100644 --- a/packages/d2b-unsafe-local-helper/src/runtime.rs +++ b/packages/d2b-unsafe-local-helper/src/runtime.rs @@ -8,7 +8,8 @@ use d2b_core::workload_identity::WorkloadIdentity; use d2b_realm_core::ids::OperationId; use nix::libc; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashSet}; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::{Read, Write}; @@ -33,6 +34,8 @@ pub enum RuntimeError { ProxyUnavailable, ScopeCreateFailed, ScopeIdentityMismatch, + OperationIdConflict, + OperationInProgress, Timeout, LedgerInvalid, Internal, @@ -67,6 +70,8 @@ impl From for RuntimeError { #[serde(rename_all = "camelCase", deny_unknown_fields)] struct PersistedScope { operation_id: OperationId, + #[serde(default)] + fingerprint: Option<[u8; 32]>, workload: WorkloadIdentity, unit_name: String, invocation_id: String, @@ -117,10 +122,104 @@ impl fmt::Debug for PersistedScopeLedger { pub struct ScopeRuntime { manager: Arc, ledger_path: PathBuf, - ledger: Mutex, + ledger: Mutex, user_home: PathBuf, } +struct RuntimeLedger { + persisted: PersistedScopeLedger, + reservations: BTreeMap, + next_owner: u64, +} + +impl Default for RuntimeLedger { + fn default() -> Self { + Self::from_persisted(PersistedScopeLedger { + schema_version: 1, + scopes: Vec::new(), + }) + } +} + +#[derive(Clone, Copy)] +struct LaunchReservation { + fingerprint: [u8; 32], + owner: u64, +} + +enum LaunchBegin { + Started(LaunchReservation), + AlreadyCommitted(Box), +} + +impl RuntimeLedger { + fn from_persisted(persisted: PersistedScopeLedger) -> Self { + Self { + persisted, + reservations: 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()); + } + } +} + impl fmt::Debug for ScopeRuntime { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ScopeRuntime") @@ -150,7 +249,7 @@ impl ScopeRuntime { user_home: PathBuf, ledger_path: PathBuf, ) -> Result { - let ledger = load_ledger(&ledger_path)?; + let ledger = RuntimeLedger::from_persisted(load_ledger(&ledger_path)?); Ok(Self { manager: Arc::new(manager), ledger_path, @@ -162,6 +261,39 @@ impl ScopeRuntime { pub fn launch( &self, request: HelperLaunchRequest, + ) -> Result { + 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()?; let argv = request.argv.as_slice(); @@ -187,36 +319,21 @@ 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, }; - 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,26 +346,37 @@ impl ScopeRuntime { }) } + 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 stop_failed_scope(&self, scope: &VerifiedScope) { let _ = self.manager.terminate_scope(scope, libc::SIGKILL); let _ = self.manager.stop_scope(scope); } - 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 fn snapshot(&self, generation: u64) -> Result { let entries = self .ledger .lock() .map_err(|_| RuntimeError::Internal)? + .persisted .scopes .clone(); if entries.len() > MAX_HELPER_SNAPSHOT_SCOPES { @@ -282,6 +410,17 @@ impl ScopeRuntime { } } +fn launch_fingerprint(request: &HelperLaunchRequest) -> Result<[u8; 32], RuntimeError> { + let encoded = serde_json::to_vec(&( + &request.workload, + &request.item_id, + &request.argv, + request.graphical, + )) + .map_err(|_| RuntimeError::Internal)?; + Ok(Sha256::digest(encoded).into()) +} + #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct SupervisorSpec { @@ -456,7 +595,15 @@ 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::>(); + if ledger.schema_version != 1 + || ledger.scopes.len() > MAX_HELPER_SNAPSHOT_SCOPES + || unique_operations.len() != ledger.scopes.len() + { return Err(RuntimeError::LedgerInvalid); } Ok(ledger) @@ -493,13 +640,135 @@ 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_realm_core::token::ProtocolToken; + use std::sync::{Arc, Barrier}; + + 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, + } + } + + #[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, + }); + 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", diff --git a/packages/d2b/src/lib.rs b/packages/d2b/src/lib.rs index acae36f8c..4e08683f6 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,10 @@ 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(), ] } @@ -2472,6 +2501,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 +3587,378 @@ fn cmd_config_status(args: &ConfigStatusArgs) -> Result { Ok(0) } +fn cmd_launch(context: &Context, args: &LaunchArgs) -> Result { + use d2b_realm_core::{LauncherItemKind, ProtocolToken, WorkloadProviderKind}; + + 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 { + if workload.provider_kind == WorkloadProviderKind::UnsafeLocal { + return Err(CliFailure::new( + 70, + "unsafe-local persistent shell launch is unavailable; no host-shell fallback is permitted", + )); + } + let vm = local_vm_shell_target(&workload).to_owned(); + 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 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 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(), + } + } + + 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")); + } +} + +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 +10360,7 @@ fn all_known_subcommands() -> Vec { vec![ "list", "status", + "launch", "audit", "host check", "auth status", 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..6b2677400 100644 --- a/packages/d2bd/src/daemon_audit.rs +++ b/packages/d2bd/src/daemon_audit.rs @@ -121,6 +121,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 { @@ -269,6 +281,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 @@ -1979,4 +2007,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..1dbd3f920 100644 --- a/packages/d2bd/src/lib.rs +++ b/packages/d2bd/src/lib.rs @@ -120,6 +120,7 @@ pub mod terminal_session; pub mod typed_error; pub mod unsafe_local_helper; pub mod wire; +mod workload_dispatch; pub mod workload_target_index; use admission::{ PeerIdentity, PeerRole, authorize_peer, gateway_display_op_requires_admin, @@ -2935,11 +2936,22 @@ 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(), ]; + 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 +2978,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 @@ -3164,6 +3190,8 @@ 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(_)) ) } @@ -3227,6 +3255,7 @@ fn dispatch_request_locked( 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) @@ -3240,6 +3269,1114 @@ fn dispatch_request_locked( } } +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}; + static REQUEST_ID: AtomicU64 = AtomicU64::new(1); + let request = HelperLaunchRequest { + request_id: REQUEST_ID.fetch_add(1, Ordering::Relaxed), + operation_id: operation_id.clone(), + workload: resolved.identity.clone(), + item_id: resolved.item_id.clone(), + argv: resolved.argv.clone(), + graphical: resolved.graphical, + }; + 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 => Kind::ConfiguredItemMismatch, + 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(); + } + 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}")); + } + } +} + fn dispatch_console( state: &ServerState, peer: &PeerIdentity, @@ -8223,6 +9360,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 +21761,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 +21818,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 +22396,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 +22467,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}; diff --git a/packages/d2bd/src/metrics.rs b/packages/d2bd/src/metrics.rs index 5cbaa3313..092595d1e 100644 --- a/packages/d2bd/src/metrics.rs +++ b/packages/d2bd/src/metrics.rs @@ -167,6 +167,18 @@ pub const METRIC_INVENTORY: &[MetricDescriptor] = &[ labels: &["subsystem", "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 +269,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 +445,12 @@ 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_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 +608,8 @@ mod tests { "d2b_daemon_uptime_seconds", "d2b_daemon_guest_control_exec_total", "d2b_daemon_guest_control_shell_total", + "d2b_daemon_workload_availability", + "d2b_daemon_workload_lifecycle_total", ] ); } @@ -668,6 +714,9 @@ mod tests { "reason", "subsystem", "error_kind", + "provider", + "component", + "operation", ]; // Populate one sample of EVERY inventory metric so render() emits @@ -724,7 +773,6 @@ mod tests { "environment", "cwd", "current_working_directory", - "provider", "stream", "stream_id", "terminal_stream_id", @@ -750,6 +798,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/typed_error.rs b/packages/d2bd/src/typed_error.rs index 61c622f1f..89cf9f648 100644 --- a/packages/d2bd/src/typed_error.rs +++ b/packages/d2bd/src/typed_error.rs @@ -251,6 +251,122 @@ pub enum GuestControlShellErrorKind { 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 { @@ -543,6 +659,9 @@ pub enum TypedError { GuestControlShellFailed { kind: GuestControlShellErrorKind, }, + 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 +781,7 @@ impl TypedError { Self::GuestControlReadFailed { kind } => kind.wire_kind(), Self::GuestControlExecFailed { kind } => kind.wire_kind(), Self::GuestControlShellFailed { 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 +842,7 @@ impl TypedError { Self::GuestControlReadFailed { .. } => 70, Self::GuestControlExecFailed { kind } => kind.exit_code(), Self::GuestControlShellFailed { 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 +998,7 @@ 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::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 +1168,7 @@ impl TypedError { Self::GuestControlReadFailed { kind } => kind.remediation().to_owned(), Self::GuestControlExecFailed { kind } => kind.remediation().to_owned(), Self::GuestControlShellFailed { 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 +1380,7 @@ impl TypedError { | Self::GuestControlReadFailed { .. } | Self::GuestControlExecFailed { .. } | Self::GuestControlShellFailed { .. } + | Self::WorkloadLaunchFailed { .. } | Self::DaemonBusy | Self::ConsoleVmNotFound { .. } | Self::ConsoleNotRunning { .. } @@ -1360,6 +1484,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 { diff --git a/packages/d2bd/src/unsafe_local_helper.rs b/packages/d2bd/src/unsafe_local_helper.rs index 16cf90c3e..94ebac049 100644 --- a/packages/d2bd/src/unsafe_local_helper.rs +++ b/packages/d2bd/src/unsafe_local_helper.rs @@ -36,6 +36,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 { @@ -59,6 +61,13 @@ pub enum HelperRegistryError { Io, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HelperAvailability { + Ready, + Unavailable, + Stale, +} + pub enum HelperReply { Operation(HelperOperationResult), Rejected(HelperOperationRejected), @@ -87,12 +96,18 @@ struct PendingRequest { sender: mpsc::SyncSender>, } +struct AbandonedRequest { + operation_id: String, + 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 +153,47 @@ 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(), + 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 +268,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 +301,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 +336,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); @@ -302,6 +382,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 +395,30 @@ 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(Err(error)) => Err(error), - Err(_) => Err(HelperRegistryError::Timeout), + 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) + } } } @@ -387,6 +488,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 +551,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 +587,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 +608,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 +623,40 @@ impl HelperRegistry { } UnsafeLocalHelperToDaemon::Operation(result) => { reject_unexpected_fds(fds)?; - complete_pending( + let delivered = complete_pending( connection, result.request_id, result.operation_id.to_string(), - HelperReply::Operation(result), - ) + HelperReply::Operation(result.clone()), + )?; + if !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 delivered = complete_pending( connection, rejected.request_id, rejected.operation_id.to_string(), - HelperReply::Rejected(rejected), - ) + HelperReply::Rejected(rejected.clone()), + )?; + if !delivered { + 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)?; @@ -543,6 +666,7 @@ impl HelperRegistry { ready.operation_id.to_string(), HelperReply::Terminal { ready, fd }, ) + .map(|_| ()) } UnsafeLocalHelperToDaemon::Hello(_) | UnsafeLocalHelperToDaemon::Snapshot(_) => { reject_unexpected_fds(fds)?; @@ -557,9 +681,14 @@ fn complete_pending( request_id: u64, operation_id: String, reply: HelperReply, -) -> Result<(), HelperRegistryError> { - let Some(pending) = connection.pending.lock().remove(&request_id) else { - return Err(HelperRegistryError::RequestCorrelationMismatch); +) -> 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 => Ok(false), + _ => Err(HelperRegistryError::RequestCorrelationMismatch), + }; }; if pending.operation_id != operation_id { let _ = pending @@ -567,10 +696,7 @@ fn complete_pending( .try_send(Err(HelperRegistryError::RequestCorrelationMismatch)); return Err(HelperRegistryError::RequestCorrelationMismatch); } - pending - .sender - .try_send(Ok(reply)) - .map_err(|_| HelperRegistryError::RequestCorrelationMismatch) + Ok(pending.sender.try_send(Ok(reply)).is_ok()) } pub fn bind_helper_socket( @@ -646,7 +772,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 +788,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 +806,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 +815,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 +896,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 +957,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 +973,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 +1021,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 +1065,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); } @@ -951,7 +1097,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 +1120,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 +1132,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)), @@ -1116,17 +1264,8 @@ mod tests { } #[test] - fn operation_ledger_never_reaps_active_entries() { + fn operation_ledger_keeps_unexpired_active_entries() { let mut ledger = OperationLedger::default(); - let active = launch(1, "active-op", "program"); - ledger - .begin( - 1000, - "active-op".to_owned(), - launch_fingerprint(&active).unwrap(), - 0, - ) - .unwrap(); 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 +1279,142 @@ 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(), + 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(); @@ -1261,7 +1522,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 +1570,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 +1599,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 +1608,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 +1636,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(), @@ -1366,7 +1648,7 @@ mod tests { kind: d2b_contracts::unsafe_local_wire::HelperScopeKind::PersistentShell, }, }; - 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 +1661,58 @@ 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, + }, + }; + + 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 = unistd::dup(stream.as_raw_fd()).unwrap(); + let second = unistd::dup(stream.as_raw_fd()).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 +1734,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/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..4fafdd033 --- /dev/null +++ b/packages/d2bd/src/workload_dispatch.rs @@ -0,0 +1,684 @@ +use std::{ + collections::BTreeMap, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, +}; + +use d2b_contracts::public_wire::{ + GraphicalLaunchPosture, WorkloadAvailability, WorkloadPublicSummary, +}; +use d2b_core::{ + bundle_resolver::BundleResolver, + configured_argv::ConfiguredArgv, + realm_controller_config::RealmControllerPlacement, + realm_workloads_launcher::LauncherWorkloadSummary, + unsafe_local_workloads::{UnsafeLocalLauncherItem, 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, + 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, +} + +#[derive(Debug, Clone)] +pub(crate) struct WorkloadCatalog { + entries: BTreeMap, +} + +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(); + for metadata in &public.workloads { + if !realm_is_direct_local(resolver, &metadata.identity) { + continue; + } + let canonical = metadata.identity.canonical_target.to_canonical(); + let route = route_for_provider( + metadata.provider_kind, + metadata + .identity + .legacy_vm_name + .as_ref() + .map(|vm| vm.as_str()), + metadata.identity.workload_id.as_str(), + ); + entries.insert( + canonical, + CatalogEntry { + metadata: metadata.clone(), + route, + }, + ); + } + Ok(Self { entries }) + } + + pub(crate) fn entries(&self) -> impl Iterator { + self.entries.values() + } + + #[cfg(test)] + pub(crate) fn from_test_entries(entries: impl IntoIterator) -> Self { + Self { + entries: entries + .into_iter() + .map(|entry| { + ( + entry.metadata.identity.canonical_target.to_canonical(), + entry, + ) + }) + .collect(), + } + } + + 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, + }) + } +} + +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, + 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(), + } + } + + #[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!( + 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 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 + ); + } +} 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/tests/unit/nix/cases/realm-workloads.nix b/tests/unit/nix/cases/realm-workloads.nix index 1118aa71f..cadde2550 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 = @@ -979,7 +1196,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 +1212,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 +1227,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 From d7a6a66d258f9d5d9138e6ad25451d5abd325a86 Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:24:46 -0700 Subject: [PATCH 03/14] contracts: define unsafe-local persistent shell wire v2 (#293) * contracts: define unsafe-local persistent shell wire v2 ( W3 ) Complete the install-together helper contract before runtime dispatch lands. Keep public protocol v3 and terminal protocol v1 while making management correlation, terminal framing, restart snapshots, and skew rejection explicit and bounded. * contracts: defer unsafe-local shell advertisement ( W3 ) Let clients recognize the additive feature token while keeping daemon negotiation fail-closed until the stacked runtime dispatch slice lands. --------- Co-authored-by: John Vicondoa --- CHANGELOG.md | 5 + docs/reference/daemon-api.md | 26 +- .../schemas/v2/unsafe-local-helper-wire.json | 1339 +++++++++++++++-- .../schemas/v2/unsafe-local-helper-wire.md | 26 +- docs/reference/unsafe-local-provider.md | 25 +- .../tests/realm_workload_schema_contract.rs | 66 + packages/d2b-contracts/src/lib.rs | 14 + .../d2b-contracts/src/unsafe_local_wire.rs | 1164 +++++++++++++- .../d2b-unsafe-local-helper/src/protocol.rs | 11 +- .../d2b-unsafe-local-helper/src/runtime.rs | 1 + packages/d2b/src/lib.rs | 1 + packages/d2bd/src/unsafe_local_helper.rs | 19 +- 12 files changed, 2503 insertions(+), 194 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fda34864a..7df09f3ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ deprecations ship one minor release before removal. ### Added +- Prepared unsafe-local persistent shells with private helper protocol v2, + correlated management results, bounded dedicated terminal-stream frames, + restart snapshot metadata, typed shell failures, and the public + `unsafe-local-shell-v1` feature token. Runtime dispatch remains unavailable. + - Added proposed ADR 0045, defining parent-owned workload-hosted realm controllers, explicit runtime/infrastructure/relay provider responsibilities, type-first sortable provider crate names with mandatory standard interfaces diff --git a/docs/reference/daemon-api.md b/docs/reference/daemon-api.md index 33f38e041..754a01201 100644 --- a/docs/reference/daemon-api.md +++ b/docs/reference/daemon-api.md @@ -77,15 +77,15 @@ 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 @@ -502,7 +502,7 @@ 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` | +| `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` | @@ -544,13 +544,13 @@ running live guest activation. | `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#L123) | `LauncherApp`; `WaylandProxy`; `PersistentShell` | +| `HelperScopeState` | enum | [`HelperScopeState`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L147) | `Starting`; `Active`; `Stopping`; `Exited`; `Degraded` | +| `HelperFailureCode` | enum | [`HelperFailureCode`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L402) | `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#L428) | `Committed`; `AlreadyCommitted`; `Completed` | +| `HelperTerminalTransport` | enum | [`HelperTerminalTransport`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L471) | `ConnectedUnixStream` | +| `DaemonToUnsafeLocalHelper` | enum | [`DaemonToUnsafeLocalHelper`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L873) | `HelloAccepted` — (HelperHelloAccepted); `Heartbeat` — (HelperHeartbeat); `Launch` — (HelperLaunchRequest); `Shell` — (HelperShellRequest) | +| `UnsafeLocalHelperToDaemon` | enum | [`UnsafeLocalHelperToDaemon`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L882) | `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` | diff --git a/docs/reference/schemas/v2/unsafe-local-helper-wire.json b/docs/reference/schemas/v2/unsafe-local-helper-wire.json index be8bb6d1e..0ddd823b3 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" ] }, @@ -309,6 +323,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 +367,16 @@ "operationId": { "$ref": "#/definitions/OperationId" }, + "persistentShell": { + "anyOf": [ + { + "$ref": "#/definitions/HelperPersistentShellSnapshot" + }, + { + "type": "null" + } + ] + }, "scope": { "$ref": "#/definitions/ScopeIdentity" }, @@ -351,6 +399,92 @@ "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 + }, "HelperShellRequest": { "oneOf": [ { @@ -363,11 +497,15 @@ "args": { "type": "object", "required": [ - "request_id", + "operationId", + "requestId", "workload" ], "properties": { - "request_id": { + "operationId": { + "$ref": "#/definitions/OperationId" + }, + "requestId": { "type": "integer", "format": "uint64", "minimum": 0.0 @@ -375,7 +513,8 @@ "workload": { "$ref": "#/definitions/WorkloadIdentity" } - } + }, + "additionalProperties": false }, "op": { "type": "string", @@ -383,7 +522,8 @@ "list" ] } - } + }, + "additionalProperties": false }, { "type": "object", @@ -395,12 +535,38 @@ "args": { "type": "object", "required": [ - "operation_id", - "request_id", - "tty", + "initialTerminalSize", + "operationId", + "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 +577,19 @@ } ] }, - "operation_id": { + "operationId": { "$ref": "#/definitions/OperationId" }, - "request_id": { + "requestId": { "type": "integer", "format": "uint64", "minimum": 0.0 }, - "tty": { - "type": "boolean" - }, "workload": { "$ref": "#/definitions/WorkloadIdentity" } - } + }, + "additionalProperties": false }, "op": { "type": "string", @@ -433,7 +597,8 @@ "attach" ] } - } + }, + "additionalProperties": false }, { "type": "object", @@ -446,18 +611,18 @@ "type": "object", "required": [ "name", - "operation_id", - "request_id", + "operationId", + "requestId", "workload" ], "properties": { "name": { "$ref": "#/definitions/ShellName" }, - "operation_id": { + "operationId": { "$ref": "#/definitions/OperationId" }, - "request_id": { + "requestId": { "type": "integer", "format": "uint64", "minimum": 0.0 @@ -465,7 +630,8 @@ "workload": { "$ref": "#/definitions/WorkloadIdentity" } - } + }, + "additionalProperties": false }, "op": { "type": "string", @@ -473,7 +639,8 @@ "detach" ] } - } + }, + "additionalProperties": false }, { "type": "object", @@ -486,18 +653,18 @@ "type": "object", "required": [ "name", - "operation_id", - "request_id", + "operationId", + "requestId", "workload" ], "properties": { "name": { "$ref": "#/definitions/ShellName" }, - "operation_id": { + "operationId": { "$ref": "#/definitions/OperationId" }, - "request_id": { + "requestId": { "type": "integer", "format": "uint64", "minimum": 0.0 @@ -505,7 +672,8 @@ "workload": { "$ref": "#/definitions/WorkloadIdentity" } - } + }, + "additionalProperties": false }, "op": { "type": "string", @@ -513,7 +681,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,103 +768,994 @@ }, "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" + "cause": { + "anyOf": [ + { + "$ref": "#/definitions/ShellCloseCause" + }, + { + "type": "null" + } + ] }, - "requestId": { + "detached": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "HelperTerminalChunkBase64": { + "description": "Bounded standard padded base64 terminal bytes.", + "type": "string", + "maxLength": 87384, + "minLength": 0, + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + }, + "HelperTerminalControl": { + "type": "object", + "required": [ + "controlSequence", + "requestId" + ], + "properties": { + "controlSequence": { "type": "integer", "format": "uint64", "minimum": 0.0 }, - "scope": { - "$ref": "#/definitions/ScopeIdentity" - }, - "terminalProtocolVersion": { + "requestId": { "type": "integer", - "format": "uint32", + "format": "uint64", "minimum": 0.0 - }, - "transport": { - "$ref": "#/definitions/HelperTerminalTransport" } }, "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": { - "type": "string", - "maxLength": 388, - "minLength": 1, - "pattern": "^[a-z][a-z0-9-]*(\\.[a-z][a-z0-9-]*)+\\.d2b$" - }, - "ScopeIdentity": { + "HelperTerminalControlResponse_for_HelperTerminalAttachmentClosed": { "type": "object", "required": [ - "invocationId", - "kind" + "controlSequence", + "requestId", + "result" ], "properties": { - "invocationId": { - "type": "string" + "controlSequence": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 }, - "kind": { - "$ref": "#/definitions/HelperScopeKind" + "requestId": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "result": { + "$ref": "#/definitions/HelperTerminalAttachmentClosed" } }, "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": [ - { - "type": "object", - "required": [ - "payload", + "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 + }, + "UnsafeLocalHelperToDaemon": { + "oneOf": [ + { + "type": "object", + "required": [ + "payload", "type" ], "properties": { @@ -721,6 +1842,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..0a86bb2ac 100644 --- a/docs/reference/schemas/v2/unsafe-local-helper-wire.md +++ b/docs/reference/schemas/v2/unsafe-local-helper-wire.md @@ -2,14 +2,30 @@ 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. 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. +- These are contract definitions only. Unsafe-local persistent-shell runtime + dispatch remains unavailable until its daemon and helper backend lands. diff --git a/docs/reference/unsafe-local-provider.md b/docs/reference/unsafe-local-provider.md index 989aa2893..73f067511 100644 --- a/docs/reference/unsafe-local-provider.md +++ b/docs/reference/unsafe-local-provider.md @@ -88,9 +88,11 @@ 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. Clients may +recognize the shell token with this contract revision, but `d2bd` does not +advertise it until runtime dispatch is enabled. Clients must hide or refuse +unsupported operations; they must never fall back to unsafe-local. Availability values are directly actionable: `helper-unavailable` and `helper-stale` require restarting the caller's user helper; @@ -117,10 +119,12 @@ host-local Unix binding. The helper lookup is keyed by the requester's 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 2 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. Version 1 is 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 @@ -159,7 +163,14 @@ 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 8 MiB, and waits to 1000 ms. +Terminal bytes do not share the helper control queue. This contract does not +make unsafe-local persistent-shell runtime dispatch available yet. 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 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 8aadc7f9c..1a9d00640 100644 --- a/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs +++ b/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs @@ -441,6 +441,72 @@ fn generated_unsafe_local_schemas_are_closed_and_argv_is_private() { 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"] { + assert!( + shell_request.contains(field), + "helper shell request schema must use wire field {field}" + ); + } + 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-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/unsafe_local_wire.rs b/packages/d2b-contracts/src/unsafe_local_wire.rs index 4ab5bff0d..8f99ba177 100644 --- a/packages/d2b-contracts/src/unsafe_local_wire.rs +++ b/packages/d2b-contracts/src/unsafe_local_wire.rs @@ -3,18 +3,48 @@ //! 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 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, 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 = 2; 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 +53,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 +152,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 +236,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)] @@ -110,28 +259,49 @@ pub struct HelperLaunchRequest { } #[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, }, Attach { + #[schemars(rename = "requestId")] request_id: u64, + #[schemars(rename = "operationId")] operation_id: OperationId, workload: WorkloadIdentity, #[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, name: ShellName, }, Kill { + #[schemars(rename = "requestId")] request_id: u64, + #[schemars(rename = "operationId")] operation_id: OperationId, workload: WorkloadIdentity, name: ShellName, @@ -143,10 +313,12 @@ impl fmt::Debug for HelperShellRequest { match self { Self::List { request_id, + operation_id, workload, } => f .debug_struct("HelperShellRequest::List") .field("request_id", request_id) + .field("operation_id", operation_id) .field("workload", workload) .finish(), Self::Attach { @@ -154,14 +326,16 @@ impl fmt::Debug for HelperShellRequest { operation_id, workload, 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("name", &name.as_ref().map(|_| "")) - .field("tty", tty) + .field("force", force) + .field("initial_terminal_size", initial_terminal_size) .finish(), Self::Detach { request_id, @@ -191,6 +365,38 @@ impl fmt::Debug for HelperShellRequest { } } +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> { + 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 +414,12 @@ pub enum HelperFailureCode { ProxyUnavailable, FirstClientTimeout, ShellUnavailable, + ShellNotFound, + ShellAlreadyAttached, + TerminalOutputGap, + TerminalOffsetMismatch, + TerminalClosed, + InvalidTerminalSize, Internal, } @@ -229,7 +441,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 +449,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 +485,389 @@ 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 > 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 +885,7 @@ pub enum UnsafeLocalHelperToDaemon { Heartbeat(HelperHeartbeat), Operation(HelperOperationResult), TerminalReady(HelperTerminalReady), + Shell(HelperShellResponse), Rejected(HelperOperationRejected), } @@ -286,76 +896,498 @@ 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 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(), + }, + HelperShellRequest::Attach { + request_id: 2, + operation_id: operation("op-attach"), + workload: workload(), + 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(), + name: shell_name("primary"), + }, + HelperShellRequest::Kill { + request_id: 4, + operation_id: operation("op-kill"), + workload: workload(), + 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 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 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 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 helper_frames_reject_uid_environment_and_cwd_fields() { - let frame = r#"{ + 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} + "payload":{"protocolVersion":2,"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" - }"#; - 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, - }; - assert!(!format!("{scope:?}").contains(canary)); + 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 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) + ); } #[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 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(), + name: Some(shell_name(shell_canary)), + force: true, + initial_terminal_size: TerminalSize { rows: 24, cols: 80 }, }; - assert!(!format!("{request:?}").contains(canary)); + 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!("{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 helper_v1_is_rejected_while_terminal_v1_is_preserved() { + assert_eq!(UNSAFE_LOCAL_HELPER_PROTOCOL_VERSION, 2); + assert!(!unsafe_local_helper_protocol_supported(1)); + assert!(unsafe_local_helper_protocol_supported(2)); + assert_eq!(UNSAFE_LOCAL_TERMINAL_PROTOCOL_VERSION, 1); } #[test] @@ -368,21 +1400,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-unsafe-local-helper/src/protocol.rs b/packages/d2b-unsafe-local-helper/src/protocol.rs index dac28eb3b..d3d1192fd 100644 --- a/packages/d2b-unsafe-local-helper/src/protocol.rs +++ b/packages/d2b-unsafe-local-helper/src/protocol.rs @@ -6,6 +6,7 @@ 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; @@ -103,7 +104,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); @@ -432,8 +433,12 @@ fn shell_operation_identity( ) -> Option<(u64, OperationId)> { use d2b_contracts::unsafe_local_wire::HelperShellRequest; match request { - HelperShellRequest::List { .. } => None, - HelperShellRequest::Attach { + HelperShellRequest::List { + request_id, + operation_id, + .. + } + | HelperShellRequest::Attach { request_id, operation_id, .. diff --git a/packages/d2b-unsafe-local-helper/src/runtime.rs b/packages/d2b-unsafe-local-helper/src/runtime.rs index 31579c374..0949aa35f 100644 --- a/packages/d2b-unsafe-local-helper/src/runtime.rs +++ b/packages/d2b-unsafe-local-helper/src/runtime.rs @@ -404,6 +404,7 @@ impl ScopeRuntime { workload: entry.workload, scope, state, + persistent_shell: None, }); } Ok(HelperSnapshot { generation, scopes }) diff --git a/packages/d2b/src/lib.rs b/packages/d2b/src/lib.rs index 4e08683f6..0ff82ba2b 100644 --- a/packages/d2b/src/lib.rs +++ b/packages/d2b/src/lib.rs @@ -1598,6 +1598,7 @@ fn daemon_supported_features() -> Vec { KnownFeatureFlag::ExportBrokerAudit.wire_value(), KnownFeatureFlag::ConfiguredLaunchV1.wire_value(), KnownFeatureFlag::UnsafeLocalProviderV1.wire_value(), + KnownFeatureFlag::UnsafeLocalShellV1.wire_value(), ] } diff --git a/packages/d2bd/src/unsafe_local_helper.rs b/packages/d2bd/src/unsafe_local_helper.rs index 94ebac049..77f359efa 100644 --- a/packages/d2bd/src/unsafe_local_helper.rs +++ b/packages/d2bd/src/unsafe_local_helper.rs @@ -6,6 +6,7 @@ use d2b_contracts::unsafe_local_wire::{ 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}; @@ -444,7 +445,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( @@ -668,6 +670,10 @@ impl HelperRegistry { ) .map(|_| ()) } + UnsafeLocalHelperToDaemon::Shell(_) => { + reject_unexpected_fds(fds)?; + Err(HelperRegistryError::ProtocolMismatch) + } UnsafeLocalHelperToDaemon::Hello(_) | UnsafeLocalHelperToDaemon::Snapshot(_) => { reject_unexpected_fds(fds)?; Err(HelperRegistryError::ProtocolMismatch) @@ -1435,6 +1441,7 @@ mod tests { kind: d2b_contracts::unsafe_local_wire::HelperScopeKind::LauncherApp, }, state: d2b_contracts::unsafe_local_wire::HelperScopeState::Active, + persistent_shell: None, }], }, 2, @@ -1647,6 +1654,11 @@ 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![ReceivedFd(raw)]).unwrap(); let flags = fcntl(validated.as_raw_fd(), FcntlArg::F_GETFD).unwrap(); @@ -1689,6 +1701,11 @@ 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, + }, }; assert!(matches!( From 04d707afee683d80144a910dc06611d754570dd2 Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:42:31 -0700 Subject: [PATCH 04/14] unsafe-local: supervise persistent host shells (#294) * unsafe-local: supervise persistent host shells ( W3 ) Keep each PTY and login shell under a reconnectable same-UID supervisor so helper and daemon reconnects cannot destroy session ownership. Bound rings, workers, sockets, and descriptor transfer while preserving exact verified-scope teardown. * unsafe-local: serialize terminal control operations ( W3 ) Preserve wire order for input offsets and control sequences while leaving bounded read and wait observations concurrent. * ci: refresh helper stack checks ( W3 ) --------- Co-authored-by: John Vicondoa --- AGENTS.md | 2 +- CHANGELOG.md | 8 + docs/explanation/persistent-shells.md | 22 +- docs/reference/daemon-api.md | 14 +- .../schemas/v2/unsafe-local-helper-wire.json | 35 + .../schemas/v2/unsafe-local-helper-wire.md | 10 +- docs/reference/unsafe-local-provider.md | 43 +- packages/Cargo.lock | 1 + .../tests/realm_workload_schema_contract.rs | 21 +- .../d2b-contracts/src/unsafe_local_wire.rs | 103 +- packages/d2b-unsafe-local-helper/Cargo.toml | 1 + .../src/environment.rs | 9 + packages/d2b-unsafe-local-helper/src/lib.rs | 17 + packages/d2b-unsafe-local-helper/src/main.rs | 9 + .../src/output_ring.rs | 210 +++ .../d2b-unsafe-local-helper/src/protocol.rs | 298 +++- .../d2b-unsafe-local-helper/src/runtime.rs | 308 +++- .../src/shell_runtime.rs | 1450 +++++++++++++++++ .../src/shell_socket.rs | 266 +++ .../src/shell_supervisor.rs | 1185 ++++++++++++++ .../src/supervisor_protocol.rs | 181 ++ .../d2b-unsafe-local-helper/src/tty_exec.rs | 136 ++ .../tests/shell_supervisor.rs | 590 +++++++ 23 files changed, 4820 insertions(+), 99 deletions(-) create mode 100644 packages/d2b-unsafe-local-helper/src/output_ring.rs create mode 100644 packages/d2b-unsafe-local-helper/src/shell_runtime.rs create mode 100644 packages/d2b-unsafe-local-helper/src/shell_socket.rs create mode 100644 packages/d2b-unsafe-local-helper/src/shell_supervisor.rs create mode 100644 packages/d2b-unsafe-local-helper/src/supervisor_protocol.rs create mode 100644 packages/d2b-unsafe-local-helper/src/tty_exec.rs create mode 100644 packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs diff --git a/AGENTS.md b/AGENTS.md index 809014b38..27920cc72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -954,7 +954,7 @@ 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. Bundle version 11 extends that private artifact with local-VM configured items/argv; local-VM dispatch uses `legacyVmName` when present and otherwise the first-class workload id. 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). | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7df09f3ab..ca4ec2da7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ deprecations ship one minor release before removal. ### Added +- Added the unsafe-local persistent-shell helper runtime: a hidden verified + user-scope supervisor owns each login-shell PTY, private reconnect socket, + bounded merged-output ring, and single attachment across helper reconnects. + Terminal streams transfer as one CLOEXEC fd, shell metadata survives in the + existing user ledger, and exact-scope teardown cannot target unrelated + same-UID processes. Public daemon shell routing and CLI enablement remain + deferred. + - Prepared unsafe-local persistent shells with private helper protocol v2, correlated management results, bounded dedicated terminal-stream frames, restart snapshot metadata, typed shell failures, and the public diff --git a/docs/explanation/persistent-shells.md b/docs/explanation/persistent-shells.md index d7b74621f..3ee79d529 100644 --- a/docs/explanation/persistent-shells.md +++ b/docs/explanation/persistent-shells.md @@ -17,12 +17,17 @@ 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: @@ -48,10 +53,17 @@ 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. Public daemon routing for this backend remains +feature-gated until the daemon integration lands. + ## 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. diff --git a/docs/reference/daemon-api.md b/docs/reference/daemon-api.md index 754a01201..dff8dccb3 100644 --- a/docs/reference/daemon-api.md +++ b/docs/reference/daemon-api.md @@ -544,13 +544,13 @@ running live guest activation. | `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#L123) | `LauncherApp`; `WaylandProxy`; `PersistentShell` | -| `HelperScopeState` | enum | [`HelperScopeState`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L147) | `Starting`; `Active`; `Stopping`; `Exited`; `Degraded` | -| `HelperFailureCode` | enum | [`HelperFailureCode`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L402) | `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#L428) | `Committed`; `AlreadyCommitted`; `Completed` | -| `HelperTerminalTransport` | enum | [`HelperTerminalTransport`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L471) | `ConnectedUnixStream` | -| `DaemonToUnsafeLocalHelper` | enum | [`DaemonToUnsafeLocalHelper`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L873) | `HelloAccepted` — (HelperHelloAccepted); `Heartbeat` — (HelperHeartbeat); `Launch` — (HelperLaunchRequest); `Shell` — (HelperShellRequest) | -| `UnsafeLocalHelperToDaemon` | enum | [`UnsafeLocalHelperToDaemon`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L882) | `Hello` — (HelperHello); `Snapshot` — (HelperSnapshot); `Heartbeat` — (HelperHeartbeat); `Operation` — (HelperOperationResult); `TerminalReady` — (HelperTerminalReady); `Shell` — (HelperShellResponse); `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#L450) | `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#L476) | `Committed`; `AlreadyCommitted`; `Completed` | +| `HelperTerminalTransport` | enum | [`HelperTerminalTransport`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L519) | `ConnectedUnixStream` | +| `DaemonToUnsafeLocalHelper` | enum | [`DaemonToUnsafeLocalHelper`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L922) | `HelloAccepted` — (HelperHelloAccepted); `Heartbeat` — (HelperHeartbeat); `Launch` — (HelperLaunchRequest); `Shell` — (HelperShellRequest) | +| `UnsafeLocalHelperToDaemon` | enum | [`UnsafeLocalHelperToDaemon`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L931) | `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` | diff --git a/docs/reference/schemas/v2/unsafe-local-helper-wire.json b/docs/reference/schemas/v2/unsafe-local-helper-wire.json index 0ddd823b3..94f996a7f 100644 --- a/docs/reference/schemas/v2/unsafe-local-helper-wire.json +++ b/docs/reference/schemas/v2/unsafe-local-helper-wire.json @@ -485,6 +485,25 @@ }, "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": [ { @@ -498,6 +517,7 @@ "type": "object", "required": [ "operationId", + "policy", "requestId", "workload" ], @@ -505,6 +525,9 @@ "operationId": { "$ref": "#/definitions/OperationId" }, + "policy": { + "$ref": "#/definitions/HelperShellPolicy" + }, "requestId": { "type": "integer", "format": "uint64", @@ -537,6 +560,7 @@ "required": [ "initialTerminalSize", "operationId", + "policy", "requestId", "workload" ], @@ -580,6 +604,9 @@ "operationId": { "$ref": "#/definitions/OperationId" }, + "policy": { + "$ref": "#/definitions/HelperShellPolicy" + }, "requestId": { "type": "integer", "format": "uint64", @@ -612,6 +639,7 @@ "required": [ "name", "operationId", + "policy", "requestId", "workload" ], @@ -622,6 +650,9 @@ "operationId": { "$ref": "#/definitions/OperationId" }, + "policy": { + "$ref": "#/definitions/HelperShellPolicy" + }, "requestId": { "type": "integer", "format": "uint64", @@ -654,6 +685,7 @@ "required": [ "name", "operationId", + "policy", "requestId", "workload" ], @@ -664,6 +696,9 @@ "operationId": { "$ref": "#/definitions/OperationId" }, + "policy": { + "$ref": "#/definitions/HelperShellPolicy" + }, "requestId": { "type": "integer", "format": "uint64", diff --git a/docs/reference/schemas/v2/unsafe-local-helper-wire.md b/docs/reference/schemas/v2/unsafe-local-helper-wire.md index 0a86bb2ac..1cb266cb8 100644 --- a/docs/reference/schemas/v2/unsafe-local-helper-wire.md +++ b/docs/reference/schemas/v2/unsafe-local-helper-wire.md @@ -12,8 +12,10 @@ identity. The dedicated terminal stream remains terminal protocol version 1. - 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. List, detach, and kill results map directly to the public shell result - DTOs. + 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, @@ -27,5 +29,5 @@ identity. The dedicated terminal stream remains terminal protocol version 1. - Shell and terminal requests contain no uid, argv, environment, cwd, host path, transcript, PID, unit name, compositor data, or terminal session handle. -- These are contract definitions only. Unsafe-local persistent-shell runtime - dispatch remains unavailable until its daemon and helper backend lands. +- 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/unsafe-local-provider.md b/docs/reference/unsafe-local-provider.md index 73f067511..af3a3c653 100644 --- a/docs/reference/unsafe-local-provider.md +++ b/docs/reference/unsafe-local-provider.md @@ -6,8 +6,8 @@ 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 configured launch is feature-negotiated; -unsafe-local persistent shells remain unavailable until their dedicated backend -is enabled. +the user helper contains the persistent-shell backend, while public daemon +routing and feature advertisement remain unavailable until integration lands. ## Nix options @@ -134,6 +134,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 later daemon routing layer must populate +those fields only from the bundle-hashed unsafe-local workload record; 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 @@ -157,6 +162,24 @@ 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 @@ -168,9 +191,19 @@ 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 8 MiB, and waits to 1000 ms. -Terminal bytes do not share the helper control queue. This contract does not -make unsafe-local persistent-shell runtime dispatch available yet. +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 a long read poll does not block writes or resize. +Terminal bytes do not share the helper control queue. Public daemon shell +routing remains unavailable in this revision. + +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 diff --git a/packages/Cargo.lock b/packages/Cargo.lock index a71e0b71f..0ce7160ba 100644 --- a/packages/Cargo.lock +++ b/packages/Cargo.lock @@ -1153,6 +1153,7 @@ dependencies = [ "d2b-realm-core", "getrandom 0.2.17", "nix 0.29.0", + "rustix 0.38.44", "serde", "serde_json", "sha2", 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 1a9d00640..c1011feb3 100644 --- a/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs +++ b/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs @@ -465,12 +465,31 @@ fn helper_shell_schema_is_correlated_bounded_and_authority_free() { let definitions = helper_schema["definitions"].as_object().unwrap(); let shell_request = serde_json::to_string(&definitions["HelperShellRequest"]).unwrap(); - for field in ["requestId", "operationId", "initialTerminalSize"] { + 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), diff --git a/packages/d2b-contracts/src/unsafe_local_wire.rs b/packages/d2b-contracts/src/unsafe_local_wire.rs index 8f99ba177..a880b4094 100644 --- a/packages/d2b-contracts/src/unsafe_local_wire.rs +++ b/packages/d2b-contracts/src/unsafe_local_wire.rs @@ -13,7 +13,10 @@ use crate::{ TerminalStream, TerminalWaitResult, TerminalWriteStdinResult, }, }; -use d2b_core::{configured_argv::ConfiguredArgv, workload_identity::WorkloadIdentity}; +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, @@ -258,6 +261,32 @@ pub struct HelperLaunchRequest { pub graphical: bool, } +#[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", @@ -273,6 +302,7 @@ pub enum HelperShellRequest { #[schemars(rename = "operationId")] operation_id: OperationId, workload: WorkloadIdentity, + policy: HelperShellPolicy, }, Attach { #[schemars(rename = "requestId")] @@ -280,6 +310,7 @@ pub enum HelperShellRequest { #[schemars(rename = "operationId")] operation_id: OperationId, workload: WorkloadIdentity, + policy: HelperShellPolicy, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, #[serde(default)] @@ -296,6 +327,7 @@ pub enum HelperShellRequest { #[schemars(rename = "operationId")] operation_id: OperationId, workload: WorkloadIdentity, + policy: HelperShellPolicy, name: ShellName, }, Kill { @@ -304,6 +336,7 @@ pub enum HelperShellRequest { #[schemars(rename = "operationId")] operation_id: OperationId, workload: WorkloadIdentity, + policy: HelperShellPolicy, name: ShellName, }, } @@ -315,16 +348,19 @@ impl fmt::Debug for HelperShellRequest { 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, force, initial_terminal_size, @@ -333,6 +369,7 @@ impl fmt::Debug for HelperShellRequest { .field("request_id", request_id) .field("operation_id", operation_id) .field("workload", workload) + .field("policy", policy) .field("name", &name.as_ref().map(|_| "")) .field("force", force) .field("initial_terminal_size", initial_terminal_size) @@ -341,24 +378,28 @@ impl fmt::Debug for HelperShellRequest { 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(), } @@ -385,6 +426,13 @@ impl HelperShellRequest { } 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, @@ -728,7 +776,8 @@ impl HelperTerminalRequest { pub fn validate_bounds(&self) -> Result<(), HelperFailureCode> { match self { Self::ReadOutput(request) - if request.max_len > MAX_UNSAFE_LOCAL_TERMINAL_CHUNK_BYTES + 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) @@ -996,6 +1045,13 @@ mod tests { 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, @@ -1021,11 +1077,13 @@ mod tests { 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 }, @@ -1034,12 +1092,14 @@ mod tests { 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"), }, ]; @@ -1288,6 +1348,18 @@ mod tests { 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, @@ -1309,15 +1381,42 @@ mod tests { 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_eq!( + invalid_policy.validate_bounds(), + Err(HelperFailureCode::InvalidRequest) + ); } #[test] 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: operation("op-1"), workload: workload(), + policy, name: Some(shell_name(shell_canary)), force: true, initial_terminal_size: TerminalSize { rows: 24, cols: 80 }, diff --git a/packages/d2b-unsafe-local-helper/Cargo.toml b/packages/d2b-unsafe-local-helper/Cargo.toml index 577f55885..732113909 100644 --- a/packages/d2b-unsafe-local-helper/Cargo.toml +++ b/packages/d2b-unsafe-local-helper/Cargo.toml @@ -16,6 +16,7 @@ d2b-core = { path = "../d2b-core", version = "0.0.0-bootstrap" } d2b-realm-core = { path = "../d2b-realm-core", version = "0.0.0-bootstrap" } 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" diff --git a/packages/d2b-unsafe-local-helper/src/environment.rs b/packages/d2b-unsafe-local-helper/src/environment.rs index 3aa8bcefd..b04fd2d5b 100644 --- a/packages/d2b-unsafe-local-helper/src/environment.rs +++ b/packages/d2b-unsafe-local-helper/src/environment.rs @@ -13,6 +13,7 @@ pub enum EnvironmentError { InvalidEntry, DuplicateKey, PathMissing, + RuntimeDirectoryInvalid, ExecutableUnavailable, ProxyUnavailable, } @@ -98,6 +99,14 @@ 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 resolve_program(&self, program: &str) -> Result { if program.is_empty() || program.contains('\0') { return Err(EnvironmentError::ExecutableUnavailable); 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..3143f327b 100644 --- a/packages/d2b-unsafe-local-helper/src/main.rs +++ b/packages/d2b-unsafe-local-helper/src/main.rs @@ -19,14 +19,23 @@ struct Cli { 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::ShellSupervisor) => { + d2b_unsafe_local_helper::run_shell_supervisor().map_err(|_| "shell runtime failed") + } None => ScopeRuntime::new(SystemdUserScopeManager::new()) .map_err(|_| "helper runtime unavailable") .and_then(|runtime| { 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..e9cd66cff --- /dev/null +++ b/packages/d2b-unsafe-local-helper/src/output_ring.rs @@ -0,0 +1,210 @@ +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_and_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)); + writer.join().unwrap(); + assert_eq!(read.data, b"ready"); + assert!(read.eof); + assert!(!read.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 d3d1192fd..90eafb445 100644 --- a/packages/d2b-unsafe-local-helper/src/protocol.rs +++ b/packages/d2b-unsafe-local-helper/src/protocol.rs @@ -13,13 +13,14 @@ 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}; @@ -46,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") @@ -126,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, @@ -178,7 +190,7 @@ impl HelperClient { 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); @@ -186,16 +198,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); @@ -338,6 +376,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], @@ -412,7 +492,9 @@ 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 @@ -422,45 +504,26 @@ fn failure_code(error: RuntimeError) -> HelperFailureCode { RuntimeError::ScopeCreateFailed => HelperFailureCode::ScopeCreateFailed, RuntimeError::ScopeIdentityMismatch => HelperFailureCode::ScopeIdentityMismatch, RuntimeError::OperationIdConflict => HelperFailureCode::OperationIdConflict, - RuntimeError::OperationInProgress => HelperFailureCode::QueueFull, + 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 { - request_id, - operation_id, - .. - } - | 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() { @@ -497,6 +560,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( @@ -518,4 +605,127 @@ 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 result = send_queued_response( + &sender, + QueuedResponse { + frame: terminal_ready_frame(), + fd: Some(payload_read), + }, + ); + assert_eq!(result, Err(ProtocolError::ConnectFailed)); + assert!(!std::path::Path::new(&format!("/proc/self/fd/{raw}")).exists()); + } } diff --git a/packages/d2b-unsafe-local-helper/src/runtime.rs b/packages/d2b-unsafe-local-helper/src/runtime.rs index 0949aa35f..93f9d6f1a 100644 --- a/packages/d2b-unsafe-local-helper/src/runtime.rs +++ b/packages/d2b-unsafe-local-helper/src/runtime.rs @@ -1,8 +1,11 @@ use crate::environment::EnvironmentError; 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, }; use d2b_core::workload_identity::WorkloadIdentity; use d2b_realm_core::ids::OperationId; @@ -27,6 +30,7 @@ const MAX_LEDGER_BYTES: u64 = 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RuntimeError { + InvalidRequest, InvalidIdentity, UserManagerUnavailable, EnvironmentInvalid, @@ -36,6 +40,11 @@ pub enum RuntimeError { ScopeIdentityMismatch, OperationIdConflict, OperationInProgress, + QuotaExceeded, + ShellUnavailable, + ShellNotFound, + ShellAlreadyAttached, + TerminalClosed, Timeout, LedgerInvalid, Internal, @@ -68,15 +77,17 @@ impl From for RuntimeError { #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PersistedScope { - operation_id: OperationId, +pub(crate) struct PersistedScope { + pub(crate) operation_id: OperationId, #[serde(default)] - fingerprint: Option<[u8; 32]>, - workload: WorkloadIdentity, - unit_name: String, - invocation_id: String, - control_group: String, - kind: HelperScopeKind, + 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 { @@ -88,12 +99,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(), @@ -105,9 +133,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 { @@ -120,15 +148,20 @@ 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, } -struct RuntimeLedger { - persisted: PersistedScopeLedger, - reservations: BTreeMap, +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, } @@ -142,9 +175,9 @@ impl Default for RuntimeLedger { } #[derive(Clone, Copy)] -struct LaunchReservation { - fingerprint: [u8; 32], - owner: u64, +pub(crate) struct LaunchReservation { + pub(crate) fingerprint: [u8; 32], + pub(crate) owner: u64, } enum LaunchBegin { @@ -152,11 +185,26 @@ enum LaunchBegin { 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 { - fn from_persisted(persisted: PersistedScopeLedger) -> Self { + 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, } } @@ -218,6 +266,125 @@ impl RuntimeLedger { 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 { @@ -225,6 +392,9 @@ impl fmt::Debug for ScopeRuntime { f.debug_struct("ScopeRuntime") .field("ledger_path", &"") .field("user_home", &"") + .field("shell_home", &"") + .field("uid", &"") + .field("executable", &"") .finish_non_exhaustive() } } @@ -241,7 +411,8 @@ 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)?; + Self::with_paths_and_executable(manager, user_home, ledger_path, executable) } pub fn with_paths( @@ -249,15 +420,44 @@ impl ScopeRuntime { user_home: PathBuf, ledger_path: PathBuf, ) -> Result { + 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 { + 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, }) } + pub fn shell( + &self, + request: HelperShellRequest, + ) -> Result { + crate::shell_runtime::dispatch(self, request) + } + pub fn launch( &self, request: HelperLaunchRequest, @@ -325,6 +525,7 @@ impl ScopeRuntime { invocation_id: scope.invocation_id.clone(), control_group: scope.control_group.clone(), kind: scope.kind, + persistent_shell: None, }; if let Err(error) = supervisor.release_and_wait_started() { supervisor.abort(); @@ -366,7 +567,7 @@ impl ScopeRuntime { Ok(()) } - fn stop_failed_scope(&self, scope: &VerifiedScope) { + pub(crate) fn stop_failed_scope(&self, scope: &VerifiedScope) { let _ = self.manager.terminate_scope(scope, libc::SIGKILL); let _ = self.manager.stop_scope(scope); } @@ -386,7 +587,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(); @@ -398,13 +599,18 @@ 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: None, + persistent_shell, }); } Ok(HelperSnapshot { generation, scopes }) @@ -601,16 +807,53 @@ fn load_ledger(path: &Path) -> Result { .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)) @@ -726,6 +969,7 @@ mod tests { 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), @@ -780,7 +1024,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)); } 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..ef0c2cd0a --- /dev/null +++ b/packages/d2b-unsafe-local-helper/src/shell_runtime.rs @@ -0,0 +1,1450 @@ +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.child_entries(false, None)?; + 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)?; + verify_scope(runtime, &scope)?; + let kill_acknowledged = supervisor_action(runtime, &scope, SupervisorAction::Kill) + .map(|response| matches!(response.result, SupervisorResult::KillAccepted)) + .unwrap_or(false); + if !kill_acknowledged { + return Err(RuntimeError::ShellUnavailable); + } + + let verified = scope.verified(); + collect_verified_scope(runtime, &verified)?; + remove_shell_scope(runtime, &scope)?; + + let response = HelperShellResponse::Kill(HelperShellKillResponse { + request_id, + operation_id: operation_id.clone(), + result: ShellKillResult { + name, + killed: true, + 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..7cac9a158 --- /dev/null +++ b/packages/d2b-unsafe-local-helper/src/shell_socket.rs @@ -0,0 +1,266 @@ +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)?; + if !metadata.file_type().is_dir() + || metadata.file_type().is_symlink() + || metadata.uid() != expected_uid + || metadata.permissions().mode() & 0o7777 != 0o700 + { + 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_rejects_symlink_and_permissive_mode() { + let directory = scratch(); + let uid = Uid::current().as_raw(); + fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + validate_runtime_directory(&directory, uid), + Err(ShellSocketError::RuntimeDirectoryInvalid) + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[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..4839db1f8 --- /dev/null +++ b/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs @@ -0,0 +1,1185 @@ +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::sys::socket::{getsockopt, sockopt::PeerCredentials}; +use rustix::fs::{Mode, OFlags}; +use rustix::io::{Errno, 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::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_INTERVAL: Duration = Duration::from_millis(10); + +#[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, + 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) -> 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), + 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); + 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 state = Arc::new(SupervisorState::new(master, Arc::clone(&ring))); + drop(spec); + start_pty_reader(Arc::clone(&state)); + write_ready(1)?; + + while !state.kill_requested.load(Ordering::Acquire) { + 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(), + }), + } + std::thread::sleep(SUPERVISOR_POLL_INTERVAL); + } + 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(SUPERVISOR_POLL_INTERVAL), + Err(_) => break, + } + } + drop(listener); + Ok(()) +} + +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 start_pty_reader(state: Arc) { + let _ = std::thread::Builder::new() + .name("d2b-shell-pty-reader".to_owned()) + .spawn(move || { + let mut buffer = [0u8; 16 * 1024]; + loop { + let result = { + let master = state + .master + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let Some(master) = master.as_ref() else { + break; + }; + fd_read(master, &mut buffer) + }; + match result { + Ok(0) => { + state.ring.close(); + break; + } + Ok(count) => state.ring.append(&buffer[..count]), + Err(Errno::AGAIN) => std::thread::sleep(SUPERVISOR_POLL_INTERVAL), + Err(Errno::INTR) => {} + Err(_) => { + state.ring.close(); + break; + } + } + } + }); +} + +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 state = SupervisorState::new(master, Arc::new(OutputRing::new(1024).unwrap())); + 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/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..4026fcee3 --- /dev/null +++ b/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs @@ -0,0 +1,590 @@ +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() { + if get_current_uid() == 0 { + return; + } + let scratch = Scratch::new(); + 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()); + 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 created = runtime + .shell(attach_request("op-runtime-create", 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_ADOPT\\n'\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 (_, output) = read_until(&mut terminal, 2, 0, b"D2B_RUNTIME_ADOPT"); + assert!( + output + .windows(b"D2B_RUNTIME_ADOPT".len()) + .any(|window| window == b"D2B_RUNTIME_ADOPT") + ); + 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 reattached = reconstructed + .shell(attach_request( + "op-runtime-reattach", + workload.clone(), + true, + )) + .unwrap(); + let (_, fd) = reattached.into_parts(); + drop(fd); + + let killed = reconstructed + .shell(HelperShellRequest::Kill { + request_id: 4, + operation_id: OperationId::parse("op-runtime-kill").unwrap(), + workload, + policy: shell_policy(), + name: ShellName::new("host").unwrap(), + }) + .unwrap(); + assert!(matches!( + killed.into_parts().0, + UnsafeLocalHelperToDaemon::Shell(_) + )); + let deadline = Instant::now() + Duration::from_secs(2); + while has_shell_socket(&scratch.path) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(!has_shell_socket(&scratch.path)); +} + +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 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) +} From 5c9522f083e3aa58c51e4e8eb05c3f4e3b05f2cd Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:58:27 -0700 Subject: [PATCH 05/14] shell: wire unsafe-local sessions through d2bd (#295) * shell: wire unsafe-local sessions through d2bd ( W3 ) * shell: harden feature and helper correlation ( W3 ) Use one helper request-id namespace across launches and shells, require negotiated unsafe-local shell support after admin auth, redact unresolved audit targets, and forbid canonical-target fallback to a VM. * shell: satisfy negotiated-route lint gate ( W3 ) Pass the borrowed server state directly when checking whether a shell target requires unsafe-local feature negotiation. * shell: harden late replies and idle supervision ( W3fu1 H1 H2 M1 ) Close the terminal-late-response, real-CLI KVM coverage, and idle poll findings together because they validate the same end-to-end attachment lifecycle. Retain an evicted-abandon watermark, block idle PTY/listener loops in poll, and exercise the actual Rust CLI through a pseudo-terminal. * shell: satisfy late-reply lint gate ( W3fu1 H1 ) Collapse the bounded abandoned-request eviction condition for the warnings-denied workspace gate. * tests: wait for real shell CLI readiness ( W3fu1 H2 ) Avoid mistaking local PTY echo for remote shell output by waiting for the attach boundary, disabling remote echo, and then proving command output before detaching. * cli: resolve direct shells before gateway routing ( W3fu1 H2 ) Consult the daemon workload inventory for canonical direct-local shells before applying realm-gateway routing, while preserving gateway fallback when no direct workload exists. The KVM PTY driver now waits for actual attachment and non-echoed remote output. * cli: default invalid PTY shell geometry ( W3fu1 H2 ) Treat zero or out-of-range host terminal dimensions as unavailable and use the documented 24x80 attach default instead of forwarding an invalid helper request. * tests: detach real shell CLI through signals ( W3fu1 H2 ) Drive the CLI signal-handling detach path after proving remote non-echoed output; the detach escape state machine remains covered hermetically without pseudo-terminal flow-control interference. * shell: make lifecycle observability exactly once ( W3fu2 H1 H2 H3 H4 ) Close the four observability findings as one coherent accounting fix: resolved execution failures retain canonical targets, resolution and capability failures emit metric/audit pairs, and runtime paths emit only the provider-neutral shell audit event. Preserve bounded force intent on unified attach records. * shell: close unified audit contract gaps ( W3fu3 H1 H2 H3 H4 ) Close the feature-refusal accounting and three audit-contract documentation findings together: negotiated unsafe-local refusals now emit one resolved metric/audit pair, ShellLifecycle is the sole runtime enum, optional force is documented, and source guards prevent legacy reintroduction. * tests: stabilize terminal fd cleanup check ( W3fu4 ) Allocate cleanup-probe descriptors from a high bounded range so parallel tests cannot immediately reuse their raw numbers and falsify EBADF assertions. * docs: name the sole shell lifecycle event ( W3fu5 L1 L2 ) Align the unsafe-local reference with the daemon API by naming ShellLifecycle as the sole runtime event and marking operation/session correlation digests optional. --------- Co-authored-by: John Vicondoa --- AGENTS.md | 1 + CHANGELOG.md | 16 +- README.md | 2 +- docs/explanation/persistent-shells.md | 18 +- .../configure-desktop-terminal-integration.md | 2 +- docs/how-to/use-persistent-shells.md | 47 +- docs/reference/cli-contract.md | 61 +- docs/reference/daemon-api.md | 57 +- docs/reference/daemon-metrics.md | 16 + docs/reference/error-codes.md | 36 +- docs/reference/unsafe-local-provider.md | 57 +- .../dashboards/01-d2b-overview.json | 20 + .../tests/policy_metrics.rs | 27 + .../tests/realm_workload_schema_contract.rs | 2 +- .../tests/workload_observability_contract.rs | 62 + packages/d2b-contracts/src/public_wire.rs | 5 + .../d2b-unsafe-local-helper/src/runtime.rs | 4 +- .../src/shell_runtime.rs | 41 +- .../src/shell_supervisor.rs | 153 +- .../d2b-unsafe-local-helper/src/systemd.rs | 35 +- packages/d2b/src/lib.rs | 282 +++- packages/d2bd/src/daemon_audit.rs | 138 +- packages/d2bd/src/lib.rs | 1266 ++++++++++++++--- packages/d2bd/src/metrics.rs | 16 + packages/d2bd/src/shell_backend.rs | 464 ++++++ packages/d2bd/src/typed_error.rs | 245 ++++ packages/d2bd/src/unsafe_local_helper.rs | 547 ++++++- packages/d2bd/src/unsafe_local_terminal.rs | 738 ++++++++++ packages/d2bd/src/workload_dispatch.rs | 439 +++++- .../host-integration/unsafe-local-helper.nix | 469 ++++-- tests/unit/nix/cases/realm-workloads.nix | 16 + 31 files changed, 4658 insertions(+), 624 deletions(-) create mode 100644 packages/d2bd/src/shell_backend.rs create mode 100644 packages/d2bd/src/unsafe_local_terminal.rs diff --git a/AGENTS.md b/AGENTS.md index 27920cc72..869f4cf48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -961,6 +961,7 @@ Touch these only with a clear plan and a corresponding test run. | 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`) | 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 ca4ec2da7..8e345884b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,18 +12,28 @@ deprecations ship one minor release before removal. ### Added +- Added end-to-end unsafe-local persistent shells. `d2bd` now resolves + canonical or unambiguous workload targets, derives helper policy only from + the hash-verified private bundle, multiplexes the helper terminal fd behind + an opaque public session handle, and routes attach/list/detach/kill without a + broker op, root unit, SSH path, or host-shell fallback. `d2b shell` and shell + launcher items require `unsafe-local-shell-v1`; provider-neutral audit and + low-cardinality metrics cover create, attach, detach, kill, close, and typed + failure boundaries. Persistent host shells survive CLI, helper, and daemon + reconnects while their verified user scope lives, but terminate with the + non-lingering user manager and provide no same-UID containment. + - Added the unsafe-local persistent-shell helper runtime: a hidden verified user-scope supervisor owns each login-shell PTY, private reconnect socket, bounded merged-output ring, and single attachment across helper reconnects. Terminal streams transfer as one CLOEXEC fd, shell metadata survives in the existing user ledger, and exact-scope teardown cannot target unrelated - same-UID processes. Public daemon shell routing and CLI enablement remain - deferred. + same-UID processes. - Prepared unsafe-local persistent shells with private helper protocol v2, correlated management results, bounded dedicated terminal-stream frames, restart snapshot metadata, typed shell failures, and the public - `unsafe-local-shell-v1` feature token. Runtime dispatch remains unavailable. + `unsafe-local-shell-v1` feature token. - Added proposed ADR 0045, defining parent-owned workload-hosted realm controllers, explicit runtime/infrastructure/relay provider responsibilities, 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/explanation/persistent-shells.md b/docs/explanation/persistent-shells.md index 3ee79d529..6149eec6c 100644 --- a/docs/explanation/persistent-shells.md +++ b/docs/explanation/persistent-shells.md @@ -33,6 +33,7 @@ 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. @@ -55,8 +56,16 @@ 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. Public daemon routing for this backend remains -feature-gated until the daemon integration lands. +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 @@ -68,6 +77,11 @@ 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/how-to/configure-desktop-terminal-integration.md b/docs/how-to/configure-desktop-terminal-integration.md index 9735b648b..92e7efa19 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 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/reference/cli-contract.md b/docs/reference/cli-contract.md index ee3f631af..f97b0da54 100644 --- a/docs/reference/cli-contract.md +++ b/docs/reference/cli-contract.md @@ -69,9 +69,10 @@ The public request carries only the canonical target, configured item id, and 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; unsafe-local shell execution remains unavailable until its dedicated -backend exists. When `--item` is omitted, the CLI selects `defaultItem`, then an -only item, otherwise returns the available item ids and names. +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 @@ -80,8 +81,9 @@ 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`. Unsupported peers -return a typed capability refusal and never fall back. +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. **Exit codes** @@ -91,7 +93,7 @@ return a typed capability refusal and never fall back. | `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 unsafe-local shell requested. | +| `70` | Capability unavailable, provider mismatch, or required feature/version skew. | | `76` | Protocol response or operation-id conflict. | ## Command reference @@ -122,7 +124,7 @@ return a typed capability refusal and never fall back. | `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 unsafe-local shell requested. | 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** @@ -3052,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 @@ -3097,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 @@ -3141,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`. @@ -3154,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` @@ -3462,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/daemon-api.md b/docs/reference/daemon-api.md index dff8dccb3..c8ae83ad3 100644 --- a/docs/reference/daemon-api.md +++ b/docs/reference/daemon-api.md @@ -101,6 +101,23 @@ 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 @@ -220,10 +237,10 @@ host reboot. | `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#L3690) | empty struct | -| `UsbSecurityKeySessionsRequest` | struct | [`UsbSecurityKeySessionsRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3776) | empty struct | -| `UsbSecurityKeyCancelRequest` | struct | [`UsbSecurityKeyCancelRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3830) | struct { `session_id`: `Option`; `current`: `bool` } | -| `UsbSecurityKeyTestRequest` | struct | [`UsbSecurityKeyTestRequest`](../../packages/d2b-contracts/src/public_wire.rs#L3855) | struct { `vm`: `String` } | +| `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 @@ -343,10 +360,10 @@ see the auto-generated tables above for the committed Rust variants. | `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#L3761) | struct { `host_proxy_enabled`: `bool`; `physical_keys`: `Vec`; `vm_devices`: `Vec`; `lease`: `UsbSkLeaseStatus` } | -| `UsbSecurityKeySessionsResponse` | struct | [`UsbSecurityKeySessionsResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3823) | struct { `sessions`: `Vec` } | -| `UsbSecurityKeyCancelResponse` | struct | [`UsbSecurityKeyCancelResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3841) | struct { `cancelled_session_id`: `Option`; `already_idle`: `bool` } | -| `UsbSecurityKeyTestResponse` | struct | [`UsbSecurityKeyTestResponse`](../../packages/d2b-contracts/src/public_wire.rs#L3878) | struct { `vm`: `String`; `ok`: `bool`; `checks`: `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 @@ -536,8 +553,8 @@ running live guest activation. | `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#L3728) | `Idle`; `Active`; `Queued`; `Unknown` | -| `UsbSkSessionOutcome` | enum | [`UsbSkSessionOutcome`](../../packages/d2b-contracts/src/public_wire.rs#L3783) | `Success`; `Timeout`; `Cancelled`; `DeviceUnavailable`; `Active`; `Unknown` | +| `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` } | @@ -593,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). @@ -717,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 7fb823bd6..61497694a 100644 --- a/docs/reference/daemon-metrics.md +++ b/docs/reference/daemon-metrics.md @@ -216,6 +216,18 @@ 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 @@ -261,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/unsafe-local-provider.md b/docs/reference/unsafe-local-provider.md index af3a3c653..a9f42967a 100644 --- a/docs/reference/unsafe-local-provider.md +++ b/docs/reference/unsafe-local-provider.md @@ -5,9 +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 configured launch is feature-negotiated; -the user helper contains the persistent-shell backend, while public daemon -routing and feature advertisement remain unavailable until integration lands. +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 @@ -55,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 @@ -89,10 +91,11 @@ The closed unsafe-local posture is: | `sessionPersistence` | `user-manager-lifetime` | `configured-launch-v1`, `unsafe-local-provider-v1`, and -`unsafe-local-shell-v1` are additive protocol-v3 feature flags. Clients may -recognize the shell token with this contract revision, but `d2bd` does not -advertise it until runtime dispatch is enabled. Clients must hide or refuse -unsupported operations; they must never fall back to unsafe-local. +`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; @@ -135,9 +138,9 @@ 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 later daemon routing layer must populate -those fields only from the bundle-hashed unsafe-local workload record; no public -shell request can choose or raise them. +`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 @@ -196,9 +199,24 @@ 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 a long read poll does not block writes or resize. -Terminal bytes do not share the helper control queue. Public daemon shell -routing remains unavailable in this revision. +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 @@ -212,10 +230,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/nixos-modules/components/observability/dashboards/01-d2b-overview.json b/nixos-modules/components/observability/dashboards/01-d2b-overview.json index e2a360003..61f598542 100644 --- a/nixos-modules/components/observability/dashboards/01-d2b-overview.json +++ b/nixos-modules/components/observability/dashboards/01-d2b-overview.json @@ -209,6 +209,26 @@ "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/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 c1011feb3..fbdfcc7fc 100644 --- a/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs +++ b/packages/d2b-contract-tests/tests/realm_workload_schema_contract.rs @@ -427,7 +427,7 @@ fn generated_unsafe_local_schemas_are_closed_and_argv_is_private() { let private_schema: serde_json::Value = serde_json::from_str(&private_schema).unwrap(); assert_eq!( private_schema["properties"]["workloads"]["maxItems"], - serde_json::json!(16) + serde_json::json!(256) ); assert_eq!( private_schema["properties"]["localVmWorkloads"]["maxItems"], diff --git a/packages/d2b-contract-tests/tests/workload_observability_contract.rs b/packages/d2b-contract-tests/tests/workload_observability_contract.rs index a64db3e11..536134f4c 100644 --- a/packages/d2b-contract-tests/tests/workload_observability_contract.rs +++ b/packages/d2b-contract-tests/tests/workload_observability_contract.rs @@ -21,3 +21,65 @@ fn overview_dashboard_covers_workload_provider_signals() { 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/public_wire.rs b/packages/d2b-contracts/src/public_wire.rs index a8078a00c..22ceff907 100644 --- a/packages/d2b-contracts/src/public_wire.rs +++ b/packages/d2b-contracts/src/public_wire.rs @@ -3377,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-unsafe-local-helper/src/runtime.rs b/packages/d2b-unsafe-local-helper/src/runtime.rs index 93f9d6f1a..165789be1 100644 --- a/packages/d2b-unsafe-local-helper/src/runtime.rs +++ b/packages/d2b-unsafe-local-helper/src/runtime.rs @@ -70,7 +70,9 @@ 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 + } } } } diff --git a/packages/d2b-unsafe-local-helper/src/shell_runtime.rs b/packages/d2b-unsafe-local-helper/src/shell_runtime.rs index ef0c2cd0a..153348b92 100644 --- a/packages/d2b-unsafe-local-helper/src/shell_runtime.rs +++ b/packages/d2b-unsafe-local-helper/src/shell_runtime.rs @@ -534,16 +534,37 @@ fn kill( reservation: LaunchReservation, ) -> Result { let scope = persisted_shell(runtime, &workload, &name)?; - verify_scope(runtime, &scope)?; - let kill_acknowledged = supervisor_action(runtime, &scope, SupervisorAction::Kill) - .map(|response| matches!(response.result, SupervisorResult::KillAccepted)) - .unwrap_or(false); - if !kill_acknowledged { - return Err(RuntimeError::ShellUnavailable); - } - let verified = scope.verified(); - collect_verified_scope(runtime, &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 => { + let response = supervisor_action(runtime, &scope, SupervisorAction::Kill)?; + if !matches!(response.result, SupervisorResult::KillAccepted) { + return Err(RuntimeError::ShellUnavailable); + } + // The verified supervisor has acknowledged Kill. If systemd drops + // the now-empty scope before collection, do not signal a replacement. + 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 { @@ -551,7 +572,7 @@ fn kill( operation_id: operation_id.clone(), result: ShellKillResult { name, - killed: true, + killed, state: ShellSessionState::Killed, }, }); diff --git a/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs b/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs index 4839db1f8..2720a79ee 100644 --- a/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs +++ b/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs @@ -16,9 +16,10 @@ use d2b_contracts::unsafe_local_wire::{ 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, ioctl_fionbio, read as fd_read, write as fd_write}; +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}; @@ -26,7 +27,7 @@ use std::collections::BTreeMap; use std::fmt; use std::fs::File; use std::io::{Read, Write}; -use std::os::fd::OwnedFd; +use std::os::fd::{AsFd, OwnedFd}; use std::os::unix::net::UnixStream; use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; @@ -48,7 +49,7 @@ const _: () = { const MAX_SUPERVISOR_SPEC_BYTES: usize = 2 * 1024 * 1024; const MAX_TERMINAL_WORKERS: usize = 16; const MAX_CONTROL_CONNECTIONS: usize = 32; -const SUPERVISOR_POLL_INTERVAL: Duration = Duration::from_millis(10); +const SUPERVISOR_POLL_TIMEOUT: Duration = Duration::from_millis(100); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ShellSupervisorError { @@ -469,32 +470,35 @@ pub(crate) fn run_shell_supervisor() -> Result<(), ShellSupervisorError> { write_ready(1)?; while !state.kill_requested.load(Ordering::Acquire) { - 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); + let listener_ready = poll_readable(listener.listener().as_fd())?; + 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), } - 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)), @@ -503,7 +507,6 @@ pub(crate) fn run_shell_supervisor() -> Result<(), ShellSupervisorError> { slug: "wait-failed".to_owned(), }), } - std::thread::sleep(SUPERVISOR_POLL_INTERVAL); } let deadline = Instant::now() + Duration::from_millis(250); while Instant::now() < deadline { @@ -512,7 +515,7 @@ pub(crate) fn run_shell_supervisor() -> Result<(), ShellSupervisorError> { state.set_terminal_status(exit_status(status)); break; } - Ok(None) => std::thread::sleep(SUPERVISOR_POLL_INTERVAL), + Ok(None) => std::thread::sleep(Duration::from_millis(10)), Err(_) => break, } } @@ -520,6 +523,65 @@ pub(crate) fn run_shell_supervisor() -> Result<(), ShellSupervisorError> { Ok(()) } +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() @@ -604,39 +666,6 @@ fn spawn_login_shell(spec: &ShellSupervisorSpec) -> Result<(OwnedFd, Child), She } } -fn start_pty_reader(state: Arc) { - let _ = std::thread::Builder::new() - .name("d2b-shell-pty-reader".to_owned()) - .spawn(move || { - let mut buffer = [0u8; 16 * 1024]; - loop { - let result = { - let master = state - .master - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let Some(master) = master.as_ref() else { - break; - }; - fd_read(master, &mut buffer) - }; - match result { - Ok(0) => { - state.ring.close(); - break; - } - Ok(count) => state.ring.append(&buffer[..count]), - Err(Errno::AGAIN) => std::thread::sleep(SUPERVISOR_POLL_INTERVAL), - Err(Errno::INTR) => {} - Err(_) => { - state.ring.close(); - break; - } - } - } - }); -} - fn handle_supervisor_connection(mut stream: UnixStream, state: Arc) { let Ok(peer) = getsockopt(&stream, PeerCredentials) else { return; 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/src/lib.rs b/packages/d2b/src/lib.rs index 0ff82ba2b..ab9d7010e 100644 --- a/packages/d2b/src/lib.rs +++ b/packages/d2b/src/lib.rs @@ -1847,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 => { @@ -2007,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(), @@ -2032,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, @@ -2114,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( @@ -3589,7 +3608,7 @@ fn cmd_config_status(args: &ConfigStatusArgs) -> Result { } fn cmd_launch(context: &Context, args: &LaunchArgs) -> Result { - use d2b_realm_core::{LauncherItemKind, ProtocolToken, WorkloadProviderKind}; + use d2b_realm_core::{LauncherItemKind, ProtocolToken}; if !context.public_socket.exists() { return Err(CliFailure::new( @@ -3625,13 +3644,7 @@ fn cmd_launch(context: &Context, args: &LaunchArgs) -> Result { let item = select_launcher_item(&workload, args.item.as_deref())?; if item.kind == LauncherItemKind::Shell { - if workload.provider_kind == WorkloadProviderKind::UnsafeLocal { - return Err(CliFailure::new( - 70, - "unsafe-local persistent shell launch is unavailable; no host-shell fallback is permitted", - )); - } - let vm = local_vm_shell_target(&workload).to_owned(); + let vm = shell_launch_target(&workload, &negotiated.capabilities)?; return cmd_shell( context, &ShellArgs { @@ -3691,6 +3704,18 @@ fn local_vm_shell_target(workload: &public_wire::WorkloadPublicSummary) -> &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, @@ -3717,6 +3742,97 @@ fn require_launch_features( 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, @@ -3826,7 +3942,7 @@ mod workload_launch_tests { } } - fn workload( + pub(super) fn workload( workload_id: &str, realm: &str, items: Vec, @@ -3916,6 +4032,28 @@ mod workload_launch_tests { .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"); } } @@ -11848,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", @@ -11942,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"]); @@ -12110,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() @@ -12140,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) @@ -12651,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/d2bd/src/daemon_audit.rs b/packages/d2bd/src/daemon_audit.rs index 6b2677400..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")] @@ -170,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. - peer_uid: u32, - /// Closed shell action. - 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. + /// 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 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. /// @@ -1584,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", + ]) ); } diff --git a/packages/d2bd/src/lib.rs b/packages/d2bd/src/lib.rs index 1dbd3f920..0430943b0 100644 --- a/packages/d2bd/src/lib.rs +++ b/packages/d2bd/src/lib.rs @@ -119,6 +119,7 @@ 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; @@ -148,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 @@ -2942,6 +2944,7 @@ fn handle_connection_authorized( KnownFeatureFlag::ExportBrokerAudit.wire_value(), KnownFeatureFlag::ConfiguredLaunchV1.wire_value(), KnownFeatureFlag::UnsafeLocalProviderV1.wire_value(), + KnownFeatureFlag::UnsafeLocalShellV1.wire_value(), ]; let capabilities = advertised_capabilities .into_iter() @@ -3049,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(); @@ -3612,9 +3628,8 @@ fn dispatch_unsafe_local_launcher( resolved: &workload_dispatch::ResolvedExec, ) -> Result { use d2b_contracts::unsafe_local_wire::{HelperLaunchRequest, HelperOperationDisposition}; - static REQUEST_ID: AtomicU64 = AtomicU64::new(1); let request = HelperLaunchRequest { - request_id: REQUEST_ID.fetch_add(1, Ordering::Relaxed), + request_id: next_internal_helper_request_id(), operation_id: operation_id.clone(), workload: resolved.identity.clone(), item_id: resolved.item_id.clone(), @@ -3678,7 +3693,9 @@ fn map_workload_catalog_error(error: workload_dispatch::CatalogError) -> TypedEr CatalogError::ItemNotFound => Kind::ItemNotFound, CatalogError::ArtifactsUnavailable | CatalogError::ConfiguredItemMissing - | CatalogError::ConfiguredItemMismatch => Kind::ConfiguredItemMismatch, + | CatalogError::ConfiguredItemMismatch + | CatalogError::ShellCapabilityUnavailable => Kind::ConfiguredItemMismatch, + CatalogError::AliasConflict => Kind::ItemNotFound, CatalogError::OperationConflict => Kind::OperationConflict, CatalogError::OperationInProgress => Kind::QueueFull, }; @@ -7966,6 +7983,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 } @@ -8080,10 +8131,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 { @@ -8093,82 +8210,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(_) @@ -8182,23 +8566,253 @@ fn dispatch_shell_management( Ok(wire::shell_response(&response)) } -fn emit_shell_management_audit( +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, - vm: &str, + 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 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, + 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, - action, + 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, + 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( @@ -8229,95 +8843,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()) { @@ -8334,7 +8925,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(), @@ -8349,25 +8943,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), @@ -8386,15 +8969,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( @@ -8414,12 +8991,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 ) @@ -8449,24 +9024,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, @@ -8487,7 +9058,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< @@ -8767,22 +9628,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, @@ -27958,9 +28803,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")); } @@ -28074,6 +28919,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 092595d1e..ba098c7d8 100644 --- a/packages/d2bd/src/metrics.rs +++ b/packages/d2bd/src/metrics.rs @@ -167,6 +167,18 @@ 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, @@ -445,6 +457,9 @@ 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." } @@ -608,6 +623,7 @@ 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", ] 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 89cf9f648..30d42c7f4 100644 --- a/packages/d2bd/src/typed_error.rs +++ b/packages/d2bd/src/typed_error.rs @@ -251,6 +251,35 @@ 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, @@ -448,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 { @@ -659,6 +842,9 @@ pub enum TypedError { GuestControlShellFailed { kind: GuestControlShellErrorKind, }, + UnsafeLocalShellFailed { + kind: UnsafeLocalShellErrorKind, + }, WorkloadLaunchFailed { kind: WorkloadLaunchErrorKind, }, @@ -781,6 +967,7 @@ 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", @@ -842,6 +1029,7 @@ 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, @@ -998,6 +1186,7 @@ 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 } => { @@ -1168,6 +1357,7 @@ 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() @@ -1380,6 +1570,7 @@ impl TypedError { | Self::GuestControlReadFailed { .. } | Self::GuestControlExecFailed { .. } | Self::GuestControlShellFailed { .. } + | Self::UnsafeLocalShellFailed { .. } | Self::WorkloadLaunchFailed { .. } | Self::DaemonBusy | Self::ConsoleVmNotFound { .. } @@ -1742,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 77f359efa..077c6f25a 100644 --- a/packages/d2bd/src/unsafe_local_helper.rs +++ b/packages/d2bd/src/unsafe_local_helper.rs @@ -1,9 +1,10 @@ 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, @@ -46,6 +47,7 @@ pub enum HelperRegistryError { InvalidPeer, SocketBufferTooSmall, InvalidFrame, + InvalidRequest, FrameTooLarge, ProtocolMismatch, SnapshotTooLarge, @@ -72,6 +74,7 @@ pub enum HelperAvailability { pub enum HelperReply { Operation(HelperOperationResult), Rejected(HelperOperationRejected), + Shell(HelperShellResponse), Terminal { ready: HelperTerminalReady, fd: OwnedFd, @@ -83,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) @@ -92,13 +96,68 @@ 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, } @@ -177,6 +236,7 @@ impl HelperConnection { request_id, AbandonedRequest { operation_id: operation_id.to_owned(), + kind: pending.kind, expires_at: now + LATE_RESPONSE_RETENTION, }, ); @@ -356,6 +416,7 @@ impl HelperRegistry { request.request_id, PendingRequest { operation_id: operation_key.clone(), + kind: PendingKind::Launch, sender, }, ) @@ -409,6 +470,13 @@ impl HelperRegistry { .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 @@ -423,6 +491,84 @@ impl HelperRegistry { } } + 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(_) => { + connection.abandon_pending(request_id, &operation_id); + Err(HelperRegistryError::Timeout) + } + } + } + fn handle_socket(&self, socket: Socket) -> Result<(), HelperRegistryError> { let peer = getsockopt(&socket, PeerCredentials).map_err(|_| HelperRegistryError::InvalidPeer)?; @@ -625,13 +771,14 @@ impl HelperRegistry { } UnsafeLocalHelperToDaemon::Operation(result) => { reject_unexpected_fds(fds)?; - let delivered = complete_pending( + let completion = complete_pending( connection, result.request_id, result.operation_id.to_string(), HelperReply::Operation(result.clone()), + Some(PendingKind::Launch), )?; - if !delivered { + if !completion.delivered { let operation_id = result.operation_id.to_string(); self.operations.lock().complete( uid, @@ -644,13 +791,14 @@ impl HelperRegistry { } UnsafeLocalHelperToDaemon::Rejected(rejected) => { reject_unexpected_fds(fds)?; - let delivered = complete_pending( + let completion = complete_pending( connection, rejected.request_id, rejected.operation_id.to_string(), HelperReply::Rejected(rejected.clone()), + None, )?; - if !delivered { + if !completion.delivered && completion.kind.is_launch() { self.operations.lock().reject( uid, rejected.operation_id.as_str(), @@ -667,12 +815,21 @@ impl HelperRegistry { ready.request_id, ready.operation_id.to_string(), HelperReply::Terminal { ready, fd }, + Some(PendingKind::ShellAttach), ) .map(|_| ()) } - UnsafeLocalHelperToDaemon::Shell(_) => { + UnsafeLocalHelperToDaemon::Shell(response) => { reject_unexpected_fds(fds)?; - Err(HelperRegistryError::ProtocolMismatch) + 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)?; @@ -682,27 +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 { + 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 => Ok(false), + 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); } - Ok(pending.sender.try_send(Ok(reply)).is_ok()) + Ok(PendingCompletion { + delivered: pending.sender.try_send(Ok(reply)).is_ok(), + kind: pending.kind, + }) } pub fn bind_helper_socket( @@ -1094,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, @@ -1177,6 +1360,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}; @@ -1208,6 +1397,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(); @@ -1397,6 +1630,7 @@ mod tests { request.request_id, PendingRequest { operation_id: request.operation_id.to_string(), + kind: PendingKind::Launch, sender, }, ); @@ -1455,6 +1689,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]); @@ -1508,6 +1781,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() { @@ -1714,8 +2231,8 @@ mod tests { )); assert_eq!(fcntl(raw, FcntlArg::F_GETFD), Err(nix::errno::Errno::EBADF)); - let first = unistd::dup(stream.as_raw_fd()).unwrap(); - let second = unistd::dup(stream.as_raw_fd()).unwrap(); + 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) 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/workload_dispatch.rs b/packages/d2bd/src/workload_dispatch.rs index 4fafdd033..7443561a8 100644 --- a/packages/d2bd/src/workload_dispatch.rs +++ b/packages/d2bd/src/workload_dispatch.rs @@ -4,15 +4,18 @@ use std::{ time::{Duration, Instant}, }; -use d2b_contracts::public_wire::{ - GraphicalLaunchPosture, WorkloadAvailability, WorkloadPublicSummary, +use d2b_contracts::{ + public_wire::{GraphicalLaunchPosture, ShellName, WorkloadAvailability, WorkloadPublicSummary}, + unsafe_local_wire::HelperShellPolicy, }; use d2b_core::{ bundle_resolver::BundleResolver, configured_argv::ConfiguredArgv, realm_controller_config::RealmControllerPlacement, realm_workloads_launcher::LauncherWorkloadSummary, - unsafe_local_workloads::{UnsafeLocalLauncherItem, UnsafeLocalWorkloadsJson}, + unsafe_local_workloads::{ + UnsafeLocalLauncherItem, UnsafeLocalShellPolicy, UnsafeLocalWorkloadsJson, + }, workload_identity::{WorkloadIdentity, WorkloadTarget}, }; use d2b_realm_core::{LauncherItemKind, ProtocolToken, WorkloadProviderKind, WorkloadState}; @@ -32,6 +35,8 @@ pub(crate) enum CatalogError { ItemNotFound, ConfiguredItemMissing, ConfiguredItemMismatch, + ShellCapabilityUnavailable, + AliasConflict, OperationConflict, OperationInProgress, } @@ -144,9 +149,18 @@ pub(crate) struct ResolvedExec { pub graphical: bool, } +#[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 { @@ -156,20 +170,28 @@ impl WorkloadCatalog { .as_ref() .ok_or(CatalogError::ArtifactsUnavailable)?; let mut entries = BTreeMap::new(); + let mut visible = std::collections::BTreeSet::new(); for metadata in &public.workloads { - if !realm_is_direct_local(resolver, &metadata.identity) { - continue; - } let canonical = metadata.identity.canonical_target.to_canonical(); - let route = route_for_provider( - metadata.provider_kind, - metadata - .identity - .legacy_vm_name - .as_ref() - .map(|vm| vm.as_str()), - metadata.identity.workload_id.as_str(), - ); + 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 { @@ -178,25 +200,43 @@ impl WorkloadCatalog { }, ); } - Ok(Self { entries }) + Ok(Self { + entries, + visible, + known_local_vms: resolver.manifest.vms.keys().cloned().collect(), + }) } pub(crate) fn entries(&self) -> impl Iterator { - self.entries.values() + 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: entries - .into_iter() - .map(|entry| { - ( - entry.metadata.identity.canonical_target.to_canonical(), - entry, - ) - }) - .collect(), + entries, + visible, + known_local_vms, } } @@ -297,6 +337,141 @@ impl WorkloadCatalog { graphical: private_exec.graphical, }) } + + 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( @@ -345,7 +520,8 @@ mod tests { contract_id::ContractId, unsafe_local_workloads::{ LocalVmConfiguredWorkload, UNSAFE_LOCAL_WORKLOADS_SCHEMA_VERSION, UnsafeLocalExecItem, - UnsafeLocalWorkload, UnsafeLocalWorkloadsJson, + UnsafeLocalShellItem, UnsafeLocalShellPolicy, UnsafeLocalWorkload, + UnsafeLocalWorkloadsJson, }, }; use d2b_realm_core::{ @@ -477,6 +653,66 @@ mod tests { } } + 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(); @@ -681,4 +917,153 @@ mod tests { 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/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 cadde2550..1fa8e808a 100644 --- a/tests/unit/nix/cases/realm-workloads.nix +++ b/tests/unit/nix/cases/realm-workloads.nix @@ -1093,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 = @@ -1111,6 +1120,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; @@ -1125,6 +1138,9 @@ in daemonAllowedUsers = [ "alice" ]; helperRootUnitAbsent = true; noHelperBrokerSocketUnit = true; + unsafeLocalShellRootUnits = [ ]; + shellBrokerRequired = "no"; + unsafeLocalShellBrokerOps = [ ]; }; }; From bc29f63683baa37c82befee99125c0aeedfbd9ae Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:39:17 -0700 Subject: [PATCH 06/14] unsafe-local: complete graphical host integration and release 1.4.0 (#297) * unsafe-local: supervise graphical proxy and app ( W6 ) * unsafe-local: harden live graphical supervision ( W6 ) * clipboard: wait for picker focus restoration ( W6 ) * clipboard: align paste replay virtual keyboard ( W6 ) * clipboard: preserve paste keycode through proxies ( W6 ) * docs: refresh daemon API source links ( W6 ) * test: migrate host realm isolation fixture ( W6 ) * docs: document helper group relog requirement ( W6fu1 H1 ) * docs: align WeezTerm input guidance ( W6fu1 H4 ) * daemon: fingerprint unsafe-local realm accent ( W6fu2 H1 ) * helper: reap workload on ack failure ( W6fu4 H1 ) * test: cover runtime path and display bounds ( W6fu4 H2 H3 H4 ) * release: prepare 1.4.0 --------- Co-authored-by: John Vicondoa --- AGENTS.md | 5 +- CHANGELOG.md | 2095 +---------------- docs/explanation/unsafe-local-runtime.md | 5 + .../configure-desktop-terminal-integration.md | 9 +- docs/reference/daemon-api.md | 10 +- .../schemas/v2/unsafe-local-helper-wire.json | 4 + docs/reference/unsafe-local-provider.md | 22 +- nixos-modules/options-gateway.nix | 7 + nixos-modules/processes-json.nix | 1 + nixos-modules/unsafe-local-helper.nix | 2 +- packages/Cargo.lock | 1 + packages/d2b-clipd/src/main.rs | 125 +- packages/d2b-clipd/src/virtual_keyboard.rs | 44 +- .../d2b-contracts/src/unsafe_local_wire.rs | 84 +- packages/d2b-unsafe-local-helper/Cargo.toml | 1 + .../src/environment.rs | 74 +- packages/d2b-unsafe-local-helper/src/main.rs | 18 +- .../d2b-unsafe-local-helper/src/protocol.rs | 3 + .../d2b-unsafe-local-helper/src/runtime.rs | 994 +++++++- .../src/shell_socket.rs | 35 +- packages/d2b-wayland-proxy/src/terminal.rs | 53 +- packages/d2bd/src/lib.rs | 1 + packages/d2bd/src/unsafe_local_helper.rs | 33 + packages/d2bd/src/workload_dispatch.rs | 19 +- .../host-integration/host-realm-isolation.nix | 33 +- tests/unit/nix/cases/realm-workloads.nix | 9 + 26 files changed, 1603 insertions(+), 2084 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 869f4cf48..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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e345884b..a14656833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,2004 +10,127 @@ deprecations ship one minor release before removal. ## [Unreleased] -### Added - -- Added end-to-end unsafe-local persistent shells. `d2bd` now resolves - canonical or unambiguous workload targets, derives helper policy only from - the hash-verified private bundle, multiplexes the helper terminal fd behind - an opaque public session handle, and routes attach/list/detach/kill without a - broker op, root unit, SSH path, or host-shell fallback. `d2b shell` and shell - launcher items require `unsafe-local-shell-v1`; provider-neutral audit and - low-cardinality metrics cover create, attach, detach, kill, close, and typed - failure boundaries. Persistent host shells survive CLI, helper, and daemon - reconnects while their verified user scope lives, but terminate with the - non-lingering user manager and provide no same-UID containment. - -- Added the unsafe-local persistent-shell helper runtime: a hidden verified - user-scope supervisor owns each login-shell PTY, private reconnect socket, - bounded merged-output ring, and single attachment across helper reconnects. - Terminal streams transfer as one CLOEXEC fd, shell metadata survives in the - existing user ledger, and exact-scope teardown cannot target unrelated - same-UID processes. - -- Prepared unsafe-local persistent shells with private helper protocol v2, - correlated management results, bounded dedicated terminal-stream frames, - restart snapshot metadata, typed shell failures, and the public - `unsafe-local-shell-v1` feature token. - -- Added proposed ADR 0045, defining parent-owned workload-hosted realm - controllers, explicit runtime/infrastructure/relay provider responsibilities, - type-first sortable provider crate names with mandatory standard interfaces - and conformance suites backed by canonical provider DTOs in `d2b-contracts`, - Entra and YubiKey credential placement, and policy-authorized peer shortcuts - over inherited shared relay fabrics for nested realms. - -- Added feature-negotiated provider-neutral workload list, status, and configured - launch dispatch. `d2b launch` resolves targets and default/sole items from - argv-free public metadata, while `d2bd` resolves execution solely from the - bundle-hashed private item contract. Local VMs launch through authenticated - guest-control as the workload user; unsafe-local launches require an exact - same-UID helper. Unsupported providers, remote access, missing prerequisites, - and every fallback path fail with typed remediation. New bounded audit and - low-cardinality dashboard signals expose provider readiness and launch results - without command, environment, path, process, or unit details. Bundle format 11 - extends the private configured-item artifact to local VM workloads. - First-class local-VM workloads use their workload id as the backing VM name - when no transition-only `legacyVmName` is configured. - -- 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 is feature-gated and available through the daemon workload - operation family. - -- 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`. - -- 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. - -### Fixed - -- Fixed configured-launch retry behavior across failure and restart boundaries. - Unsafe-local helper timeouts release their active operation reservation, and - local-VM launches use a deterministic opaque guest exec id backed by guestd's - durable detached record so daemon restart retries cannot spawn duplicates. - Audit records now correlate the authenticated peer, public operation id, and - local-VM detached exec without recording execution details. - -- 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. - -### Changed - -- 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. +## [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/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 92e7efa19..683cff8d2 100644 --- a/docs/how-to/configure-desktop-terminal-integration.md +++ b/docs/how-to/configure-desktop-terminal-integration.md @@ -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/reference/daemon-api.md b/docs/reference/daemon-api.md index c8ae83ad3..5595f7b0c 100644 --- a/docs/reference/daemon-api.md +++ b/docs/reference/daemon-api.md @@ -563,11 +563,11 @@ running live guest activation. | `PathClass` | enum | [`PathClass`](../../packages/d2b-contracts/src/types.rs#L171) | `Vm`; `Runtime` | | `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#L450) | `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#L476) | `Committed`; `AlreadyCommitted`; `Completed` | -| `HelperTerminalTransport` | enum | [`HelperTerminalTransport`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L519) | `ConnectedUnixStream` | -| `DaemonToUnsafeLocalHelper` | enum | [`DaemonToUnsafeLocalHelper`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L922) | `HelloAccepted` — (HelperHelloAccepted); `Heartbeat` — (HelperHeartbeat); `Launch` — (HelperLaunchRequest); `Shell` — (HelperShellRequest) | -| `UnsafeLocalHelperToDaemon` | enum | [`UnsafeLocalHelperToDaemon`](../../packages/d2b-contracts/src/unsafe_local_wire.rs#L931) | `Hello` — (HelperHello); `Snapshot` — (HelperSnapshot); `Heartbeat` — (HelperHeartbeat); `Operation` — (HelperOperationResult); `TerminalReady` — (HelperTerminalReady); `Shell` — (HelperShellResponse); `Rejected` — (HelperOperationRejected) | +| `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` | diff --git a/docs/reference/schemas/v2/unsafe-local-helper-wire.json b/docs/reference/schemas/v2/unsafe-local-helper-wire.json index 94f996a7f..39d3a5fee 100644 --- a/docs/reference/schemas/v2/unsafe-local-helper-wire.json +++ b/docs/reference/schemas/v2/unsafe-local-helper-wire.json @@ -231,6 +231,7 @@ "graphical", "itemId", "operationId", + "realmAccentColor", "requestId", "workload" ], @@ -250,6 +251,9 @@ "operationId": { "$ref": "#/definitions/OperationId" }, + "realmAccentColor": { + "type": "string" + }, "requestId": { "type": "integer", "format": "uint64", diff --git a/docs/reference/unsafe-local-provider.md b/docs/reference/unsafe-local-provider.md index a9f42967a..c9bdf1def 100644 --- a/docs/reference/unsafe-local-provider.md +++ b/docs/reference/unsafe-local-provider.md @@ -122,9 +122,9 @@ host-local Unix binding. The helper lookup is keyed by the requester's remote, stale-helper, cross-UID, direct-compositor, root, SSH, and arbitrary command fallbacks are not available. -The separate helper protocol is version 2 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. Version 1 is rejected without a +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 @@ -149,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 @@ -157,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 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/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/packages/Cargo.lock b/packages/Cargo.lock index 0ce7160ba..05dd84371 100644 --- a/packages/Cargo.lock +++ b/packages/Cargo.lock @@ -1151,6 +1151,7 @@ dependencies = [ "d2b-contracts", "d2b-core", "d2b-realm-core", + "d2b-wayland-proxy", "getrandom 0.2.17", "nix 0.29.0", "rustix 0.38.44", 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-contracts/src/unsafe_local_wire.rs b/packages/d2b-contracts/src/unsafe_local_wire.rs index a880b4094..951a58b97 100644 --- a/packages/d2b-contracts/src/unsafe_local_wire.rs +++ b/packages/d2b-contracts/src/unsafe_local_wire.rs @@ -26,7 +26,7 @@ use schemars::{ use serde::{Deserialize, Serialize, de::DeserializeOwned}; use std::fmt; -pub const UNSAFE_LOCAL_HELPER_PROTOCOL_VERSION: u32 = 2; +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; @@ -250,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, @@ -259,6 +259,59 @@ 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)] @@ -1482,13 +1535,34 @@ mod tests { } #[test] - fn helper_v1_is_rejected_while_terminal_v1_is_preserved() { - assert_eq!(UNSAFE_LOCAL_HELPER_PROTOCOL_VERSION, 2); + 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(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] fn terminal_ready_freezes_single_connected_stream_transport() { assert_eq!(UNSAFE_LOCAL_TERMINAL_FD_COUNT, 1); diff --git a/packages/d2b-unsafe-local-helper/Cargo.toml b/packages/d2b-unsafe-local-helper/Cargo.toml index 732113909..3a3b0e48a 100644 --- a/packages/d2b-unsafe-local-helper/Cargo.toml +++ b/packages/d2b-unsafe-local-helper/Cargo.toml @@ -14,6 +14,7 @@ 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 } diff --git a/packages/d2b-unsafe-local-helper/src/environment.rs b/packages/d2b-unsafe-local-helper/src/environment.rs index b04fd2d5b..fbd3fe1bd 100644 --- a/packages/d2b-unsafe-local-helper/src/environment.rs +++ b/packages/d2b-unsafe-local-helper/src/environment.rs @@ -16,6 +16,7 @@ pub enum EnvironmentError { RuntimeDirectoryInvalid, ExecutableUnavailable, ProxyUnavailable, + WaylandUnavailable, } #[derive(Clone, PartialEq, Eq)] @@ -74,7 +75,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"); @@ -107,10 +108,23 @@ impl ManagerEnvironment { .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) @@ -132,6 +146,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()) @@ -184,13 +214,51 @@ 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 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/main.rs b/packages/d2b-unsafe-local-helper/src/main.rs index 3143f327b..d5a0f0e43 100644 --- a/packages/d2b-unsafe-local-helper/src/main.rs +++ b/packages/d2b-unsafe-local-helper/src/main.rs @@ -13,6 +13,8 @@ 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)] @@ -30,14 +32,20 @@ fn main() { } 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/protocol.rs b/packages/d2b-unsafe-local-helper/src/protocol.rs index 90eafb445..6171c6193 100644 --- a/packages/d2b-unsafe-local-helper/src/protocol.rs +++ b/packages/d2b-unsafe-local-helper/src/protocol.rs @@ -187,6 +187,7 @@ 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)) } }; @@ -501,6 +502,8 @@ fn failure_code(error: RuntimeError) -> HelperFailureCode { } 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, diff --git a/packages/d2b-unsafe-local-helper/src/runtime.rs b/packages/d2b-unsafe-local-helper/src/runtime.rs index 165789be1..fcdb27523 100644 --- a/packages/d2b-unsafe-local-helper/src/runtime.rs +++ b/packages/d2b-unsafe-local-helper/src/runtime.rs @@ -1,22 +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, HelperShellRequest, HelperShellResponse, HelperSnapshot, HelperSupervisorId, MAX_COMPLETED_OPERATION_AGE_SECS, MAX_COMPLETED_OPERATIONS_PER_UID, - MAX_HELPER_SNAPSHOT_SCOPES, + 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 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}; @@ -24,9 +34,13 @@ 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 { @@ -36,6 +50,8 @@ pub enum RuntimeError { EnvironmentInvalid, ExecutableUnavailable, ProxyUnavailable, + WaylandUnavailable, + FirstClientTimeout, ScopeCreateFailed, ScopeIdentityMismatch, OperationIdConflict, @@ -57,6 +73,7 @@ impl From for RuntimeError { Self::ExecutableUnavailable } EnvironmentError::ProxyUnavailable => Self::ProxyUnavailable, + EnvironmentError::WaylandUnavailable => Self::WaylandUnavailable, _ => Self::EnvironmentInvalid, } } @@ -157,6 +174,7 @@ pub struct ScopeRuntime { pub(crate) shell_home: PathBuf, pub(crate) uid: u32, pub(crate) executable: PathBuf, + pub(crate) wayland_proxy_binary: Option, } pub(crate) struct RuntimeLedger { @@ -397,12 +415,16 @@ impl fmt::Debug for ScopeRuntime { .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); @@ -414,7 +436,14 @@ impl ScopeRuntime { } let ledger_path = user_home.join(".local/state/d2b/unsafe-local-scopes.json"); let executable = std::env::current_exe().map_err(|_| RuntimeError::Internal)?; - Self::with_paths_and_executable(manager, user_home, ledger_path, executable) + 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( @@ -431,6 +460,16 @@ impl ScopeRuntime { 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 { @@ -450,6 +489,7 @@ impl ScopeRuntime { shell_home, uid, executable, + wayland_proxy_binary, }) } @@ -497,15 +537,59 @@ impl ScopeRuntime { fingerprint: [u8; 32], reservation: LaunchReservation, ) -> Result { - let environment = self.manager.manager_environment()?; + 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(); @@ -625,11 +709,44 @@ fn launch_fingerprint(request: &HelperLaunchRequest) -> Result<[u8; 32], Runtime &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 { @@ -637,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 { @@ -646,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() } } @@ -668,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)?; @@ -767,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) @@ -776,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 => { @@ -888,9 +1465,68 @@ mod tests { use super::*; 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, @@ -905,6 +1541,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(), } } @@ -1059,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_socket.rs b/packages/d2b-unsafe-local-helper/src/shell_socket.rs index 7cac9a158..5c7dacd50 100644 --- a/packages/d2b-unsafe-local-helper/src/shell_socket.rs +++ b/packages/d2b-unsafe-local-helper/src/shell_socket.rs @@ -31,10 +31,13 @@ pub(crate) fn validate_runtime_directory( } 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 - || metadata.permissions().mode() & 0o7777 != 0o700 + || mode & 0o700 != 0o700 + || mode & 0o027 != 0 + || mode & 0o7000 != 0 { return Err(ShellSocketError::RuntimeDirectoryInvalid); } @@ -217,16 +220,38 @@ mod tests { 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(); - fs::set_permissions(&directory, fs::Permissions::from_mode(0o755)).unwrap(); + 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!( - validate_runtime_directory(&directory, uid), - Err(ShellSocketError::RuntimeDirectoryInvalid) + supervisor_socket_path(&directory, &id), + Err(ShellSocketError::PathInvalid) ); - fs::remove_dir_all(directory).unwrap(); } #[test] 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/d2bd/src/lib.rs b/packages/d2bd/src/lib.rs index 0430943b0..9c3c052d1 100644 --- a/packages/d2bd/src/lib.rs +++ b/packages/d2bd/src/lib.rs @@ -3635,6 +3635,7 @@ fn dispatch_unsafe_local_launcher( 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 diff --git a/packages/d2bd/src/unsafe_local_helper.rs b/packages/d2bd/src/unsafe_local_helper.rs index 077c6f25a..a8c3ae99b 100644 --- a/packages/d2bd/src/unsafe_local_helper.rs +++ b/packages/d2bd/src/unsafe_local_helper.rs @@ -1345,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()) @@ -1385,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(), } } @@ -1502,6 +1505,36 @@ mod tests { )); } + #[test] + fn operation_ledger_rejects_reuse_with_changed_realm_accent() { + let mut ledger = OperationLedger::default(); + 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, + "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(); diff --git a/packages/d2bd/src/workload_dispatch.rs b/packages/d2bd/src/workload_dispatch.rs index 7443561a8..6835bb5d0 100644 --- a/packages/d2bd/src/workload_dispatch.rs +++ b/packages/d2bd/src/workload_dispatch.rs @@ -6,7 +6,7 @@ use std::{ use d2b_contracts::{ public_wire::{GraphicalLaunchPosture, ShellName, WorkloadAvailability, WorkloadPublicSummary}, - unsafe_local_wire::HelperShellPolicy, + unsafe_local_wire::{HelperShellPolicy, RealmAccentColor}, }; use d2b_core::{ bundle_resolver::BundleResolver, @@ -147,6 +147,7 @@ pub(crate) struct ResolvedExec { pub item_id: ProtocolToken, pub argv: ConfiguredArgv, pub graphical: bool, + pub realm_accent_color: RealmAccentColor, } #[derive(Debug, Clone)] @@ -335,6 +336,8 @@ impl WorkloadCatalog { 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)?, }) } @@ -826,6 +829,7 @@ mod tests { "argv comes from the private artifact" ); assert!(resolved.graphical); + assert_eq!(resolved.realm_accent_color.as_str(), "#336699"); assert!( matches!( (&resolved.route, provider), @@ -905,6 +909,19 @@ mod tests { 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!() 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/unit/nix/cases/realm-workloads.nix b/tests/unit/nix/cases/realm-workloads.nix index 1fa8e808a..fd64c5857 100644 --- a/tests/unit/nix/cases/realm-workloads.nix +++ b/tests/unit/nix/cases/realm-workloads.nix @@ -1113,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; @@ -1133,6 +1140,8 @@ 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" ]; From 976f92d21728bb4ea00d9c892f365495738bb0a8 Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:41:01 -0700 Subject: [PATCH 07/14] test: allow bounded shell socket cleanup (#299) Co-authored-by: John Vicondoa --- CHANGELOG.md | 5 +++++ packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a14656833..9d6ddc7b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ deprecations ship one minor release before removal. ## [Unreleased] +### Fixed + +- Stabilized shell-supervisor teardown coverage by allowing its asynchronous + socket cleanup the same bounded reconciliation horizon used by the runtime. + ## [1.4.0] - 2026-07-12 ### Added diff --git a/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs b/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs index 4026fcee3..4a8edb7a9 100644 --- a/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs +++ b/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs @@ -469,11 +469,14 @@ fn helper_runtime_creates_persists_and_reconstructs_real_supervisor() { killed.into_parts().0, UnsafeLocalHelperToDaemon::Shell(_) )); - let deadline = Instant::now() + Duration::from_secs(2); + 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)); + assert!( + !has_shell_socket(&scratch.path), + "shell supervisor socket survived the cleanup deadline" + ); } fn workload() -> WorkloadIdentity { From 2bd35a0bcb611a7d9cdf6a87b5609f166ef94322 Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:55:44 -0700 Subject: [PATCH 08/14] release: update prebuilt manifest for v1.4.0 (#298) Auto-generated by release-host-binaries.yml after publishing GitHub Release v1.4.0. Consumers: `nix flake update d2b` to pick up pre-built binaries. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: John Vicondoa --- nix/prebuilt.json | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/nix/prebuilt.json b/nix/prebuilt.json index d3137e2bb..8b71655bd 100644 --- a/nix/prebuilt.json +++ b/nix/prebuilt.json @@ -1,11 +1,30 @@ { - "version": "1.3.1", + "version": "1.4.0", "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.0/d2b-1.4.0-x86_64-linux.tar.gz", + "hash": "sha256-2BRAIc8DRG9bpaYHLUzwkhl56ZZC9VUtWPn3GqUpPNc=" + }, + "d2b-activation-helper": { + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.0/d2b-activation-helper-1.4.0-x86_64-linux.tar.gz", + "hash": "sha256-cGzwcipd/i0ouj3gtambTVhwA+2X0E6pm64ZiAKkCfk=" + }, + "d2b-priv-broker": { + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.0/d2b-priv-broker-1.4.0-x86_64-linux.tar.gz", + "hash": "sha256-cJDh84Cm9jqluzvbRKHbU0ASF18Oof/wCHqhD7rUzxs=" + }, + "d2b-unsafe-local-helper": { + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.0/d2b-unsafe-local-helper-1.4.0-x86_64-linux.tar.gz", + "hash": "sha256-egmrzD2lxdHydGpVHyMNRCMLBe2gdjMx604kiG5kGUQ=" + }, + "d2b-wayland-proxy": { + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.0/d2b-wayland-proxy-1.4.0-x86_64-linux.tar.gz", + "hash": "sha256-nTJV4QDLOyVl/BsM7l9Bl8OyO4SLYiRY8Rlvgmwk3K4=" + }, + "d2bd": { + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.0/d2bd-1.4.0-x86_64-linux.tar.gz", + "hash": "sha256-rM893EZS0h3N+LW+qdKDt8epatb0lOT/dyEFjtXCjCg=" + } } } From ee829bec1270a052ff98837b4b3a3424685d3676 Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:42:15 -0700 Subject: [PATCH 09/14] framework: reconcile provider and transport architecture (#296) Rename ADR 0045 to Provider and transport framework, define generic byte transports plus Noise/ttrpc component sessions, and preserve parent-owned controller and shared-fabric invariants. Also reconcile shell-supervisor cleanup and launch authorization regressions exposed by the latest-main integration. --- CHANGELOG.md | 20 +- ... 0045-provider-and-transport-framework.md} | 799 +++++++++++++++--- docs/adr/README.md | 2 +- docs/reference/privileges.md | 7 + docs/reference/schemas/v2/privileges.json | 1 + nixos-modules/privileges-json.nix | 13 + packages/d2b-core/src/privileges.rs | 10 + .../src/shell_runtime.rs | 10 +- .../src/shell_supervisor.rs | 67 +- .../tests/shell_supervisor.rs | 48 +- .../cli-output/auth-status-human.golden | 2 +- .../golden/cli-output/auth-status-json.golden | 1 + 12 files changed, 818 insertions(+), 162 deletions(-) rename docs/adr/{0045-workload-hosted-realm-controllers-and-relay-shortcuts.md => 0045-provider-and-transport-framework.md} (58%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d6ddc7b7..ecfef57c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,28 @@ deprecations ship one minor release before removal. ## [Unreleased] +### Added + +- Added proposed 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. + ### Fixed - Stabilized shell-supervisor teardown coverage by allowing its asynchronous - socket cleanup the same bounded reconciliation horizon used by the runtime. + 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 diff --git a/docs/adr/0045-workload-hosted-realm-controllers-and-relay-shortcuts.md b/docs/adr/0045-provider-and-transport-framework.md similarity index 58% rename from docs/adr/0045-workload-hosted-realm-controllers-and-relay-shortcuts.md rename to docs/adr/0045-provider-and-transport-framework.md index e4eaf84e5..99163ff4d 100644 --- a/docs/adr/0045-workload-hosted-realm-controllers-and-relay-shortcuts.md +++ b/docs/adr/0045-provider-and-transport-framework.md @@ -1,4 +1,4 @@ -# ADR 0045: Workload-hosted realm controllers and shared-relay shortcuts +# ADR 0045: Provider and transport framework - Status: Proposed - Date: 2026-07-10 @@ -8,7 +8,8 @@ (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) + (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) @@ -28,11 +29,20 @@ That leaves several ambiguities: - 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, or relay transport; + controller host, transport, protocol codec, node client, or daemon-access + adapter; - the existing `d2b.realms..providers` records do not distinguish those responsibilities; -- relay configuration says how a realm is reachable, but not which workload - owns interactive credentials or opens the connector; +- `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. @@ -77,7 +87,9 @@ D2b will use the following vocabulary: | --- | --- | | Workload runtime provider | Starts, stops, inspects, and executes one workload. | | Infrastructure provider | Provisions or adopts infrastructure on which workloads, including controller workloads, run. | -| Relay provider | Supplies rendezvous and byte transport for authenticated d2b peer sessions. | +| 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 @@ -86,7 +98,7 @@ 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 relay 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. @@ -115,7 +127,7 @@ The selected provider crate namespaces are: | `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-relay-` | `RelayProvider` | Opens relay sessions and creates or revokes scoped rendezvous bindings. | +| `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. | @@ -130,15 +142,17 @@ configured instance. The provider type is not repeated in that segment: - `d2b-provider-runtime-bubblewrap`; - `d2b-provider-runtime-azure-container-apps`; - `d2b-provider-infrastructure-azure-vm`; -- `d2b-provider-relay-azure`; +- `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-relay-azure` has -`ProviderType::Relay` and implementation id `azure`; its complete public -provider kind may still render as `azure-relay`. A configured deployment may -then assign instance ids such as `work-relay` or `payments-relay` without +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`, @@ -162,8 +176,7 @@ capability; `common`, `util`, `manager`, and an axis-free This type-first grammar supersedes ADR 0035's examples `d2b-provider-hypervisor-`, `d2b-provider-`, and `d2b-constellation-transport-`. Hypervisors are runtime providers and -relay transports are relay providers, so they sort under the same type axes as -their peers. +relay, Unix-stream, and vsock transports sort under the common transport axis. ### Every provider implements a standard base interface @@ -221,6 +234,13 @@ pub trait Provider: Send + Sync { } ``` +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: @@ -240,7 +260,7 @@ The closed primary provider types are: pub enum ProviderType { Runtime, Infrastructure, - Relay, + Transport, Substrate, Credential, Display, @@ -271,11 +291,34 @@ The specialized interfaces extend `Provider`: | --- | --- | | `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. | -| `RelayProvider` | Capability description; connect/listen; issue scoped rendezvous binding; revoke binding; inspect transport health. | +| `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 @@ -283,6 +326,43 @@ sandboxes, provider-managed sandboxes, and remote VM runtimes implement one 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: @@ -344,19 +424,254 @@ 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`, in-process traits/registries into `d2b-provider`, and mocks/conformance into `d2b-provider-testkit`. | +| `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-relay-azure` | +| `d2b-provider-relay` | `d2b-provider-transport-azure-relay` | | Provider conformance code in production crates | `d2b-provider-testkit` | -| Loopback relay implementation used only by tests | `d2b-provider-testkit` | -| Provider implementations in `d2b-realm-transport` | Move to the matching `d2b-provider-relay-` crate; protocol-neutral session DTOs remain in realm-core. | +| 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. | @@ -372,6 +687,10 @@ 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: @@ -402,6 +721,7 @@ identifier here does not claim current support. | 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 @@ -471,11 +791,6 @@ parent-owned declaration carries a typed controller role: d2b.realms.local-root.workloads.work-controller = { kind = "local-vm"; - roles.realmController = { - enable = true; - forRealm = "work"; - }; - localVm = { autostart = true; tpm.enable = true; @@ -486,27 +801,28 @@ d2b.realms.local-root.workloads.work-controller = { d2b.realms.work = { parent = "local-root"; + controller.workload = "work-controller.local-root.d2b"; }; ``` -The option spelling above is the selected public shape. Implementation may add -typed sub-options, but it must not replace the role with an arbitrary command, -free-form service definition, or provider-specific controller option. +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. The controller workload is owned by the direct parent realm of - `forRealm`. +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. Exactly one controller workload is declared for a realm. 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. -4. `roles.realmController.forRealm` is a scalar realm path. Lists are rejected - at evaluation, so one declaration cannot collapse multiple realm credential - domains. +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 @@ -524,7 +840,7 @@ 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 relay configuration references; +- 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; @@ -549,6 +865,9 @@ 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, @@ -587,12 +906,9 @@ d2b.realms.local-root.runtimeProviders.azure-controller = { d2b.realms.local-root.workloads.work-controller = { kind = "provider-managed"; provider = "azure-controller"; - - roles.realmController = { - enable = true; - forRealm = "work"; - }; }; + +d2b.realms.work.controller.workload = "work-controller.local-root.d2b"; ``` `provider-managed` is the selected generic workload configuration variant for a @@ -638,11 +954,11 @@ 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 -handshake. 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. +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 relay-connector placement is derived +### Provider-agent and transport-connector placement is derived Three responsibilities may be co-located but are not the same role: @@ -650,17 +966,29 @@ Three responsibilities may be co-located but are not the same role: | --- | --- | | Realm controller | Realm policy, registry, audit, routing, and semantic operations. | | Infrastructure provider executor | Provider API calls and lifecycle of provider-hosted workloads. | -| Relay connector | Relay authentication and outbound transport session. | +| Transport connector | Provider-specific transport authentication and outbound session establishment. | -Only `roles.realmController` is asserted directly by a generic workload. -Infrastructure-provider and relay-connector behavior is derived from the -provider or relay binding that references the executor workload. This avoids -two independently configurable declarations claiming the same authority. +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 Relay connector while the remote VM owns -the realm controller: +may remain the provider executor and Azure Relay transport connector while the +remote VM owns the realm controller: ```text local parent realm @@ -677,28 +1005,28 @@ 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 relay configuration remains the transport source of truth +### Realm transport configuration is the connectivity source of truth -Relay placement is configured from `d2b.realms..relay`; a separate +Connectivity is configured from `d2b.realms..transport`; a separate gateway or realm-entrypoint object is not introduced. -The relay schema is extended conceptually as follows. `relay.provider` is a -reference to a typed `relayProviders` instance, not an inline provider kind: +`transport.provider` is a reference to a typed `transportProviders` instance, +not an inline provider kind: ```nix -d2b.realms.work.relayProviders.azure-work = { +d2b.realms.work.transportProviders.azure-work = { kind = "azure-relay"; connector = { workload = "work-connector.local-root.d2b"; credentialRef = "entra-work-relay"; - authentication = "entra-user"; + underlayAuthentication = "entra-user"; }; - controllerAuthentication = "managed-identity"; + controllerUnderlayAuthentication = "managed-identity"; }; -d2b.realms.work.relay = { +d2b.realms.work.transport = { enable = true; provider = "azure-work"; fabricRef = "work-relay"; @@ -706,27 +1034,31 @@ d2b.realms.work.relay = { peerShortcuts.enable = true; }; -d2b.realms.payments.relay.inheritFrom = "work"; +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 relay 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. +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-relay peer shortcuts separate control and data paths +### Shared-transport peer shortcuts separate control and data paths -Strict tree routing remains the authorization model. Shared-relay shortcuts +Strict tree routing remains the authorization model. Shared-transport shortcuts optimize only the data path: ```text @@ -734,7 +1066,7 @@ control: source -> source controller -> applicable ancestors -> target controller data after authorization: - source peer -> shared relay fabric -> target peer + source peer -> shared transport fabric -> target peer ``` Every applicable source, ancestor, and target policy is evaluated before a @@ -753,13 +1085,13 @@ The grant is scoped to: - issue and expiry times; - a replay nonce. -The grant contains no token cache, Relay credential, raw endpoint, provider +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 relay adapter supplies an opaque, one-time rendezvous binding -below the policy DTO. Session keys and d2b identities authenticate and encrypt -the stream end to end; the relay authenticates transport access only. +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: @@ -790,8 +1122,8 @@ 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 relay and must not drive security policy, -billing, exfiltration detection, or compliance conclusions. A relay provider +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. @@ -804,7 +1136,7 @@ generalizes that foundation to peer transport shortcuts: ```text PeerShortcutTransport = native-direct - | shared-relay + | shared-fabric ``` Authorization metadata remains transport-address-free. Provider-specific @@ -813,44 +1145,45 @@ 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-relay` shortcut is -allowed only when both peers already participate in the same configured relay -fabric and the relay provider advertises shortcut support. It does not discover -or construct a new network path. +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 relay provider advertising `active-shortcut-revoke-v1` must close -the established relay binding when the authorizing ancestor revokes it, while +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-relay shortcuts only with a maximum 60-second session grant and -policy-authorized renewal; expiration closes the stream before renewal. Relay -token or listener revocation that affects only future connections is -insufficient by itself. +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 relay path or fails with a typed transport error. There is no +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 relay +### 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 relay fabric. +only their controllers, to participate in the transport fabric. -A workload may advertise `relay-client-v1` only when its runtime supplies a +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 relay access grant or the workload authenticates independently -through a provider-supported identity such as Azure Managed Identity. +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 Relay credential; +- 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 relay provider cannot issue or validate a workload-scoped transport -identity, that workload cannot use a direct shared-relay shortcut. Its traffic +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 @@ -914,9 +1247,11 @@ 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. Labels may include provider type, -realm class, and result class, but never a user identity, token subject, RP id, -or provider endpoint. +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 @@ -929,25 +1264,78 @@ 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 relay-connector peer identities are +2. Controller, provider-executor, and transport-connector peer identities are authenticated before any state lookup, token resolution, provisioning, or route publication. -3. Provider and Relay tokens stay in the configured credential-owning workload. -4. Entra, managed identity, and Relay identities are never mapped to local - daemon roles or broker authorization. +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. One shared relay fabric does not merge realm policy, identity, audit, or - credential domains. +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 Relay endpoints, provider resource ids, token subjects, user ids, device - ids, command data, and stream payloads are forbidden as metric labels. +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 @@ -971,22 +1359,29 @@ 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. +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 Relay connector is unavailable, the remote realm may continue +- 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 Relay is unavailable, no provider-native, SSH, or direct-network fallback - is attempted unless a separately configured and authorized transport exists. +- 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. @@ -996,19 +1391,24 @@ operations are included in the retained/exported audit sequence. 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..workloads..roles.realmController`; +- `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..relay` provider, fabric inheritance, connector, and +- `d2b.realms..transport` provider, fabric inheritance, connector, and shortcut policy; -- workload, controller, provider-executor, and relay-client capability +- 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, and relay status output; +- realm-controller, workload, session, and transport status output; - bundle artifacts, generated schemas, reference documentation, and migration guidance. @@ -1019,6 +1419,23 @@ 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: @@ -1029,9 +1446,15 @@ Implementation is incomplete without: `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 @@ -1039,18 +1462,50 @@ Implementation is incomplete without: - 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 `forRealm`, provider capability gating, typed - relay-provider references, ancestor-only relay inheritance, and derived - placement; + 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 @@ -1064,20 +1519,20 @@ Implementation is incomplete without: self-managed user namespace modes; - provider-executor tests proving credentials are resolved only inside the selected workload; -- Relay inheritance tests proving nested realms receive distinct identities and - no copied credential references; -- route-engine tests for shared-relay shortcut authorization, replay, +- 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 relay route or returns a typed transport error without + 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 relay-authenticated peer is not local `Admin`; -- negative tests proving no remote realm, workload, relay, or provider peer can - receive or invoke the local privileged broker wire protocol; +- 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 @@ -1088,10 +1543,16 @@ Implementation is incomplete without: 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; -- redaction tests covering provider ids, endpoints, Entra identities, Azure - resource ids, and shortcut metadata. + 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 @@ -1102,14 +1563,18 @@ Implementation is incomplete without: 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, Relay, and controller responsibilities have explicit credential and - authority boundaries. -- Nested realms can share one relay fabric without forcing stream bytes through - every controller. +- 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 relay transport binding. + provider-neutral transport binding. - One physical FIDO key can serve multiple work VMs while each retains an independent Entra session. @@ -1119,11 +1584,16 @@ Implementation is incomplete without: 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 relay-capable guest agents and scoped +- Direct workload shortcuts require transport-capable guest agents and scoped transport identities. -- Shared-relay revocation and audit are more complex than hop-by-hop byte +- 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. @@ -1162,6 +1632,55 @@ 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, @@ -1197,14 +1716,14 @@ 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 Relay membership as authorization +### Treat shared transport membership as authorization -Rejected. Relay credentials establish transport access only. Realm keys, +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-relay shortcuts are operation-scoped and do not +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 110119359..e97eb9fc1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -49,4 +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. Workload-hosted realm controllers and shared-relay shortcuts](0045-workload-hosted-realm-controllers-and-relay-shortcuts.md) | Proposed | 2026-07-10 | Models gateway and remote realm controllers as parent-owned workloads, gives provider crates sortable type-first names and standard interfaces backed by `d2b-contracts`, and authorizes direct peer streams over inherited shared relay fabrics without bypassing realm-tree policy. | +| [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/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/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/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/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-unsafe-local-helper/src/shell_runtime.rs b/packages/d2b-unsafe-local-helper/src/shell_runtime.rs index 153348b92..21a54e546 100644 --- a/packages/d2b-unsafe-local-helper/src/shell_runtime.rs +++ b/packages/d2b-unsafe-local-helper/src/shell_runtime.rs @@ -541,12 +541,10 @@ fn kill( } let killed = match inspection.state { HelperScopeState::Starting | HelperScopeState::Active => { - let response = supervisor_action(runtime, &scope, SupervisorAction::Kill)?; - if !matches!(response.result, SupervisorResult::KillAccepted) { - return Err(RuntimeError::ShellUnavailable); - } - // The verified supervisor has acknowledged Kill. If systemd drops - // the now-empty scope before collection, do not signal a replacement. + // 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 { diff --git a/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs b/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs index 2720a79ee..cf3d03f73 100644 --- a/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs +++ b/packages/d2b-unsafe-local-helper/src/shell_supervisor.rs @@ -222,6 +222,7 @@ struct SupervisorState { attachment: Mutex>, next_attachment: AtomicU64, kill_requested: AtomicBool, + kill_wakeup: UnixStream, stdin_closed: AtomicBool, terminal_workers: Arc<(Mutex, Condvar)>, control_connections: AtomicUsize, @@ -240,7 +241,7 @@ impl fmt::Debug for SupervisorState { } impl SupervisorState { - fn new(master: OwnedFd, ring: Arc) -> Self { + fn new(master: OwnedFd, ring: Arc, kill_wakeup: UnixStream) -> Self { Self { ring, master: Mutex::new(Some(master)), @@ -249,6 +250,7 @@ impl SupervisorState { 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), @@ -364,6 +366,8 @@ impl SupervisorState { 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(); } @@ -464,13 +468,29 @@ pub(crate) fn run_shell_supervisor() -> Result<(), ShellSupervisorError> { ioctl_fionbio(&master, true).map_err(|_| ShellSupervisorError::RuntimeUnavailable)?; let ring = Arc::new(OutputRing::new(spec.output_ring_bytes).ok_or(ShellSupervisorError::InvalidSpec)?); - let state = Arc::new(SupervisorState::new(master, Arc::clone(&ring))); + 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_readable(listener.listener().as_fd())?; + 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, _)) => { @@ -508,6 +528,7 @@ pub(crate) fn run_shell_supervisor() -> Result<(), ShellSupervisorError> { }), } } + drop(listener); let deadline = Instant::now() + Duration::from_millis(250); while Instant::now() < deadline { match child.try_wait() { @@ -519,10 +540,41 @@ pub(crate) fn run_shell_supervisor() -> Result<(), ShellSupervisorError> { Err(_) => break, } } - drop(listener); 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)?; @@ -1122,7 +1174,12 @@ mod tests { fn input_offsets_and_control_sequences_are_strictly_monotonic() { let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC).unwrap(); - let state = SupervisorState::new(master, Arc::new(OutputRing::new(1024).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 { diff --git a/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs b/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs index 4a8edb7a9..e297074dd 100644 --- a/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs +++ b/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs @@ -368,10 +368,22 @@ fn real_supervisor_preserves_pty_across_reconnect_and_kills_exact_scope() { #[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 scratch = Scratch::new(); let user = get_user_by_uid(get_current_uid()).unwrap(); let mut environment = BTreeMap::from([ ( @@ -408,8 +420,9 @@ fn helper_runtime_creates_persists_and_reconstructs_real_supervisor() { ) .unwrap(); let workload = workload(); + let create_operation = format!("op-runtime-create-{operation_suffix}"); let created = runtime - .shell(attach_request("op-runtime-create", workload.clone(), false)) + .shell(attach_request(&create_operation, workload.clone(), false)) .unwrap(); let (frame, fd) = created.into_parts(); assert!(matches!(frame, UnsafeLocalHelperToDaemon::TerminalReady(_))); @@ -446,20 +459,24 @@ fn helper_runtime_creates_persists_and_reconstructs_real_supervisor() { 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( - "op-runtime-reattach", - workload.clone(), - true, - )) + .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("op-runtime-kill").unwrap(), + operation_id: OperationId::parse(kill_operation).unwrap(), workload, policy: shell_policy(), name: ShellName::new("host").unwrap(), @@ -469,6 +486,7 @@ fn helper_runtime_creates_persists_and_reconstructs_real_supervisor() { 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)); @@ -525,6 +543,20 @@ fn has_shell_socket(directory: &Path) -> bool { }) } +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 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", From 2d3c2c8ea56c40abed220f9c0d890b39fed2246b Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:22:07 -0700 Subject: [PATCH 10/14] test: stabilize mkfs and output-ring assertions (#300) * test: isolate mkfs diagnostic bounds * test: separate output and EOF wakeups * test: confirm EOF at the returned cursor --------- Co-authored-by: John Vicondoa --- CHANGELOG.md | 5 +++++ packages/d2b-priv-broker/src/ops/disk_init.rs | 4 +--- packages/d2b-unsafe-local-helper/src/output_ring.rs | 9 ++++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecfef57c3..2e2ed4ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ deprecations ship one minor release before removal. ### Fixed +- 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. + - 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 diff --git a/packages/d2b-priv-broker/src/ops/disk_init.rs b/packages/d2b-priv-broker/src/ops/disk_init.rs index 0831e4c0f..dd0316239 100644 --- a/packages/d2b-priv-broker/src/ops/disk_init.rs +++ b/packages/d2b-priv-broker/src/ops/disk_init.rs @@ -1208,12 +1208,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/src/output_ring.rs b/packages/d2b-unsafe-local-helper/src/output_ring.rs index e9cd66cff..ec32fc3c7 100644 --- a/packages/d2b-unsafe-local-helper/src/output_ring.rs +++ b/packages/d2b-unsafe-local-helper/src/output_ring.rs @@ -173,7 +173,7 @@ mod tests { } #[test] - fn wait_wakes_for_output_and_eof() { + 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 || { @@ -182,10 +182,13 @@ mod tests { producer.close(); }); let read = ring.read(0, 32, true, Duration::from_secs(1)); - writer.join().unwrap(); assert_eq!(read.data, b"ready"); - assert!(read.eof); 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] From f7cf274a9f974089e5d91c35690d89624b055365 Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:54:21 -0700 Subject: [PATCH 11/14] test: isolate parallel scratch and fd identity (#301) * test: isolate mkfs diagnostic bounds * test: separate output and EOF wakeups * test: confirm EOF at the returned cursor * test: isolate parallel scratch and fd identity --------- Co-authored-by: John Vicondoa --- CHANGELOG.md | 4 ++++ packages/d2b-priv-broker/src/ops/disk_init.rs | 24 +++++++++++-------- .../d2b-unsafe-local-helper/src/protocol.rs | 11 ++++++++- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e2ed4ef8..db8339b74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ deprecations ship one minor release before removal. 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, diff --git a/packages/d2b-priv-broker/src/ops/disk_init.rs b/packages/d2b-priv-broker/src/ops/disk_init.rs index dd0316239..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 { diff --git a/packages/d2b-unsafe-local-helper/src/protocol.rs b/packages/d2b-unsafe-local-helper/src/protocol.rs index 6171c6193..0589853c3 100644 --- a/packages/d2b-unsafe-local-helper/src/protocol.rs +++ b/packages/d2b-unsafe-local-helper/src/protocol.rs @@ -721,6 +721,8 @@ mod tests { 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 { @@ -729,6 +731,13 @@ mod tests { }, ); assert_eq!(result, Err(ProtocolError::ConnectFailed)); - assert!(!std::path::Path::new(&format!("/proc/self/fd/{raw}")).exists()); + 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}"), + } } } From 9bfbf38f783a90c345df946ed6d7d1fea1f33168 Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:14:55 -0700 Subject: [PATCH 12/14] unsafe-local: restore host shell profiles and release 1.4.1 (#302) * unsafe-local: fix persistent shell terminal environment * release: prepare 1.4.1 * test: tolerate unrelated fd number reuse --------- Co-authored-by: John Vicondoa --- CHANGELOG.md | 8 +++- .../src/environment.rs | 42 +++++++++++++++++++ .../src/shell_runtime.rs | 2 +- .../tests/shell_supervisor.rs | 25 +++++++++-- 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db8339b74..b745d112a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ deprecations ship one minor release before removal. ## [Unreleased] +## [1.4.1] - 2026-07-12 + ### Added -- Added proposed ADR 0045, defining parent-owned workload-hosted realm +- 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; @@ -22,6 +24,9 @@ deprecations ship one minor release before removal. ### Fixed +- 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 @@ -30,7 +35,6 @@ deprecations ship one minor release before removal. 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 diff --git a/packages/d2b-unsafe-local-helper/src/environment.rs b/packages/d2b-unsafe-local-helper/src/environment.rs index fbd3fe1bd..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 { @@ -84,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") @@ -240,6 +252,36 @@ mod tests { } } + #[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 { diff --git a/packages/d2b-unsafe-local-helper/src/shell_runtime.rs b/packages/d2b-unsafe-local-helper/src/shell_runtime.rs index 21a54e546..47b937560 100644 --- a/packages/d2b-unsafe-local-helper/src/shell_runtime.rs +++ b/packages/d2b-unsafe-local-helper/src/shell_runtime.rs @@ -412,7 +412,7 @@ fn create_and_attach( if user.home_dir() != runtime.shell_home || !user.shell().is_absolute() { return Err(RuntimeError::InvalidIdentity); } - let child_environment = environment.child_entries(false, None)?; + let child_environment = environment.persistent_shell_entries(); let supervisor_id = random_supervisor_id()?; supervisor_socket_path(&runtime_directory, &supervisor_id) .map_err(|_| RuntimeError::EnvironmentInvalid)?; diff --git a/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs b/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs index e297074dd..7673c4dd7 100644 --- a/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs +++ b/packages/d2b-unsafe-local-helper/tests/shell_supervisor.rs @@ -400,6 +400,8 @@ fn exercise_helper_runtime_reconstruction(scratch: &Scratch, operation_suffix: & 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 @@ -430,7 +432,7 @@ fn exercise_helper_runtime_reconstruction(scratch: &Scratch, operation_suffix: & terminal .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); - let command = b"printf 'D2B_RUNTIME_ADOPT\\n'\n"; + 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 { @@ -441,12 +443,27 @@ fn exercise_helper_runtime_reconstruction(scratch: &Scratch, operation_suffix: & }), ); let _ = read_terminal_frame(&mut terminal); - let (_, output) = read_until(&mut terminal, 2, 0, b"D2B_RUNTIME_ADOPT"); + 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_ADOPT".len()) - .any(|window| window == b"D2B_RUNTIME_ADOPT") + .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( From 9cc7db2af3181a555de97a7e4354d627908c1950 Mon Sep 17 00:00:00 2001 From: John Vicondoa <1145714+vicondoa@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:51:32 -0700 Subject: [PATCH 13/14] release: update prebuilt manifest for v1.4.1 (#303) Auto-generated by release-host-binaries.yml after publishing GitHub Release v1.4.1. Consumers: `nix flake update d2b` to pick up pre-built binaries. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- nix/prebuilt.json | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/nix/prebuilt.json b/nix/prebuilt.json index 8b71655bd..85e5a88ca 100644 --- a/nix/prebuilt.json +++ b/nix/prebuilt.json @@ -1,30 +1,30 @@ { - "version": "1.4.0", + "version": "1.4.1", "system": "x86_64-linux", "binaries": { "d2b": { - "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.0/d2b-1.4.0-x86_64-linux.tar.gz", - "hash": "sha256-2BRAIc8DRG9bpaYHLUzwkhl56ZZC9VUtWPn3GqUpPNc=" + "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.0/d2b-activation-helper-1.4.0-x86_64-linux.tar.gz", - "hash": "sha256-cGzwcipd/i0ouj3gtambTVhwA+2X0E6pm64ZiAKkCfk=" + "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.0/d2b-priv-broker-1.4.0-x86_64-linux.tar.gz", - "hash": "sha256-cJDh84Cm9jqluzvbRKHbU0ASF18Oof/wCHqhD7rUzxs=" + "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.0/d2b-unsafe-local-helper-1.4.0-x86_64-linux.tar.gz", - "hash": "sha256-egmrzD2lxdHydGpVHyMNRCMLBe2gdjMx604kiG5kGUQ=" + "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.0/d2b-wayland-proxy-1.4.0-x86_64-linux.tar.gz", - "hash": "sha256-nTJV4QDLOyVl/BsM7l9Bl8OyO4SLYiRY8Rlvgmwk3K4=" + "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.0/d2bd-1.4.0-x86_64-linux.tar.gz", - "hash": "sha256-rM893EZS0h3N+LW+qdKDt8epatb0lOT/dyEFjtXCjCg=" + "url": "https://github.com/vicondoa/d2b/releases/download/v1.4.1/d2bd-1.4.1-x86_64-linux.tar.gz", + "hash": "sha256-8ccn5AiYjiwW8Sjally4pWYPwIzjC34sybKfXoXxujk=" } } } From 7fe85b1ac2047de0c84907370c6a21bebb4ef15b Mon Sep 17 00:00:00 2001 From: John Vicondoa Date: Fri, 24 Jul 2026 16:45:11 -0700 Subject: [PATCH 14/14] ci: enable v3 pull request gates --- .github/workflows/eval-with-entra-id.yml | 4 ++-- .github/workflows/pr-eval-shell-tests.yml | 4 ++-- .github/workflows/pr-l1-static-fast.yml | 4 ++-- CHANGELOG.md | 5 +++++ packages/xtask/tests/policy_ci.rs | 21 +++++++++++++++++++++ tests/ci/layer1-workflow.template.yml | 4 ++-- 6 files changed, 34 insertions(+), 8 deletions(-) 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/CHANGELOG.md b/CHANGELOG.md index b745d112a..520104f5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ deprecations ship one minor release before removal. ## [Unreleased] +### Changed + +- Enabled the required Layer-1, eval-shell, and Entra example PR gates for + changes targeting the `v3` branch as well as `main`. + ## [1.4.1] - 2026-07-12 ### Added 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