From 84b88c80e8a7ebf60d649c23af2fff57866aa608 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:23:13 +0100 Subject: [PATCH 1/4] test(web): exercise isolated fixture instance routing agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/package.json | 1 + .../scripts/fixture-instance-routing.mjs | 106 ++++++++++++++++++ docs/fractal-web.md | 15 +++ .../fixture-instance-web-routing.json | 11 ++ scripts/ci-fractal-web | 1 + 5 files changed, 134 insertions(+) create mode 100644 apps/fractal-web/scripts/fixture-instance-routing.mjs create mode 100644 release-notes/fixture-instance-web-routing.json diff --git a/apps/fractal-web/package.json b/apps/fractal-web/package.json index f13e7282320..06d381c89e4 100644 --- a/apps/fractal-web/package.json +++ b/apps/fractal-web/package.json @@ -6,6 +6,7 @@ "scripts": { "test": "vitest run", "test:embed": "node scripts/embed-proof.mjs", + "test:fixture-routing": "node scripts/fixture-instance-routing.mjs", "changelog:registry": "node scripts/changelog-registry.mjs", "changelog:registry:check": "node scripts/changelog-registry.mjs --check", "typecheck": "node scripts/changelog-registry.mjs && tsc --noEmit", diff --git a/apps/fractal-web/scripts/fixture-instance-routing.mjs b/apps/fractal-web/scripts/fixture-instance-routing.mjs new file mode 100644 index 00000000000..e6b1c8103e9 --- /dev/null +++ b/apps/fractal-web/scripts/fixture-instance-routing.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import { once } from 'node:events' +import { createServer } from 'node:http' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createServer as createViteServer } from 'vite' + +// Exercise the real web app and its production gateway middleware, not fixtureSource. +// The two Unix HTTP servers are transport test doubles, not initialized daemon instances. +const app = fileURLToPath(new URL('..', import.meta.url)) +const scratch = fileURLToPath(new URL('../../../tmp/fixture-instance-routing/', import.meta.url)) +await mkdir(scratch, { recursive: true }) +const root = await mkdtemp(join(scratch, 'routing-')) +const authorization = ['Bearer', 'fixture-routing-test'].join(' ') +const calls = { fixture: [], normal: [] } +const gateways = [] +let web, browser +const saved = Object.fromEntries(['WF_ST_GATEWAY', 'WF_ST_AUTHORIZATION', 'WF_ST_GATEWAY_HOST'].map(key => [key, process.env[key]])) +const previousCwd = process.cwd() + +async function gateway(kind) { + const server = createServer((request, response) => { + calls[kind].push({ path: request.url, authorization: request.headers.authorization }) + response.setHeader('content-type', 'application/json') + if (kind === 'normal' || request.headers.authorization !== authorization) { + response.writeHead(403) + response.end(JSON.stringify({ error: 'fixture-credential-refused' })) + } else { + response.end(JSON.stringify({ instance: 'client-control-fixture', capabilities: [] })) + } + }) + gateways.push(server) + const socket = join(root, `${kind}-client.sock`) + server.listen(socket) + await once(server, 'listening') + return socket +} + +async function serve(socket) { + process.env.WF_ST_GATEWAY = `unix:${socket}` + process.env.WF_ST_AUTHORIZATION = authorization + delete process.env.WF_ST_GATEWAY_HOST + web = await createViteServer({ + configFile: `${app}/vite.config.ts`, configLoader: 'runner', + server: { host: '127.0.0.1', port: 0, open: false }, + }) + await web.listen() + const address = web.httpServer.address() + assert(address && typeof address === 'object') + return `http://127.0.0.1:${address.port}` +} + +try { + const fixtureSocket = await gateway('fixture') + const normalSocket = await gateway('normal') + process.chdir(app) + const origin = await serve(fixtureSocket) + const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright') + browser = await chromium.launch({ ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) }) + // Fresh, nonpersistent context: never borrow a normal paired browser profile. + const context = await browser.newContext() + const page = await context.newPage() + await page.goto(origin, { waitUntil: 'domcontentloaded' }) + assert.equal(new URL(page.url()).origin, origin) + assert.equal(await page.locator('#root').count(), 1) + const routed = await page.evaluate(async () => { + const response = await fetch('/v1/client/capabilities') + return { status: response.status, body: await response.json() } + }) + assert.deepEqual(routed, { status: 200, body: { instance: 'client-control-fixture', capabilities: [] } }) + assert(calls.fixture.some(call => call.path === '/v1/client/capabilities' && call.authorization === authorization)) + assert.deepEqual(calls.normal, [], 'Fixture web origin must never reach the normal client service') + const privileged = await page.evaluate(async () => (await fetch('/v1/status')).status) + assert.equal(privileged, 404, 'The web boundary must not proxy privileged API routes') + await context.close() + await web.close() + web = undefined + + // Negative control: the same fixture credential routed to the normal service is refused. + const normalOrigin = await serve(normalSocket) + const refusedContext = await browser.newContext() + const refusedPage = await refusedContext.newPage() + await refusedPage.goto(normalOrigin, { waitUntil: 'domcontentloaded' }) + const refused = await refusedPage.evaluate(async () => { + const response = await fetch('/v1/client/capabilities') + return { status: response.status, body: await response.json() } + }) + assert.deepEqual(refused, { status: 403, body: { error: 'fixture-credential-refused' } }) + assert(calls.normal.some(call => call.path === '/v1/client/capabilities' && call.authorization === authorization)) + await refusedContext.close() + console.log(JSON.stringify({ check: 'fixture-instance-real-web-routing', fixtureOrigin: origin, normalOrigin, fixtureRouted: true, normalRefused: true, privilegedRouteExcluded: true, transport: 'unix-test-double', embeddedFixtureSource: false })) +} finally { + await browser?.close() + await web?.close() + for (const gateway of gateways) { + gateway.closeAllConnections() + await new Promise(resolve => gateway.close(resolve)) + } + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + process.chdir(previousCwd) + await rm(root, { recursive: true, force: true }) +} diff --git a/docs/fractal-web.md b/docs/fractal-web.md index f21f2d9f032..e84a9964ecc 100644 --- a/docs/fractal-web.md +++ b/docs/fractal-web.md @@ -554,6 +554,21 @@ checks Retry reuses the key, and prints build revision, seat and timings. The ne removes the submit binding and proves the send criterion rejects it without issuing mail. Both commands skip all live activity when the one-minute `/proc/loadavg` value is 32 or higher. +## Fixture-instance routing proof + +`pnpm --dir apps/fractal-web test:fixture-routing` starts the real Vite web app +on dedicated ephemeral loopback origins with `WF_ST_GATEWAY` selecting a Unix +client socket. It uses fresh nonpersistent Playwright contexts, not the +`fixtureSource` embed or a normal paired browser profile. + +The proof checks that browser API traffic reaches only the configured fixture +socket, privileged routes are excluded, and a fixture credential routed to the +normal socket receives a refusal. Its Unix HTTP endpoints are transport test +doubles: this is a real-web routing test, not evidence of live daemon +authorization or Fabric peer grants. It creates no daemon instance, pairing, +enrollment or exposure. Live fixture acceptance additionally needs independently +reviewed Fabric allow/deny readbacks and the fixture store's own receipts. + ## Fractal web alert strip live proof `node apps/fractal-web/scripts/alerts-live-proof.mjs [run|up|down]` proves the conversation diff --git a/release-notes/fixture-instance-web-routing.json b/release-notes/fixture-instance-web-routing.json new file mode 100644 index 00000000000..38716ccf573 --- /dev/null +++ b/release-notes/fixture-instance-web-routing.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Add a real-web fixture-instance routing proof using isolated loopback origins and browser contexts.", + "replay": { "status": "none", "detail": "The proof changes no stored claims or projections." }, + "schema": { "status": "none", "detail": "No protocol schema changes." }, + "rules": { "status": "none", "detail": "The proof exercises existing paired-client routing and privileged-route exclusion." }, + "compatibility": { "status": "none", "detail": "Existing web deployments and their gateway selection are unchanged." }, + "downtime": { "status": "none", "detail": "No service restart or deployment is required." }, + "manual": { "status": "none", "detail": "The optional proof needs the web development environment and Playwright Chromium." }, + "recovery": { "status": "none", "detail": "Removing the proof does not alter runtime state." } +} diff --git a/scripts/ci-fractal-web b/scripts/ci-fractal-web index b754cca6e93..b6ed0d3a312 100755 --- a/scripts/ci-fractal-web +++ b/scripts/ci-fractal-web @@ -177,6 +177,7 @@ exclusive_lanes() { if first_shard; then # Replay covers the app even when no kit inputs changed; never duplicate it across kit shards. lane "App replay browser e2e" node apps/fractal-web/scripts/replay-e2e.mjs + lane "Fixture instance real-web routing" node apps/fractal-web/scripts/fixture-instance-routing.mjs lane "SDK frame CPU budgets" pnpm --dir packages/fractal-sdk exec vitest run --config vitest.perf.config.ts fi if [ "${KIT_INPUTS:-true}" = true ]; then From 54827bacd8f73615e05cd24f9edbf33c653f4961 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:29:41 +0100 Subject: [PATCH 2/4] feat(instance): enforce local-only fixture network boundary agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-api-core/src/api.rs | 13 ++ crates/fractal-api-core/src/api/local_only.rs | 98 ++++++++++++ crates/fractal-kernel/src/config.rs | 4 + .../fractal-kernel/src/config/local_only.rs | 142 ++++++++++++++++++ crates/fractal-peer/src/peer.rs | 7 + .../fractal-peer/src/peer/local_only_tests.rs | 42 ++++++ crates/fractal/src/fleet_cli.rs | 2 + crates/fractal/src/main.rs | 1 + docs/st3/instances.md | 24 +++ .../client-control-fixture-local-only.json | 11 ++ 10 files changed, 344 insertions(+) create mode 100644 crates/fractal-api-core/src/api/local_only.rs create mode 100644 crates/fractal-kernel/src/config/local_only.rs create mode 100644 crates/fractal-peer/src/peer/local_only_tests.rs create mode 100644 release-notes/client-control-fixture-local-only.json diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index bc24089dc22..b52ca4da4de 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -189,6 +189,7 @@ pub mod records; mod write_principal; mod native_delivery; pub mod replication; +mod local_only; mod runs; pub mod sessions; pub mod work; @@ -1058,6 +1059,7 @@ pub fn router_for_transport(state: AppState, transport: ClientTransportBoundary, (state.clone(), transport), response_envelope, )) + .layer(from_fn_with_state(state.clone(), local_only::enforce)) .with_state(state) } @@ -6709,6 +6711,9 @@ pub fn remote_unavailable(host: &str) -> ApiError { /// A known dial-out owner syncs outward but has no inbound owner transport. pub fn remote_unavailable_for_owner(state: &AppState, host: &str) -> ApiError { + if state.local_only() { + return local_only::refusal(crate::config::NetworkOperation::PeerRelay); + } let dial_out = if let Some(relay) = &state.client_relay { relay.is_dial_out_owner(host) } else { @@ -8243,6 +8248,8 @@ pub fn overlay_seat_admission(state: &AppState, items: &mut [Value], history: bo } async fn health(State(state): State) -> Result, ApiError> { + let network_policy = crate::config::NetworkPolicy::for_state_dir(&state.state_dir); + let network_enabled = !network_policy.is_local_only(); let mut health = json!({ "status": "ready", "node": state.node, @@ -8251,6 +8258,12 @@ async fn health(State(state): State) -> Result, ApiError> "isolation": isolation_name(fractal_runtime::isolation_mode()), "store_index": state.store.index().map_err(ApiError::internal)?, "security": "trusted-network-no-tls-no-acls", + "network_policy": { + "mode": network_policy, + "fleet_join": network_enabled, + "replication": network_enabled, + "peer_relay": network_enabled, + }, "features": {"owned_sets":1,"seat_rollout":1,"seat_rollout_manual":1,"bounded_legacy_events":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"github_plugin":2}, }); // How far this member's messages stream has come. Absent when it holds no shard, so a diff --git a/crates/fractal-api-core/src/api/local_only.rs b/crates/fractal-api-core/src/api/local_only.rs new file mode 100644 index 00000000000..bdbc502eb10 --- /dev/null +++ b/crates/fractal-api-core/src/api/local_only.rs @@ -0,0 +1,98 @@ +use super::*; +use crate::config::{NetworkOperation, NetworkPolicy}; + +impl AppState { + pub fn local_only(&self) -> bool { + NetworkPolicy::for_state_dir(&self.state_dir).is_local_only() + } +} + +pub(super) fn refusal(operation: NetworkOperation) -> ApiError { + let refused = NetworkPolicy::LocalOnly.require(operation).unwrap_err(); + ApiError { + status: StatusCode::FORBIDDEN, + code: refused.code.into(), + message: refused.to_string(), + details: Box::new(serde_json::Map::from_iter([ + ("network_policy".into(), json!(NetworkPolicy::LocalOnly)), + ("operation".into(), json!(operation)), + ])), + } +} + +fn network_operation(path: &str) -> Option { + if path.starts_with("/v1/internal/replication/") || path == "/v1/internal/replication-wake" { + Some(NetworkOperation::Replication) + } else if path.starts_with("/v1/internal/fleet/") { + Some(NetworkOperation::FleetJoin) + } else if path == crate::peer::CLIENT_READ_FORWARD_PATH + || path == crate::peer::SEAT_FRESHNESS_INTERNAL_PATH + || path == crate::peer::SEAT_FRESHNESS_READS_INTERNAL_PATH + { + Some(NetworkOperation::PeerRelay) + } else { + None + } +} + +pub(super) async fn enforce( + State(state): State, + request: Request, + next: Next, +) -> Response { + if state.local_only() + && let Some(operation) = network_operation(request.uri().path()) + { + return refusal(operation).into_response(); + } + next.run(request).await +} + +#[cfg(test)] +mod tests { + use super::*; + use tower::ServiceExt as _; + + #[tokio::test] + async fn fixture_refuses_join_replication_and_peer_relay_before_body_or_store_effects() { + let root = tempfile::tempdir().unwrap(); + let mut state = super::super::tests_shared::state(root.path()); + state.state_dir = root.path().join(".fractal-instance/client-control-fixture/state/fractal"); + assert!(state.local_only()); + let before = state.store.index().unwrap(); + let app = super::super::router(state.clone()); + for (path, code) in [ + ("/v1/internal/fleet/redeem", "local-only-fleet-join-refused"), + ("/v1/internal/fleet/pairing-admission", "local-only-fleet-join-refused"), + ("/v1/internal/replication/export", "local-only-replication-refused"), + ("/v1/internal/replication/receive", "local-only-replication-refused"), + ("/v1/internal/replication/shard/messages/receive", "local-only-replication-refused"), + (crate::peer::CLIENT_READ_FORWARD_PATH, "local-only-peer-relay-refused"), + (crate::peer::SEAT_FRESHNESS_INTERNAL_PATH, "local-only-peer-relay-refused"), + ] { + let response = app.clone().oneshot(Request::builder().method("POST").uri(path) + .body(Body::from("not even valid JSON")).unwrap()).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}"); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["code"], code, "{path}: {value}"); + assert_eq!(value["details"]["network_policy"], "local-only"); + } + assert_eq!(state.store.index().unwrap(), before); + } + + #[tokio::test] + async fn health_proves_fixture_network_policy_and_nonfixture_stays_fleet_capable() { + let root = tempfile::tempdir().unwrap(); + let mut state = super::super::tests_shared::state(root.path()); + let ordinary = super::super::health(State(state.clone())).await.unwrap().0; + assert_eq!(ordinary["network_policy"]["mode"], "fleet-capable"); + state.state_dir = root.path().join(".st-instance/client-control-fixture/state/fractal"); + let health = super::super::health(State(state.clone())).await.unwrap().0; + assert_eq!(health["network_policy"], json!({ + "mode":"local-only", "fleet_join":false, "replication":false, "peer_relay":false, + })); + let error = super::super::remote_unavailable_for_owner(&state, "host/personal"); + assert_eq!(error.code, "local-only-peer-relay-refused"); + } +} diff --git a/crates/fractal-kernel/src/config.rs b/crates/fractal-kernel/src/config.rs index 85ff65a7fa4..df776ecf4ce 100644 --- a/crates/fractal-kernel/src/config.rs +++ b/crates/fractal-kernel/src/config.rs @@ -13,6 +13,8 @@ use crate::model::PlannerSpec; pub mod changes; mod client; +mod local_only; +pub use local_only::{CLIENT_CONTROL_FIXTURE, LocalOnlyRefusal, NetworkOperation, NetworkPolicy}; /// The per-host switch for seat lanes. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -998,6 +1000,7 @@ impl Config { let Some(file) = FleetFile::load(&self.state_dir)? else { return Ok(()); }; + self.network_policy().require(NetworkOperation::FleetJoin)?; if let Some(configured) = &self.fleet_id { anyhow::ensure!( *configured == file.fleet_id, @@ -1024,6 +1027,7 @@ impl Config { } pub fn validate(&self) -> Result<()> { + self.network_policy().validate_config(self)?; if let Some(usage_limits) = &self.usage_limits { usage_limits.validate()?; } diff --git a/crates/fractal-kernel/src/config/local_only.rs b/crates/fractal-kernel/src/config/local_only.rs new file mode 100644 index 00000000000..d53711ae2f3 --- /dev/null +++ b/crates/fractal-kernel/src/config/local_only.rs @@ -0,0 +1,142 @@ +//! The fixture instance is an isolation boundary, not an operator-toggleable fleet member. +use std::path::Path; + +use serde::Serialize; + +use super::Config; + +pub const CLIENT_CONTROL_FIXTURE: &str = "client-control-fixture"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum NetworkPolicy { + FleetCapable, + LocalOnly, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum NetworkOperation { + FleetJoin, + Replication, + PeerRelay, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +pub struct LocalOnlyRefusal { + pub code: &'static str, + pub operation: NetworkOperation, +} + +impl std::fmt::Display for LocalOnlyRefusal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: the client-control-fixture instance is permanently local-only", self.code) + } +} + +impl std::error::Error for LocalOnlyRefusal {} + +impl NetworkPolicy { + /// The instance environment is set before threads start. Recognize its state path too so + /// an explicit config or service invocation cannot turn the fixture into a fleet member. + pub fn for_state_dir(state_dir: &Path) -> Self { + let instance = crate::instance::current(); + Self::for_instance(instance.as_ref().map(crate::instance::Instance::name), state_dir) + } + + fn for_instance(instance: Option<&str>, state_dir: &Path) -> Self { + let fixture_path = state_dir.ancestors().any(|path| { + path.file_name().is_some_and(|name| name == CLIENT_CONTROL_FIXTURE) + && path.parent().and_then(Path::file_name).is_some_and(|name| { + name == ".st-instance" || name == ".fractal-instance" + }) + }); + if instance == Some(CLIENT_CONTROL_FIXTURE) || fixture_path { + Self::LocalOnly + } else { + Self::FleetCapable + } + } + + pub fn is_local_only(self) -> bool { + self == Self::LocalOnly + } + + pub fn require(self, operation: NetworkOperation) -> Result<(), LocalOnlyRefusal> { + if !self.is_local_only() { + return Ok(()); + } + Err(LocalOnlyRefusal { + code: match operation { + NetworkOperation::FleetJoin => "local-only-fleet-join-refused", + NetworkOperation::Replication => "local-only-replication-refused", + NetworkOperation::PeerRelay => "local-only-peer-relay-refused", + }, + operation, + }) + } + + pub fn validate_config(self, config: &Config) -> Result<(), LocalOnlyRefusal> { + if config.fleet_id.is_some() || config.fleet.is_some() { + self.require(NetworkOperation::FleetJoin)?; + } + if config.peer_listen.is_some() || config.shared_secret_file.is_some() { + self.require(NetworkOperation::Replication)?; + } + if !config.peers.is_empty() { + self.require(NetworkOperation::PeerRelay)?; + } + Ok(()) + } +} + +impl Config { + pub fn network_policy(&self) -> NetworkPolicy { + NetworkPolicy::for_state_dir(&self.state_dir) + } + + pub fn local_only(&self) -> bool { + self.network_policy().is_local_only() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reserved_instance_cannot_escape_with_an_explicit_state_directory() { + assert_eq!(NetworkPolicy::for_instance(Some(CLIENT_CONTROL_FIXTURE), Path::new("/tmp/elsewhere")), NetworkPolicy::LocalOnly); + for root in [".st-instance", ".fractal-instance"] { + assert_eq!(NetworkPolicy::for_instance(None, &Path::new("/home/operator").join(root).join(CLIENT_CONTROL_FIXTURE).join("state/fractal")), NetworkPolicy::LocalOnly); + } + assert_eq!(NetworkPolicy::for_instance(Some("other"), Path::new("/tmp/state")), NetworkPolicy::FleetCapable); + assert_eq!(NetworkPolicy::for_instance(None, Path::new("/tmp/client-control-fixture/state")), NetworkPolicy::FleetCapable); + } + + #[test] + fn each_network_operation_has_a_typed_refusal() { + for (operation, code) in [ + (NetworkOperation::FleetJoin, "local-only-fleet-join-refused"), + (NetworkOperation::Replication, "local-only-replication-refused"), + (NetworkOperation::PeerRelay, "local-only-peer-relay-refused"), + ] { + assert_eq!(NetworkPolicy::LocalOnly.require(operation), Err(LocalOnlyRefusal { code, operation })); + assert!(NetworkPolicy::FleetCapable.require(operation).is_ok()); + } + } + + #[test] + fn stale_or_injected_network_configuration_is_refused() { + let mut config = Config::default(); + assert!(NetworkPolicy::LocalOnly.validate_config(&config).is_ok()); + config.fleet_id = Some("personal-fleet".into()); + assert_eq!(NetworkPolicy::LocalOnly.validate_config(&config).unwrap_err().operation, NetworkOperation::FleetJoin); + config.fleet_id = None; + config.peer_listen = Some("127.0.0.1:31313".into()); + assert_eq!(NetworkPolicy::LocalOnly.validate_config(&config).unwrap_err().operation, NetworkOperation::Replication); + config.peer_listen = None; + config.peers.push(super::super::PeerConfig { name: "personal".into(), url: "http://127.0.0.1:31313".into() }); + assert_eq!(NetworkPolicy::LocalOnly.validate_config(&config).unwrap_err().operation, NetworkOperation::PeerRelay); + } +} diff --git a/crates/fractal-peer/src/peer.rs b/crates/fractal-peer/src/peer.rs index 6ed7dd9e02d..facdf483aa7 100644 --- a/crates/fractal-peer/src/peer.rs +++ b/crates/fractal-peer/src/peer.rs @@ -48,6 +48,8 @@ mod seat_freshness; pub use seat_freshness::*; mod seat_freshness_reads; pub use seat_freshness_reads::*; +#[cfg(test)] +mod local_only_tests; /// The peer listener's shared state, with this node's daemon as the store. type PeerState = smallclaims::sync::PeerState; @@ -531,6 +533,10 @@ impl ClientRelay { } pub fn from_config(config: &Config) -> Result> { + if config.local_only() { + config.network_policy().validate_config(config)?; + return Ok(None); + } let (Some(fleet), Some(secret)) = ( config.fleet_id.as_deref(), config.shared_secret_file.as_deref(), @@ -2104,6 +2110,7 @@ pub async fn conversation_gateway_relay( /// Run the replication worker: sync this node's store, through its daemon, with the fleet. pub async fn run_worker(config: Config, splice: RawSplice) -> Result<()> { + config.network_policy().require(crate::config::NetworkOperation::Replication)?; config.validate()?; if let Err(error) = crate::replication_worker_build::record(&config.state_dir) { eprintln!("st replication-worker: WARNING: cannot report worker build: {error:#}"); diff --git a/crates/fractal-peer/src/peer/local_only_tests.rs b/crates/fractal-peer/src/peer/local_only_tests.rs new file mode 100644 index 00000000000..da26fb0308e --- /dev/null +++ b/crates/fractal-peer/src/peer/local_only_tests.rs @@ -0,0 +1,42 @@ +use super::*; +use crate::config::{LocalOnlyRefusal, NetworkOperation}; + +fn fixture_config(root: &Path) -> Config { + Config { + state_dir: root.join(".st-instance/client-control-fixture/state/fractal"), + ..Config::default() + } +} + +#[test] +fn fixture_never_constructs_a_peer_relay_even_without_fleet_configuration() { + let root = tempfile::tempdir().unwrap(); + let mut config = fixture_config(root.path()); + assert!(ClientRelay::from_config(&config).unwrap().is_none()); + config.peers.push(PeerConfig { name: "personal".into(), url: "http://127.0.0.1:31313".into() }); + let error = match ClientRelay::from_config(&config) { + Err(error) => error, + Ok(_) => panic!("fixture peer configuration must be refused"), + }; + let refusal = error.downcast_ref::().unwrap(); + assert_eq!(refusal.operation, NetworkOperation::PeerRelay); + assert_eq!(refusal.code, "local-only-peer-relay-refused"); + assert!(!config.state_dir.exists()); +} + +#[tokio::test] +async fn fixture_worker_refuses_before_creating_state_or_loading_keys() { + fn never_splice(_: axum::extract::ws::WebSocket, _: tokio::net::UnixStream) + -> std::pin::Pin + Send>> + { + panic!("a refused worker cannot start a peer transport"); + } + let root = tempfile::tempdir().unwrap(); + let config = fixture_config(root.path()); + let state_dir = config.state_dir.clone(); + let error = run_worker(config, never_splice).await.unwrap_err(); + let refusal = error.downcast_ref::().unwrap(); + assert_eq!(refusal.operation, NetworkOperation::Replication); + assert_eq!(refusal.code, "local-only-replication-refused"); + assert!(!state_dir.exists()); +} diff --git a/crates/fractal/src/fleet_cli.rs b/crates/fractal/src/fleet_cli.rs index 514aece1eba..8a8505a7858 100644 --- a/crates/fractal/src/fleet_cli.rs +++ b/crates/fractal/src/fleet_cli.rs @@ -517,6 +517,8 @@ async fn bootstrap_fabric_invite( } pub(super) async fn run_fleet(endpoint: &Endpoint, command: FleetCommand, json_output: bool) -> Result<()> { + Config::load_unvalidated(None)?.network_policy() + .require(fractal_daemon::config::NetworkOperation::FleetJoin)?; // Recording an override only asks this member's daemon; it reads no fleet configuration. let command = match command { FleetCommand::AdmitWithout { peers, reason, actor } => { diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 24e3e1dc5e1..a2f8db0e2de 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -5973,6 +5973,7 @@ async fn run(cli: Cli) -> Result<()> { if !args.peer.is_empty() { config.peers = args.peer; } + config.network_policy().require(fractal_daemon::config::NetworkOperation::Replication)?; config.apply_fleet_file()?; fractal_daemon::node_identity::resolve(&mut config)?; let mut telemetry = diff --git a/docs/st3/instances.md b/docs/st3/instances.md index ac7a4fa281a..e1ecd3fea86 100644 --- a/docs/st3/instances.md +++ b/docs/st3/instances.md @@ -25,6 +25,30 @@ A name is 1 to 24 lowercase letters, digits and single hyphens. The flag works b subcommand and wins over the environment variable. Inside an instance, a different name is an error, so a seat of one instance cannot start another by accident. +## Client-control fixture: permanently local-only + +The reserved `client-control-fixture` instance is a fixture-data boundary, not a +second fleet member. Its network policy cannot be switched off in configuration. +It refuses fleet commands before service or membership changes, never starts a +replication worker, and never constructs a peer relay. Stale fleet membership, +peer listeners, shared secrets or configured peers make daemon startup fail closed. +This also applies when an explicit state directory points beneath the reserved +instance's `.st-instance` or `.fractal-instance` directory without an instance flag. + +The daemon refuses fleet admission, replication export/receive (including shards), +and peer-forwarding requests before processing their bodies or changing the store. +The typed refusal codes are `local-only-fleet-join-refused`, +`local-only-replication-refused` and `local-only-peer-relay-refused`. +`GET /v1/health` reports `network_policy.mode: "local-only"` and +`fleet_join`, `replication` and `peer_relay` all `false`. These are enforced +restrictions, not merely a report that no peers happen to be configured. + +Paired clients may still reach the fixture's client socket. Local-only does not +authorize setup, enrollment, grants, Fabric exposure or any live instance changes; +those require their own consent and scope. Other instance names and the default +daemon retain their existing fleet behavior. + + ## Directory discovery The daemon, CLI, standalone client and TUI share one resolver for product config diff --git a/release-notes/client-control-fixture-local-only.json b/release-notes/client-control-fixture-local-only.json new file mode 100644 index 00000000000..abf27783427 --- /dev/null +++ b/release-notes/client-control-fixture-local-only.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Make the reserved client-control-fixture instance permanently local-only with typed fleet, replication and peer-relay refusals and health proof.", + "replay": {"status": "none", "detail": "No startup replay or history rewrite."}, + "schema": {"status": "none", "detail": "No claim, store or generated client schema changes. The health response adds network_policy with the enforced mode and network capability booleans."}, + "rules": {"status": "none", "detail": "No admission vocabulary, canonical ordering, checkpoint or retention changes."}, + "compatibility": {"status": "changed", "detail": "The reserved client-control-fixture instance refuses fleet operations, replication workers and peer relays. Existing fleet membership or peer configuration fails startup closed. Default and other named instances keep their existing behavior. Paired fixture client transport remains available."}, + "manual": {"status": "none", "detail": "Source-only change; no setup, instance initialization, enrollment, grant, service exposure or live activation is authorized or performed."}, + "recovery": {"status": "none", "detail": "There is no configuration bypass for the reserved instance. Incorrect fleet or peer configuration must be removed before it can start; no automatic cleanup of state is performed."}, + "downtime": {"status": "unknown", "detail": "No builds, tests, daemon launches or restarts were run for this source change."} +} From ac7a59954151624f6fd24e0124247c886de8c7bf Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:57:17 +0100 Subject: [PATCH 3/4] Bind fixture enrollment to local operator consent and client pairing agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../src/api/client_v0.rs | 2 + crates/fractal-api-core/src/api.rs | 12 + .../src/api/client_control_enrollment.rs | 373 ++++++++++++++++++ crates/fractal-api-core/src/api/client_v0.rs | 1 + crates/fractal-store/src/pairing.rs | 40 +- crates/fractal-store/src/store.rs | 2 +- .../client-control-enrollment-v1.json | 11 + 7 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 crates/fractal-api-core/src/api/client_control_enrollment.rs create mode 100644 release-notes/client-control-enrollment-v1.json diff --git a/crates/fractal-api-client-v0/src/api/client_v0.rs b/crates/fractal-api-client-v0/src/api/client_v0.rs index 9ed8761df7d..6affdbd6637 100644 --- a/crates/fractal-api-client-v0/src/api/client_v0.rs +++ b/crates/fractal-api-client-v0/src/api/client_v0.rs @@ -98,6 +98,8 @@ mod stream_timeline_tests; /// The client-v0 routes, added to the API router as a [`RouteFamily`]. pub fn routes(app: Router) -> Router { app + .route("/v1/client/test-clients/enroll", post(fractal_api_core::api::client_control_enrollment::enroll_http)) + .route("/v1/client/test-clients/revoke", post(fractal_api_core::api::client_control_enrollment::revoke_http)) .route("/v1/client/arrangements", get(arrangements::list)) .route( "/v1/client/arrangements/{person_name}/{uuid}", diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index b52ca4da4de..01d81d9d611 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -156,6 +156,7 @@ mod request_latency; mod client_observations; pub mod client_push; pub mod client_commands; +pub mod client_control_enrollment; pub use client_push::start_push_registration_cleanup; pub mod terminal_view; mod work_response; @@ -7391,6 +7392,10 @@ async fn serve_reserved_unix_with_lookup_ready( // Every local connection names its caller, so request counts by client are always on. let mut diagnostic_connection = crate::relay_trace::Connection::capture(); let peer_pid = local_peer_pid(&stream); + let operator_peer = UnixOperatorPeer { + // Both values are supplied by the kernel, never by request metadata. + same_uid: stream.peer_cred().is_ok_and(|credentials| credentials.uid() == unsafe { libc::geteuid() }), + }; // This must precede dispatch: the numeric peer PID can be recycled while // a descendant continues using the connector's inherited socket. let (peer_process, peer_capture_error) = match local_peer_process(&stream) { @@ -7461,6 +7466,7 @@ async fn serve_reserved_unix_with_lookup_ready( let peer_process = peer_process.clone(); async move { let mut request = request.map(Body::new); + request.extensions_mut().insert(operator_peer); if let Some(cause) = ancestry_cause { request.extensions_mut().insert(cause); } @@ -7790,6 +7796,12 @@ fn observed_harness_ancestor(pid: u32, _peer: Option<&fractal_runtime::PeerProce reason: AncestryUnavailableReason::UnsupportedPlatform }) } +/// Kernel-established Unix connector identity for privileged fixture enrollment. +#[derive(Clone, Copy, Debug)] +pub struct UnixOperatorPeer { + pub same_uid: bool, +} + #[derive(Clone)] pub struct NativeDeliveryPeer { pub agent: String, diff --git a/crates/fractal-api-core/src/api/client_control_enrollment.rs b/crates/fractal-api-core/src/api/client_control_enrollment.rs new file mode 100644 index 00000000000..0c92b4be90a --- /dev/null +++ b/crates/fractal-api-core/src/api/client_control_enrollment.rs @@ -0,0 +1,373 @@ +//! Local fixture enrollment is a purpose-specific authorization, not a principal key grant. +//! The operator attests the external native consent ask; no personal ask or person is imported +//! into the fixture store. Only the existing paired public key can receive result authority. +use super::client_v0::{acting_party, forbidden, paired_client_session, revalidate_session, validation, ClientSession}; +use super::*; +use base64::Engine as _; +use std::sync::Mutex; + +pub const FIXTURE_PERSON: &str = "person/client-control-fixture"; +const ENROLLED: &str = "custom.client.test-enrolled"; +const REVOKED: &str = "custom.client.test-revoked"; +static WRITES: Mutex<()> = Mutex::new(()); + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnrollRequest { + pub device: String, + pub pairing_subject: String, + pub public_key: String, + pub kind: String, + pub consent_ref: String, + pub packet_digest: String, + pub host: String, + pub instance: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RevokeRequest { + pub device: String, +} + +#[derive(Clone, Debug)] +pub struct EnrolledClient { + pub device: String, + pub kind: String, + pub public_key: String, + pub pairing_subject: String, + /// Grants must bind this id, so fresh consent cannot resurrect an old grant. + pub enrollment_id: String, + pub session: ClientSession, +} + +fn not_enrolled() -> ApiError { + ApiError { status: StatusCode::FORBIDDEN, code: "device-not-test-client".into(), + message: "the device has no active fixture test enrollment".into(), details: Box::default() } +} +fn fixture(state: &AppState) -> Result<(), ApiError> { + if state.local_only() && state.owning_person.as_deref() == Some(FIXTURE_PERSON) { + Ok(()) + } else { Err(forbidden("client control enrollment requires the local-only fixture instance")) } +} +fn local_operator(session: &ClientSession) -> Result<(), ApiError> { + if session.transport == "unix" && session.pairing_grant.is_none() && acting_party(session) { + Ok(()) + } else { Err(forbidden("test enrollment requires a local Unix-socket operator")) } +} +fn text(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 4096 && !value.chars().any(char::is_control) +} +fn consent_error(code: &str, message: &str) -> ApiError { + ApiError { status: StatusCode::UNPROCESSABLE_ENTITY, code: code.into(), + message: message.into(), details: Box::default() } +} +fn consent_ref(value: &str) -> bool { + value.strip_prefix("step-run/ask-").and_then(|s| s.strip_suffix("/ask")) + .is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))) +} +fn packet_digest(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} +fn p256(key: &str) -> bool { + key.strip_prefix("p256:").and_then(|point| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(point).ok()) + .is_some_and(|point| point.len() == 65 && point[0] == 4) +} +fn subject(device: &str) -> String { + format!("custom/client/test-{}", hex::encode(Sha256::digest(device.as_bytes()))) +} +fn field<'a>(claim: &'a ClaimRecord, name: &str) -> Option<&'a str> { + claim.body.get("fields")?.get(name)?.as_str() +} +fn trusted(state: &AppState, claim: &ClaimRecord) -> Result { + Ok(claim.origin == state.store.origin() && state.store.client_operation_is_trusted(claim).map_err(ApiError::internal)?) +} +fn history(state: &AppState, device: &str) -> Result, ApiError> { + let rows = state.store.claims_for(&subject(device), None).map_err(ApiError::internal)?; + let mut admitted = Vec::new(); + for row in rows { + if matches!(row.kind.as_str(), ENROLLED | REVOKED) && trusted(state, &row)? { admitted.push(row); } + } + Ok(admitted) +} +fn latest_enrollment(rows: &[ClaimRecord]) -> Option<&ClaimRecord> { + rows.iter().filter(|row| row.kind == ENROLLED).max_by_key(|row| row.store_index) +} +fn active<'a>(rows: &'a [ClaimRecord]) -> Option<&'a ClaimRecord> { + let enrollment = latest_enrollment(rows)?; + (!rows.iter().any(|row| row.kind == REVOKED && field(row, "enrollmentId") == Some(enrollment.id.as_str()))) + .then_some(enrollment) +} +fn pairing(state: &AppState, pairing_subject: &str, device: &str, key: &str) -> Result { + let claim = state.store.newest_admitted_pairing_completion(pairing_subject, client_now_ms()) + .map_err(ApiError::internal)?.ok_or_else(not_enrolled)?; + if claim.origin != state.store.origin() || field(&claim, "device_id") != Some(device) + || field(&claim, "device_public_key") != Some(key) || !p256(key) { + return Err(not_enrolled()); + } + let session = paired_client_session(state, &claim, "gateway", false)?; + if session.authority_actor != FIXTURE_PERSON || !session.allows("read.projections") + || !session.allows("control.commands") + || session.scopes.iter().any(|scope| !scope.starts_with("read.") && scope != "control.commands") { + return Err(not_enrolled()); + } + Ok(session) +} + +/// Reload enrollment and the exact existing pairing on every command/grant/result admission. +/// This does not enroll a principal key and MUST NOT be used to authorize message.send. +pub fn enrolled(state: &AppState, device: &str) -> Result { + fixture(state)?; + let rows = history(state, device)?; + let row = active(&rows).ok_or_else(not_enrolled)?; + let public_key = field(row, "publicKey").ok_or_else(not_enrolled)?; + let pairing_subject = field(row, "pairingSubject").ok_or_else(not_enrolled)?; + let session = pairing(state, pairing_subject, device, public_key)?; + Ok(EnrolledClient { device: device.into(), kind: field(row, "kind").ok_or_else(not_enrolled)?.into(), + public_key: public_key.into(), pairing_subject: pairing_subject.into(), enrollment_id: row.id.clone(), session }) +} + +/// Trusted internal operation; HTTP callers MUST pass the same-UID Unix peer gate first. +/// The operator externally verifies person/schickling explicitly approved the native ask +/// for the packet digest and exact kind/host/instance. The fixture never queries main. +pub fn enroll(state: &AppState, session: &ClientSession, request: EnrollRequest) -> Result { + fixture(state)?; + let session = revalidate_session(state, session)?; + local_operator(&session)?; + if !consent_ref(&request.consent_ref) { + return Err(consent_error("consent-ref-invalid", "consent_ref must name a native step-run/ask-…/ask")); + } + if !packet_digest(&request.packet_digest) { + return Err(consent_error("consent-packet-invalid", "packet_digest must be the lowercase SHA256 of the reviewed enrollment packet and input manifest")); + } + if !matches!(request.kind.as_str(), "ios" | "web" | "tui") || !request.device.starts_with("device/") + || ![&request.device, &request.pairing_subject, &request.public_key, &request.consent_ref, &request.host].into_iter().all(|s| text(s)) + || request.instance != "client-control-fixture" { + return Err(validation("enrollment requires a fixture device, kind, host, instance and native consent ask id")); + } + let _guard = WRITES.lock().map_err(|_| ApiError::internal("enrollment lock unavailable"))?; + // A consent ask is single-use across devices as well as re-enrollments. + let mut before = None; + loop { + let page = state.store.claims_for_kind_at(ENROLLED, before, true, 256).map_err(ApiError::internal)?; + for row in page.claims { + if field(&row, "consentRef") == Some(request.consent_ref.as_str()) && trusted(state, &row)? { + return Err(consent_error("consent-ref-reused", "re-enrollment or changed scope requires a fresh consent ask")); + } + } + let Some(cursor) = page.next_cursor else { break }; before = Some(cursor); + } + let rows = history(state, &request.device)?; + if active(&rows).is_some() { return Err(validation("the device is already test-enrolled")); } + let paired = pairing(state, &request.pairing_subject, &request.device, &request.public_key)?; + let fields = BTreeMap::from([ + ("device".into(), json!(request.device)), ("kind".into(), json!(request.kind)), + ("enrolledBy".into(), json!(session.actor)), ("consentRef".into(), json!(request.consent_ref)), + ("packetDigest".into(), json!(request.packet_digest)), + ("host".into(), json!(request.host)), ("instance".into(), json!(request.instance)), + ("pairingSubject".into(), json!(request.pairing_subject)), ("publicKey".into(), json!(request.public_key)), + ("session".into(), json!(paired.actor)), + ]); + let row = state.store.append_client_operation_claim(&ClaimInput { + subject: subject(&request.device), kind: ENROLLED.into(), actor: Some(session.actor), fields, + evidence: Vec::new(), expected_subject: None, idempotency_key: None, + }).map_err(ApiError::bad)?; + signal_changed(state); + Ok(json!({"kind":"client-test-enrollment", "id":row.id, "device":request.device, "state":"enrolled"})) +} + +/// Client revoke ends enrollment, hence all grants bound to this enrollment id. Agents and +/// daemon-host operators may revoke too; no remote unpaired actor may revoke another client. +pub fn revoke(state: &AppState, session: &ClientSession, request: RevokeRequest) -> Result { + fixture(state)?; + let session = revalidate_session(state, session)?; + let _guard = WRITES.lock().map_err(|_| ApiError::internal("enrollment lock unavailable"))?; + // An operator can withdraw enrollment even after its pairing has expired. + let rows = history(state, &request.device)?; + let enrollment = active(&rows).ok_or_else(not_enrolled)?; + let own_client = session.pairing_grant.as_deref() == field(enrollment, "pairingSubject") + && Some(session.actor.as_str()) == field(enrollment, "session") + && session.authority_actor == FIXTURE_PERSON && session.pairing_grant.is_some(); + if !own_client { local_operator(&session)?; } + let row = state.store.append_client_operation_claim(&ClaimInput { + subject: subject(&request.device), kind: REVOKED.into(), actor: Some(session.actor.clone()), + fields: BTreeMap::from([("device".into(), json!(request.device)), + ("enrollmentId".into(), json!(enrollment.id)), ("revokedBy".into(), json!(session.actor))]), + evidence: vec![enrollment.id.clone()], expected_subject: None, idempotency_key: None, + }).map_err(ApiError::bad)?; + signal_changed(state); + Ok(json!({"kind":"client-test-enrollment", "id":row.id, "device":request.device, "state":"revoked"})) +} + +fn require_operator_peer(peer: Option<&super::UnixOperatorPeer>) -> Result<(), ApiError> { + if peer.is_some_and(|peer| peer.same_uid) { Ok(()) } else { + Err(ApiError { status: StatusCode::FORBIDDEN, code: "operator-not-local".into(), + message: "enrollment requires a Unix peer with the fixture daemon UID".into(), details: Box::default() }) + } +} + +pub async fn enroll_http(State(state): State, Extension(session): Extension, peer: Option>, Json(request): Json) -> Result, ApiError> { + require_operator_peer(peer.as_ref().map(|Extension(peer)| peer))?; + enroll(&state, &session, request).map(Json) +} +pub async fn revoke_http(State(state): State, Extension(session): Extension, peer: Option>, Json(request): Json) -> Result, ApiError> { + if session.transport == "unix" { require_operator_peer(peer.as_ref().map(|Extension(peer)| peer))?; } + revoke(&state, &session, request).map(Json) +} + +#[cfg(test)] +mod tests { + use super::*; + use ring::signature::KeyPair as _; + + fn fixture_state(root: &Path) -> AppState { + let mut state = super::super::tests_shared::state(root); + state.state_dir = root.join(".st-instance/client-control-fixture"); + state.owning_person = Some(FIXTURE_PERSON.into()); + state + } + fn paired(state: &AppState, scopes: &[&str], person: &str) -> (String, String) { + let rng = ring::rand::SystemRandom::new(); + let pkcs8 = ring::signature::EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap(); + let key = ring::signature::EcdsaKeyPair::from_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap(); + let public = format!("p256:{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key.public_key().as_ref())); + state.store.append_client_operation_claim(&ClaimInput { + subject: "custom/client/pairing-test".into(), kind: "custom.client.pairing-completed".into(), actor: Some(person.into()), + fields: BTreeMap::from([("device_id".into(), json!("device/test")), ("device_public_key".into(), json!(public)), + ("pairing_id".into(), json!("pairing/test")), ("person_id".into(), json!(person)), + ("delegated_by".into(), json!(person)), ("session_actor".into(), json!(format!("{person}/session/test"))), + ("scopes".into(), json!(scopes)), ("delegated_scopes".into(), json!(scopes)), + ("expires_at_unix_ms".into(), json!(client_now_ms()+60000))]), + evidence: Vec::new(), expected_subject: None, idempotency_key: None, + }).unwrap(); + ("custom/client/pairing-test".into(), public) + } + fn request(pair: &(String, String), consent: &str) -> EnrollRequest { + let consent = consent.strip_prefix("ask/").map(|id| format!("step-run/ask-{id}/ask")).unwrap_or_else(|| consent.into()); + EnrollRequest { device: "device/test".into(), pairing_subject: pair.0.clone(), public_key: pair.1.clone(), + kind: "tui".into(), consent_ref: consent, packet_digest: "a".repeat(64), + host: "test-host".into(), instance: "client-control-fixture".into() } + } + #[test] + fn pairing_alone_never_enrolls_and_revocation_survives_lookup() { + let root = tempfile::tempdir().unwrap(); + let state = fixture_state(root.path()); + let pair = paired(&state, &["read.projections", "control.commands"], FIXTURE_PERSON); + let operator = ClientSession::local(Some("agent/test")).unwrap(); + assert_eq!(enrolled(&state, "device/test").unwrap_err().code, "device-not-test-client"); + enroll(&state, &operator, request(&pair, "ask/one")).unwrap(); + let client = enrolled(&state, "device/test").unwrap(); + assert_eq!(client.public_key, pair.1); + assert!(!client.session.allows("control.messages")); + assert!(state.store.claims_for(FIXTURE_PERSON, Some("principal.key-added")).unwrap().is_empty()); + revoke(&state, &client.session, RevokeRequest { device: "device/test".into() }).unwrap(); + assert_eq!(enrolled(&state, "device/test").unwrap_err().code, "device-not-test-client"); + assert!(enroll(&state, &operator, request(&pair, "ask/one")).is_err()); + enroll(&state, &operator, request(&pair, "ask/two")).unwrap(); + assert_ne!(enrolled(&state, "device/test").unwrap().enrollment_id, client.enrollment_id); + } + #[test] + fn enrollment_refuses_remote_call_wrong_key_and_write_scope() { + let root = tempfile::tempdir().unwrap(); + let state = fixture_state(root.path()); + let pair = paired(&state, &["read.projections", "control.commands"], FIXTURE_PERSON); + let mut remote = ClientSession::local(Some("agent/test")).unwrap(); + remote.transport = "gateway"; + assert!(enroll(&state, &remote, request(&pair, "ask/one")).is_err()); + let operator = ClientSession::local(Some("agent/test")).unwrap(); + let mut wrong = request(&pair, "ask/one"); wrong.public_key.push('A'); + assert!(enroll(&state, &operator, wrong).is_err()); + let unsafe_state = fixture_state(root.path()); + let pair = paired(&unsafe_state, &["read.projections", "control.commands", "control.messages"], FIXTURE_PERSON); + assert!(enroll(&unsafe_state, &operator, request(&pair, "ask/one")).is_err()); + } + #[test] + fn enrollment_refuses_normal_instance_principal_and_missing_consent() { + let root = tempfile::tempdir().unwrap(); + let mut state = fixture_state(root.path()); + let pair = paired(&state, &["read.projections", "control.commands"], "person/normal"); + let operator = ClientSession::local(Some("agent/test")).unwrap(); + assert!(enroll(&state, &operator, request(&pair, "ask/one")).is_err()); + state.owning_person = Some("person/normal".into()); + assert!(enroll(&state, &operator, request(&pair, "ask/one")).is_err()); + state.owning_person = Some(FIXTURE_PERSON.into()); + assert!(enroll(&state, &operator, request(&pair, "")).is_err()); + } + #[test] + fn all_client_kinds_have_operator_revoke_and_no_remote_operator_authority() { + for kind in ["ios", "web", "tui"] { + let root = tempfile::tempdir().unwrap(); + let state = fixture_state(root.path()); + let pair = paired(&state, &["read.projections", "control.commands"], FIXTURE_PERSON); + let operator = ClientSession::local(Some("agent/test")).unwrap(); + let mut enrollment = request(&pair, "ask/one"); + enrollment.kind = kind.into(); + enroll(&state, &operator, enrollment).unwrap(); + assert_eq!(enrolled(&state, "device/test").unwrap().kind, kind); + let mut remote = ClientSession::local(Some(FIXTURE_PERSON)).unwrap(); + remote.transport = "gateway"; + assert!(revoke(&state, &remote, RevokeRequest { device: "device/test".into() }).is_err()); + let host_operator = ClientSession::local(Some(FIXTURE_PERSON)).unwrap(); + revoke(&state, &host_operator, RevokeRequest { device: "device/test".into() }).unwrap(); + assert!(enrolled(&state, "device/test").is_err()); + } + } + #[test] + fn enrollment_requires_control_scope_exact_pairing_and_fixture_scope() { + let root = tempfile::tempdir().unwrap(); + let operator = ClientSession::local(Some("agent/test")).unwrap(); + let read_only = fixture_state(root.path()); + let pair = paired(&read_only, &["read.projections"], FIXTURE_PERSON); + assert!(enroll(&read_only, &operator, request(&pair, "ask/one")).is_err()); + let mut state = fixture_state(root.path()); + let pair = paired(&state, &["read.projections", "control.commands"], FIXTURE_PERSON); + let mut missing = request(&pair, "ask/one"); + missing.pairing_subject = "custom/client/pairing-missing".into(); + assert!(enroll(&state, &operator, missing).is_err()); + let mut opaque = request(&pair, "ask/one"); + opaque.public_key = "opaque-client-without-a-p256-signing-key".into(); + assert!(enroll(&state, &operator, opaque).is_err()); + let mut kind = request(&pair, "ask/one"); + kind.kind = "phone".into(); + assert!(enroll(&state, &operator, kind).is_err()); + let mut instance = request(&pair, "ask/one"); + instance.instance = "main".into(); + assert!(enroll(&state, &operator, instance).is_err()); + state.state_dir = root.join("ordinary-instance"); + assert!(enroll(&state, &operator, request(&pair, "ask/one")).is_err()); + state.state_dir = root.join(".st-instance/client-control-fixture"); + enroll(&state, &operator, request(&pair, "ask/one")).unwrap(); + assert!(enroll(&state, &operator, request(&pair, "ask/two")).is_err()); + } + #[test] + fn consent_shape_digest_and_reuse_have_typed_refusals() { + let root = tempfile::tempdir().unwrap(); + let state = fixture_state(root.path()); + let pair = paired(&state, &["read.projections", "control.commands"], FIXTURE_PERSON); + let operator = ClientSession::local(Some("agent/test")).unwrap(); + for invalid in ["", "ask/one", "step-run/ask-/ask", "step-run/ask-one/other"] { + let mut body = request(&pair, "ask/one"); + body.consent_ref = invalid.into(); + assert_eq!(enroll(&state, &operator, body).unwrap_err().code, "consent-ref-invalid"); + } + for invalid in ["", "abc", &"A".repeat(64)] { + let mut body = request(&pair, "ask/one"); + body.packet_digest = invalid.into(); + assert_eq!(enroll(&state, &operator, body).unwrap_err().code, "consent-packet-invalid"); + } + enroll(&state, &operator, request(&pair, "ask/one")).unwrap(); + revoke(&state, &operator, RevokeRequest { device: "device/test".into() }).unwrap(); + let mut changed_kind = request(&pair, "ask/one"); + changed_kind.kind = "web".into(); + assert_eq!(enroll(&state, &operator, changed_kind).unwrap_err().code, "consent-ref-reused"); + let mut changed_device = request(&pair, "ask/one"); + changed_device.device = "device/other".into(); + assert_eq!(enroll(&state, &operator, changed_device).unwrap_err().code, "consent-ref-reused"); + } + #[test] + fn operator_transport_requires_kernel_verified_same_uid() { + assert_eq!(require_operator_peer(None).unwrap_err().code, "operator-not-local"); + assert_eq!(require_operator_peer(Some(&super::super::UnixOperatorPeer { same_uid: false })).unwrap_err().code, "operator-not-local"); + require_operator_peer(Some(&super::super::UnixOperatorPeer { same_uid: true })).unwrap(); + } +} diff --git a/crates/fractal-api-core/src/api/client_v0.rs b/crates/fractal-api-core/src/api/client_v0.rs index de5d5e5b6e3..10e69605f56 100644 --- a/crates/fractal-api-core/src/api/client_v0.rs +++ b/crates/fractal-api-core/src/api/client_v0.rs @@ -323,6 +323,7 @@ pub const ALL_SCOPES: &[&str] = &[ "terminal.control", "control.attention", "control.messages", + "control.commands", "control.launches", "control.missions", "control.work", diff --git a/crates/fractal-store/src/pairing.rs b/crates/fractal-store/src/pairing.rs index 44ebc521660..7c4245cf64f 100644 --- a/crates/fractal-store/src/pairing.rs +++ b/crates/fractal-store/src/pairing.rs @@ -15,10 +15,18 @@ pub fn record_hash(origin: &str, actor: Option<&str>, fields: &Value) -> Result< pub const OPERATION_AUTHORIZED: &str = "client.operation-authorized"; +/// Authority-bearing records admitted only by their dedicated daemon operation. +pub fn is_client_operation_kind(kind: &str) -> bool { + kind.starts_with("custom.client.pairing") + || kind.starts_with("custom.client.terminal") + || matches!(kind, "custom.client.test-enrolled" | "custom.client.test-revoked" + | "custom.client.control-granted" | "custom.client.control-revoked" + | "custom.client.control-consumed" | "custom.client.command-receipt") +} + /// Admission receipts use a separate subject so the operation's CAS head is unchanged. pub fn append_operation_receipt_tx(tx: &Transaction<'_>, claim: &ClaimRecord) -> Result<()> { - anyhow::ensure!(claim.kind.starts_with("custom.client.pairing") - || claim.kind.starts_with("custom.client.terminal"), "a client operation record is required"); + anyhow::ensure!(is_client_operation_kind(&claim.kind), "a client operation record is required"); let hash = record_hash(&claim.origin, claim.actor.as_deref(), &claim.body["fields"])?; let mut fields = serde_json::json!({ "record_id":claim.id, @@ -240,7 +248,7 @@ fn legacy_allowance_deadline( impl Store { pub fn append_client_operation_claim(&self, input: &ClaimInput) -> Result { - if !input.kind.starts_with("custom.client.pairing") && !input.kind.starts_with("custom.client.terminal") { + if !is_client_operation_kind(&input.kind) { return Err(crate::FractalError::new("claim-write-forbidden", "a dedicated client operation record is required")); } crate::store::append_claim_with_commit_context(self, input, None, None, None, None, @@ -333,6 +341,32 @@ mod tests { } } + #[test] + fn client_control_authority_requires_dedicated_durable_admission() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("control.db"); + for kind in ["custom.client.test-enrolled", "custom.client.test-revoked", + "custom.client.control-granted", "custom.client.control-revoked", + "custom.client.control-consumed", "custom.client.command-receipt"] + { + let store = Store::open(&path, "issuer").unwrap(); + let input = ClaimInput { + subject: format!("client-control-grant/{kind}"), kind: kind.into(), + actor: Some("agent/test".into()), + fields: serde_json::from_value(json!({"grantId":"test","runId":"run/test"})).unwrap(), + evidence: vec![], expected_subject: None, idempotency_key: None, + }; + let forged = store.append_claim(&input).unwrap(); + assert!(!store.client_operation_is_trusted(&forged).unwrap()); + let admitted = store.append_client_operation_claim(&input).unwrap(); + assert!(store.client_operation_is_trusted(&admitted).unwrap()); + drop(store); + let reopened = Store::open(&path, "issuer").unwrap(); + assert!(reopened.client_operation_is_trusted(&admitted).unwrap()); + assert!(!reopened.client_operation_is_trusted(&forged).unwrap()); + } + } + #[test] fn pairing_operation_receipts_require_local_or_keyed_envelope_admission() { let source = Store::open_memory("issuer").unwrap(); diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 27cfcb22876..fd123b1955c 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -11435,7 +11435,7 @@ pub fn append_claim_tx( } } let canonical; - let body = if (kind.starts_with("custom.client.pairing") || kind.starts_with("custom.client.terminal")) + let body = if crate::pairing::is_client_operation_kind(kind) && !sorted(body) { canonical = smallclaims::hash::canonical_json_value(body); &canonical diff --git a/release-notes/client-control-enrollment-v1.json b/release-notes/client-control-enrollment-v1.json new file mode 100644 index 00000000000..4623e5a7f03 --- /dev/null +++ b/release-notes/client-control-enrollment-v1.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Enroll and revoke isolated fixture clients through the daemon's Unix operator boundary", + "replay": {"status":"none","detail":"Enrollment and revocation read dedicated durable client-operation claims; existing pairing replay is unchanged."}, + "schema": {"status":"changed","detail":"Adds custom.client.test-enrolled and custom.client.test-revoked with exact pairing/key, kind, host, instance, consentRef and packetDigest bindings. Dedicated operation admission also reserves the control grant and receipt claim kinds."}, + "rules": {"status":"changed","detail":"Enrollment requires the daemon UID on its Unix socket and the local-only fixture principal. Pairings must use client-generated P-256 keys and read plus control.commands scopes, never control.messages. Consent references and packet digests are validated and cannot be reused."}, + "compatibility": {"status":"changed","detail":"Ordinary pairings are never implicitly enrolled. Enrollment does not create keys or principal message-signing authority. Exact clients and local operators can revoke an enrollment."}, + "downtime": {"status":"none","detail":"Source implementation performs no live setup, pairing, enrollment or service action."}, + "manual": {"status":"required","detail":"Before enrollment, the mission operator verifies an authenticated native ask approval matching the reviewed packet digest and scope. The fixture daemon attests the supplied reference without connecting to the main daemon."}, + "recovery": {"status":"none","detail":"Revocation is durable and re-enrollment requires a fresh consent reference; it never resurrects an older grant."} +} From 1fc7ae84c63f033e377be2a4b52a99c6948941fb Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:12:07 +0100 Subject: [PATCH 4/4] Classify enrollment consent as changed manual setup agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- release-notes/client-control-enrollment-v1.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/client-control-enrollment-v1.json b/release-notes/client-control-enrollment-v1.json index 4623e5a7f03..ab21f033da4 100644 --- a/release-notes/client-control-enrollment-v1.json +++ b/release-notes/client-control-enrollment-v1.json @@ -6,6 +6,6 @@ "rules": {"status":"changed","detail":"Enrollment requires the daemon UID on its Unix socket and the local-only fixture principal. Pairings must use client-generated P-256 keys and read plus control.commands scopes, never control.messages. Consent references and packet digests are validated and cannot be reused."}, "compatibility": {"status":"changed","detail":"Ordinary pairings are never implicitly enrolled. Enrollment does not create keys or principal message-signing authority. Exact clients and local operators can revoke an enrollment."}, "downtime": {"status":"none","detail":"Source implementation performs no live setup, pairing, enrollment or service action."}, - "manual": {"status":"required","detail":"Before enrollment, the mission operator verifies an authenticated native ask approval matching the reviewed packet digest and scope. The fixture daemon attests the supplied reference without connecting to the main daemon."}, + "manual": {"status":"changed","detail":"Before enrollment, the mission operator verifies an authenticated native ask approval matching the reviewed packet digest and scope. The fixture daemon attests the supplied reference without connecting to the main daemon."}, "recovery": {"status":"none","detail":"Revocation is durable and re-enrollment requires a fresh consent reference; it never resurrects an older grant."} }