From 347e2db63beed1b6cb449866bdcfc682c0be66e4 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:33:01 +0100 Subject: [PATCH 1/4] feat(fabric): isolate fixture client service and peer grants 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 --- .../modules/fractal-fabric/rust/src/bridge.rs | 85 +++++++++++ clients/typescript/fractal-client/README.md | 10 +- clients/typescript/fractal-client/fabric.mjs | 14 +- .../typescript/fractal-client/fabric.test.cjs | 20 ++- crates/fractal-client/src/fabric.rs | 30 +++- crates/fractal-kernel/src/fabric_gateway.rs | 141 ++++++++++++++++-- crates/fractal/src/main.rs | 24 ++- crates/fractal/tests/action_coverage.rs | 58 +++++++ docs/st3/ios-fabric-protocol.md | 12 ++ .../fixture-instance-fabric-service.json | 11 ++ 10 files changed, 365 insertions(+), 40 deletions(-) create mode 100644 release-notes/fixture-instance-fabric-service.json diff --git a/apps/ios/modules/fractal-fabric/rust/src/bridge.rs b/apps/ios/modules/fractal-fabric/rust/src/bridge.rs index bc8e3c3f114..2bc8272fb20 100644 --- a/apps/ios/modules/fractal-fabric/rust/src/bridge.rs +++ b/apps/ios/modules/fractal-fabric/rust/src/bridge.rs @@ -326,6 +326,91 @@ mod tests { } } + /// Real wire admission, not command-output doubles. All identities, exposures + /// and the daemon are temporary; the host Fabric home is never consulted. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[ignore = "requires FABRIC_BIN and FABRIC_EXPECTED_VERSION; run with --test-threads=1"] + async fn fixture_service_isolated_from_production_and_privileged_socket() -> Result<()> { + use tokio::net::UnixListener; + let binary = std::env::var("FABRIC_BIN").context("set FABRIC_BIN")?; + let expected = std::env::var("FABRIC_EXPECTED_VERSION").context("set FABRIC_EXPECTED_VERSION")?; + assert_eq!(String::from_utf8(Command::new(&binary).arg("--version").output()?.stdout)?.trim(), expected); + let home = tempfile::Builder::new().prefix("fabric-fixture-isolation-").tempdir_in("/tmp")?; + let command = |arguments: &[&str]| -> Result { + let output = Command::new(&binary).arg("--home").arg(home.path()).args(arguments).output()?; + if !output.status.success() { + bail!("isolated fabric command failed: {}", String::from_utf8_lossy(&output.stderr)); + } + Ok(String::from_utf8(output.stdout)?) + }; + command(&["id"])?; + let fixture = SecretKey::generate(); + let normal = SecretKey::generate(); + for (key, name, service) in [ + (&fixture, "fixture-client", "client-control-fixture"), + (&normal, "normal-client", "st3-client-v0"), + ] { + command(&["add", &key.public().to_string(), name, "--inbound-only", "--allow", service])?; + } + let _daemon = Daemon(Command::new(&binary).arg("--home").arg(home.path()) + .args(["up", "--foreground"]).env("FABRIC_PEER_HEALTH_SECS", "0") + .stdout(Stdio::null()).stderr(Stdio::null()).spawn()?); + let mut addr = None; + for _ in 0..100 { + if let Ok(value) = command(&["addr"]) { + addr = Some(serde_json::from_str::(&value)?); + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let addr = addr.context("isolated fabric did not start")?; + let fixture_socket = home.path().join("fixture-client.sock"); + let normal_socket = home.path().join("normal-client.sock"); + let private_socket = home.path().join("privileged.sock"); + let fixture_listener = UnixListener::bind(&fixture_socket)?; + let normal_listener = UnixListener::bind(&normal_socket)?; + let private_listener = UnixListener::bind(&private_socket)?; + for (service, socket) in [ + ("client-control-fixture", &fixture_socket), + ("st3-client-v0", &normal_socket), + ] { + command(&["expose", service, "--socket", socket.to_str().context("socket path")?, "--ephemeral"])?; + } + // Exercise both allow paths and both cross-service denials. A refusal must + // be Fabric's typed admission denial, not a closed application socket. + for (key, service, listener, allowed) in [ + (fixture.clone(), "client-control-fixture", &fixture_listener, true), + (fixture.clone(), "st3-client-v0", &normal_listener, false), + (normal.clone(), "st3-client-v0", &normal_listener, true), + (normal.clone(), "client-control-fixture", &fixture_listener, false), + ] { + let bridge = Bridge::start(key, addr.clone(), service.into()).await?; + let mut local = TcpStream::connect(bridge.url.trim_start_matches("http://")).await?; + local.write_all(b"fixture-isolation").await?; + if allowed { + let (mut accepted, _) = tokio::time::timeout(Duration::from_secs(8), listener.accept()).await??; + let mut bytes = [0; 17]; + tokio::time::timeout(Duration::from_secs(8), accepted.read_exact(&mut bytes)).await??; + assert_eq!(&bytes, b"fixture-isolation"); + accepted.write_all(b"paired-client-only").await?; + let mut response = [0; 18]; + tokio::time::timeout(Duration::from_secs(8), local.read_exact(&mut response)).await??; + assert_eq!(&response, b"paired-client-only"); + } else { + let mut response = Vec::new(); + let _ = tokio::time::timeout(Duration::from_secs(8), local.read_to_end(&mut response)).await?; + assert!(response.is_empty()); + let attempt = settled_attempt().await; + assert_eq!(attempt["result"], "refused", "{attempt}"); + assert_eq!(attempt["code"], "peer_not_permitted_for_service", "{attempt}"); + assert!(tokio::time::timeout(Duration::from_millis(100), listener.accept()).await.is_err()); + } + assert!(tokio::time::timeout(Duration::from_millis(100), private_listener.accept()).await.is_err()); + bridge.stop().await; + } + Ok(()) + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires FABRIC_BIN and an explicit pinned member version"] async fn pinned_daemon_grants_flow_control_and_half_close() -> Result<()> { diff --git a/clients/typescript/fractal-client/README.md b/clients/typescript/fractal-client/README.md index b2cc779fa11..d14807494f2 100644 --- a/clients/typescript/fractal-client/README.md +++ b/clients/typescript/fractal-client/README.md @@ -90,10 +90,12 @@ WebSockets through the returned Unix tunnel socket. An explicit NodeID bypasses peer-name lookup. It does not start a TCP proxy or forward trusted-local person headers. Pairing credentials and signing requirements remain gateway-owned. -The Rust and Node native readers accept exactly `fabric:///st3-client-v0` -and `fabric:///fractal-client-v0`. Both dial the exposed -`st3-client-v0` gateway service; endpoint formatting and gateway defaults still use -`st3-client-v0`. Other service names, URI suffixes and trailing line breaks are rejected. +The Rust and Node native readers accept `fabric:///st3-client-v0` +and `fabric:///fractal-client-v0`. Both production spellings dial +`st3-client-v0`; their formatting and exposure defaults remain unchanged. +The additional exact endpoint `fabric:///client-control-fixture` +preserves and dials only that isolated fixture service, never the production service. +Other service names, URI suffixes and trailing line breaks are rejected. The native TypeScript adapter recognizes canonical hex/base32 NodeID syntax and leaves Ed25519 key validity to Fabric. Unlike the Rust adapter's exact identity diff --git a/clients/typescript/fractal-client/fabric.mjs b/clients/typescript/fractal-client/fabric.mjs index 562658da655..d5cc2e04888 100644 --- a/clients/typescript/fractal-client/fabric.mjs +++ b/clients/typescript/fractal-client/fabric.mjs @@ -10,6 +10,7 @@ import { FractalClient } from './Client.generated.ts'; const execute = promisify(execFile); const protocol = 'st3-client-v0'; +const fixtureProtocol = 'client-control-fixture'; const origin = 'http://fabric.local'; // Recognize canonical NodeID encodings; Fabric validates the curve point on dial. @@ -43,16 +44,18 @@ export class FabricTransportError extends Error { export class FabricEndpoint { #target; + #protocol; constructor(value) { // Unlike $, this end assertion also rejects a final line terminator. - const match = /^fabric:\/\/([A-Za-z0-9_.-]+)\/(?:st3|fractal)-client-v0(?![\s\S])/.exec(value); + const match = /^fabric:\/\/([A-Za-z0-9_.-]+)\/((?:st3|fractal)-client-v0|client-control-fixture)(?![\s\S])/.exec(value); if (!match || match[1] === '.' || match[1] === '..') { - throw new FabricTransportError('invalid-endpoint', 'Expected fabric:///st3-client-v0 or fabric:///fractal-client-v0'); + throw new FabricTransportError('invalid-endpoint', 'Expected fabric:///st3-client-v0, fabric:///fractal-client-v0 or fabric:///client-control-fixture'); } this.#target = match[1]; + this.#protocol = match[2] === fixtureProtocol ? fixtureProtocol : protocol; } static parse(value) { return new FabricEndpoint(value); } - toString() { return `fabric://${this.#target}/${protocol}`; } + toString() { return `fabric://${this.#target}/${this.#protocol}`; } static validatePath(path) { const route = path.split('?')[0]; const allowed = value => value.startsWith('/v1/client/') || value === '/v1/health'; @@ -95,9 +98,8 @@ export class FabricEndpoint { if (matches.length !== 1) throw new FabricTransportError(matches.length ? 'ambiguous-peer' : 'unknown-peer', 'Choose one unique trusted Fabric peer name or a NodeID'); node = matches[0].node; } - // Both URI names dial the gateway's exposed st3-client-v0 service; - // the URI formatter and gateway exposure retain their existing default. - const answer = await run(['dial', node, protocol]); + // Only the production alias is normalized. Fixture targets stay exact. + const answer = await run(['dial', node, this.#protocol]); if (typeof answer.socket !== 'string' || !isAbsolute(answer.socket)) { throw new FabricTransportError('invalid-socket', 'Fabric dial returned no absolute Unix socket path'); } diff --git a/clients/typescript/fractal-client/fabric.test.cjs b/clients/typescript/fractal-client/fabric.test.cjs index 0e99976f5eb..0cddf985317 100644 --- a/clients/typescript/fractal-client/fabric.test.cjs +++ b/clients/typescript/fractal-client/fabric.test.cjs @@ -46,7 +46,7 @@ test('native Fabric parsing, roundtrip and pre-dial routing guards', async () => for (const target of ['DEVBOX', 'dev-box.local', 'a'.repeat(64)]) { assert.equal(FabricEndpoint.parse(`fabric://${target}/fractal-client-v0`).toString(), `fabric://${target}/st3-client-v0`); } - for (const service of ['st3-client-v0', 'fractal-client-v0']) { + for (const service of ['st3-client-v0', 'fractal-client-v0', 'client-control-fixture']) { for (const suffix of ['/', '?x', '#fragment', '\n', '\r\n', '/extra']) { assert.throws(() => FabricEndpoint.parse(`fabric://box/${service}${suffix}`)); } @@ -117,6 +117,24 @@ test('both URI names dial the same exposed gateway service for names and NodeIDs } }); +test('fixture endpoints preserve the exact isolated service on dial', async () => { + const node = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'; + const socket = path.join(root, 'fixture-client.sock'); + for (const target of ['Box', node]) { + const gateway = fixture(`fixture-service-${target}`, [{ node, name: 'Box' }], socket); + const value = `fabric://${target}/client-control-fixture`; + const endpoint = native.FabricEndpoint.parse(value); + assert.equal(endpoint.toString(), value); + assert.equal(await endpoint.dial(gateway), socket); + const calls = fs.readFileSync(gateway.calls, 'utf8').trim().split('\n').map(JSON.parse); + assert.deepEqual(calls, [ + ...(target === 'Box' ? [['peers', '--json']] : []), + ['dial', node, 'client-control-fixture', '--json'], + ]); + } + assert.throws(() => native.FabricEndpoint.parse(`fabric://${node}/client-control-fixture-admin`)); +}); + test('fake Fabric carries authenticated HTTP and every client WebSocket stream', { timeout: 10000 }, async t => { const socket = path.join(root, 'client.sock'); const snapshot = { id: 'snapshot/test', host_id: 'host/test', store_index: 1, projection_version: 'client-projection.v0', created_at: '2026-09-20T00:00:00Z' }; diff --git a/crates/fractal-client/src/fabric.rs b/crates/fractal-client/src/fabric.rs index a534983bace..41abfb5ce03 100644 --- a/crates/fractal-client/src/fabric.rs +++ b/crates/fractal-client/src/fabric.rs @@ -8,28 +8,30 @@ use crate::ClientError; const PROTOCOL: &str = "st3-client-v0"; const FRACTAL_PROTOCOL: &str = "fractal-client-v0"; +const FIXTURE_PROTOCOL: &str = "client-control-fixture"; const DIAL_TIMEOUT: Duration = Duration::from_secs(10); /// A peer's native client protocol, never a proxy URL or arbitrary Fabric protocol. #[derive(Clone, Debug, Eq, PartialEq)] pub struct FabricEndpoint { target: String, + protocol: &'static str, #[cfg(test)] program: Option, } impl fmt::Display for FabricEndpoint { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "fabric://{}/{PROTOCOL}", self.target) + write!(f, "fabric://{}/{}", self.target, self.protocol) } } impl FabricEndpoint { pub fn parse(value: &str) -> Result { - let invalid = || ClientError::Protocol(format!("invalid Fabric client endpoint; expected fabric:///{PROTOCOL} or fabric:///{FRACTAL_PROTOCOL}")); + let invalid = || ClientError::Protocol(format!("invalid Fabric client endpoint; expected fabric:///{PROTOCOL}, fabric:///{FRACTAL_PROTOCOL} or fabric:///{FIXTURE_PROTOCOL}")); let rest = value.strip_prefix("fabric://").ok_or_else(invalid)?; let (target, protocol) = rest.split_once('/').ok_or_else(invalid)?; - if ![PROTOCOL, FRACTAL_PROTOCOL].contains(&protocol) + if ![PROTOCOL, FRACTAL_PROTOCOL, FIXTURE_PROTOCOL].contains(&protocol) || target.is_empty() || !target.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') @@ -41,6 +43,7 @@ impl FabricEndpoint { } Ok(Self { target: target.to_owned(), + protocol: if protocol == FIXTURE_PROTOCOL { FIXTURE_PROTOCOL } else { PROTOCOL }, #[cfg(test)] program: None, }) @@ -119,10 +122,9 @@ impl FabricEndpoint { })?; resolve_peer(&self.target, &peers)?.to_owned() }; - // Both accepted URI names address the gateway's exposed service. - // Keep the legacy dial name while DEFAULT_SERVICE is st3-client-v0. + // The production alias retains its legacy target; the fixture is exact. fabric - .dial_socket(&node, PROTOCOL) + .dial_socket(&node, self.protocol) .await .map_err(|error| ClientError::Transport(format!("dial {self}: {error:#}"))) }) @@ -167,7 +169,7 @@ mod tests { let endpoint = FabricEndpoint::parse(&format!("fabric://{target}/fractal-client-v0")).unwrap(); assert_eq!(endpoint.to_string(), format!("fabric://{target}/st3-client-v0")); } - for service in [PROTOCOL, FRACTAL_PROTOCOL] { + for service in [PROTOCOL, FRACTAL_PROTOCOL, FIXTURE_PROTOCOL] { for suffix in ["/", "?x", "#fragment", "\n", "\r\n", "/extra"] { let value = format!("fabric://box/{service}{suffix}"); assert!(FabricEndpoint::parse(&value).is_err(), "{value:?}"); @@ -235,6 +237,20 @@ mod tests { } } + #[tokio::test] + async fn fixture_uri_dials_only_the_exact_fixture_service() { + let root = tempfile::tempdir().unwrap(); + let fabric = fake_fabric(root.path(), &format!( + "case \"$*\" in\n'dial {NODE_ID} client-control-fixture --json') echo '{{\"socket\":\"/fixture-client.sock\"}}';;\n*) exit 1;;\nesac" + )); + let endpoint = FabricEndpoint::parse(&format!("fabric://{NODE_ID}/{FIXTURE_PROTOCOL}")).unwrap(); + assert_eq!(endpoint.to_string(), format!("fabric://{NODE_ID}/{FIXTURE_PROTOCOL}")); + assert_eq!(endpoint.dial_with(&fabric).await.unwrap(), PathBuf::from("/fixture-client.sock")); + let normal = FabricEndpoint::parse(&format!("fabric://{NODE_ID}/{PROTOCOL}")).unwrap(); + assert!(normal.dial_with(&fabric).await.is_err()); + assert!(FabricEndpoint::parse(&format!("fabric://{NODE_ID}/client-control-fixture-admin")).is_err()); + } + #[tokio::test] async fn fake_fabric_native_http_roundtrip_and_refusal() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/fractal-kernel/src/fabric_gateway.rs b/crates/fractal-kernel/src/fabric_gateway.rs index 23890dbf37d..9a7b03c0b13 100644 --- a/crates/fractal-kernel/src/fabric_gateway.rs +++ b/crates/fractal-kernel/src/fabric_gateway.rs @@ -16,7 +16,41 @@ use crate::fleet::transport::Fabric; /// The service name `enable` uses when none is given. Both native client URI /// spellings dial this service; accepting `fractal-client-v0` does not change exposure. pub const DEFAULT_SERVICE: &str = "st3-client-v0"; +/// D-10's isolated instance and its exact paired-client Fabric service. +pub const FIXTURE_SERVICE: &str = crate::config::CLIENT_CONTROL_FIXTURE; const PEER_PREFIX: &str = "st-device-"; +const FIXTURE_PEER_PREFIX: &str = "st-fixture-device-"; + +/// Resolve the exposure without allowing the fixture to replace production's service. +pub fn service_for_instance(instance: Option<&str>, requested: Option<&str>) -> Result { + let fixture = instance == Some(FIXTURE_SERVICE); + let service = requested.unwrap_or(if fixture { FIXTURE_SERVICE } else { DEFAULT_SERVICE }); + anyhow::ensure!(!service.trim().is_empty(), "the Fabric service name must not be empty"); + anyhow::ensure!( + if fixture { service == FIXTURE_SERVICE } else { service != FIXTURE_SERVICE }, + "client-control-fixture must use its own Fabric service, never the production service" + ); + Ok(service.to_owned()) +} + +/// Select only the paired gateway, preserving production's legacy-name discovery. +pub fn client_socket(config: &crate::config::Config) -> Result { + config.validate()?; + let socket = if config.local_only() { + config.client_gateway_socket.clone() + } else { + fractal_socket_discovery::default_unix_socket( + &config.client_gateway_socket, + &config.client_gateway_socket, + ) + }; + let resolve = |path: &Path| fs::canonicalize(path).or_else(|_| std::path::absolute(path)); + let resolved = resolve(&socket)?; + for privileged in [&config.socket, &crate::config::state_socket(&config.state_dir)] { + anyhow::ensure!(resolved != resolve(privileged)?, "Fabric must never expose a privileged socket"); + } + Ok(socket) +} /// `STATE/fabric-gateway.toml`, written by `st devices fabric enable`. Its presence is the switch. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -36,9 +70,13 @@ impl GatewayExposure { pub fn load(state_dir: &Path) -> Result> { let path = Self::path(state_dir); match fs::read_to_string(&path) { - Ok(text) => toml::from_str(&text) - .map(Some) - .with_context(|| format!("parse {}", path.display())), + Ok(text) => { + let exposure: Self = toml::from_str(&text) + .with_context(|| format!("parse {}", path.display()))?; + let fixture = crate::config::NetworkPolicy::for_state_dir(state_dir).is_local_only(); + service_for_instance(fixture.then_some(FIXTURE_SERVICE), Some(&exposure.service))?; + Ok(Some(exposure)) + } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error).with_context(|| format!("read {}", path.display())), } @@ -88,6 +126,14 @@ pub fn peer_name(node: &str) -> String { format!("{PEER_PREFIX}{}", &node[..12]) } +fn service_peer_name(service: &str, node: &str) -> String { + if service == FIXTURE_SERVICE { + format!("{FIXTURE_PEER_PREFIX}{}", &node[..12]) + } else { + peer_name(node) + } +} + /// What pairing did about the device's Fabric grant, as the paired session reports it. #[derive(Clone, Debug, Eq, PartialEq)] pub enum Grant { @@ -144,7 +190,7 @@ pub async fn grant(state_dir: &Path, node: &str) -> Grant { Err(error) => return failed(error), }; let fabric = exposure.fabric(); - let name = peer_name(node); + let name = service_peer_name(&exposure.service, node); let peers = match fabric.peers().await { Ok(peers) => peers, Err(error) => return failed(error), @@ -181,14 +227,21 @@ pub async fn grant(state_dir: &Path, node: &str) -> Grant { /// Remove the grant st made for `node`. Leaves any peer st did not add. `Ok(None)` when there /// was nothing of st's to remove. pub async fn revoke(state_dir: &Path, node: &str) -> Result> { - let fabric = match GatewayExposure::load(state_dir)? { + let exposure = GatewayExposure::load(state_dir)?; + let fabric = match &exposure { Some(exposure) => exposure.fabric(), None => match crate::fleet::transport::resolve_tool(None, "fabric") { Some(program) => Fabric::new(program), None => return Ok(None), }, }; - let name = peer_name(node); + let fallback = if crate::config::NetworkPolicy::for_state_dir(state_dir).is_local_only() { + FIXTURE_SERVICE + } else { + DEFAULT_SERVICE + }; + let service = exposure.as_ref().map_or(fallback, |exposure| exposure.service.as_str()); + let name = service_peer_name(service, node); if !fabric .peers() .await? @@ -201,13 +254,13 @@ pub async fn revoke(state_dir: &Path, node: &str) -> Result> { Ok(Some(name)) } -/// Every peer st added for a device, by name. -pub async fn granted_peers(fabric: &Fabric) -> Result> { +/// Every peer st added for this gateway's devices, never another instance's. +pub async fn granted_peers(fabric: &Fabric, service: &str) -> Result> { Ok(fabric .peers() .await? .into_iter() - .filter(|(node, name)| valid_node_id(node) && *name == peer_name(node)) + .filter(|(node, name)| valid_node_id(node) && *name == service_peer_name(service, node)) .map(|(_, name)| name) .collect()) } @@ -220,6 +273,76 @@ mod tests { const PHONE: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const OTHER: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + #[test] + fn fixture_and_production_services_cannot_replace_each_other() { + assert_eq!(service_for_instance(None, None).unwrap(), DEFAULT_SERVICE); + assert_eq!(service_for_instance(Some(FIXTURE_SERVICE), None).unwrap(), FIXTURE_SERVICE); + assert_eq!(service_for_instance(Some(FIXTURE_SERVICE), Some(FIXTURE_SERVICE)).unwrap(), FIXTURE_SERVICE); + assert!(service_for_instance(Some(FIXTURE_SERVICE), Some(DEFAULT_SERVICE)).is_err()); + assert!(service_for_instance(Some(FIXTURE_SERVICE), Some("fractal-client-v0")).is_err()); + assert!(service_for_instance(None, Some(FIXTURE_SERVICE)).is_err()); + assert!(service_for_instance(Some("other"), Some(FIXTURE_SERVICE)).is_err()); + } + + #[test] + fn fixture_exposure_rejects_privileged_socket_aliases() { + let root = tempfile::tempdir().unwrap(); + let mut config = crate::config::Config::default(); + config.state_dir = root.path().join(".st-instance/client-control-fixture/state/st3"); + config.socket = root.path().join("private.sock"); + config.client_gateway_socket = root.path().join("paired.sock"); + assert_eq!(client_socket(&config).unwrap(), config.client_gateway_socket); + fs::write(&config.socket, "").unwrap(); + std::os::unix::fs::symlink(&config.socket, &config.client_gateway_socket).unwrap(); + assert!(client_socket(&config).is_err()); + fs::remove_file(&config.client_gateway_socket).unwrap(); + fs::create_dir_all(&config.state_dir).unwrap(); + let state_socket = crate::config::state_socket(&config.state_dir); + fs::write(&state_socket, "").unwrap(); + std::os::unix::fs::symlink(&state_socket, &config.client_gateway_socket).unwrap(); + assert!(client_socket(&config).is_err()); + } + + #[test] + fn fixture_settings_cannot_load_a_production_exposure() { + let root = tempfile::tempdir().unwrap(); + let state = root.path().join(".st-instance/client-control-fixture/state/st3"); + GatewayExposure { service: DEFAULT_SERVICE.into(), fabric: "/unused/fabric".into() }.save(&state).unwrap(); + assert!(GatewayExposure::load(&state).is_err()); + } + + #[tokio::test] + async fn fixture_grants_and_cleanup_never_modify_normal_device_peers() { + let root = tempfile::tempdir().unwrap(); + let log = enable(root.path(), &[(OTHER, "st-device-fedcba987654")]); + let mut exposure = GatewayExposure::load(root.path()).unwrap().unwrap(); + exposure.service = FIXTURE_SERVICE.into(); + let fixture_state = root.path().join(".st-instance/client-control-fixture/state/st3"); + exposure.save(&fixture_state).unwrap(); + assert_eq!(grant(&fixture_state, PHONE).await, Grant::Granted { + service: FIXTURE_SERVICE.into(), + peer: "st-fixture-device-0123456789ab".into(), + }); + assert!(calls(&log).ends_with(&format!( + "add {PHONE} st-fixture-device-0123456789ab --inbound-only --allow {FIXTURE_SERVICE}\n" + ))); + assert_eq!(revoke(&fixture_state, OTHER).await.unwrap(), None); + assert!(granted_peers(&exposure.fabric(), FIXTURE_SERVICE).await.unwrap().is_empty()); + assert_eq!(granted_peers(&exposure.fabric(), DEFAULT_SERVICE).await.unwrap(), ["st-device-fedcba987654"]); + assert_eq!(grant(&fixture_state, OTHER).await, Grant::AlreadyTrusted { + peer: "st-device-fedcba987654".into(), + }); + assert!(!calls(&log).contains("remove ")); + let log = enable(root.path(), &[ + (PHONE, "st-fixture-device-0123456789ab"), + (OTHER, "st-device-fedcba987654"), + ]); + assert_eq!(granted_peers(&exposure.fabric(), DEFAULT_SERVICE).await.unwrap(), ["st-device-fedcba987654"]); + assert_eq!(revoke(&fixture_state, PHONE).await.unwrap().as_deref(), Some("st-fixture-device-0123456789ab")); + assert!(calls(&log).ends_with("remove st-fixture-device-0123456789ab\n")); + assert!(!calls(&log).contains("remove st-device-")); + } + /// A fake `fabric` whose `peers --json` lists `peers`, as NodeID and name, and which logs /// every call. fn enable(root: &Path, peers: &[(&str, &str)]) -> PathBuf { diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index a2f8db0e2de..0a9ba901d9c 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -2235,9 +2235,9 @@ enum DevicesFabricCommand { /// device that sends its Fabric NodeID grants it that service, inbound-only, and revoking /// the pairing removes the grant. Needs fabric 0.2.41 or later. Enable { - /// The Fabric service name devices dial. - #[arg(long, default_value = fractal_daemon::fabric_gateway::DEFAULT_SERVICE)] - service: String, + /// The Fabric service devices dial (default st3-client-v0; fixture uses client-control-fixture). + #[arg(long)] + service: Option, }, /// Stop exposing the client gateway over Fabric and remove every grant Fractal made for a device. Disable, @@ -9466,10 +9466,11 @@ async fn run_devices_fabric( let exposure = GatewayExposure::load(state_dir)?; match command { DevicesFabricCommand::Enable { service } => { - anyhow::ensure!( - !service.trim().is_empty(), - "the Fabric service name must not be empty" - ); + let service = fractal_daemon::fabric_gateway::service_for_instance( + config.local_only().then_some(fractal_daemon::fabric_gateway::FIXTURE_SERVICE), + service.as_deref(), + )?; + let client_socket = fractal_daemon::fabric_gateway::client_socket(config)?; let override_path = fractal_daemon::config::FleetFile::load(state_dir)?.and_then(|file| file.fabric); let program = resolve_tool(override_path.as_deref(), "fabric") .context("this machine cannot use Fabric: fabric is not installed")?; @@ -9484,10 +9485,7 @@ async fn run_devices_fabric( fabric .expose_socket( &service, - &fractal_socket_discovery::default_unix_socket( - &config.client_gateway_socket, - &config.client_gateway_socket, - ), + &client_socket, ) .await .context("Fabric could not expose the client gateway; nothing changed")?; @@ -9511,7 +9509,7 @@ async fn run_devices_fabric( }; let fabric = Fabric::new(exposure.fabric.clone()); let mut removed = Vec::new(); - for peer in granted_peers(&fabric).await? { + for peer in granted_peers(&fabric, &exposure.service).await? { fabric.remove(&peer).await?; removed.push(peer); } @@ -9531,7 +9529,7 @@ async fn run_devices_fabric( } DevicesFabricCommand::Status => { let peers = match &exposure { - Some(exposure) => granted_peers(&Fabric::new(exposure.fabric.clone())).await?, + Some(exposure) => granted_peers(&Fabric::new(exposure.fabric.clone()), &exposure.service).await?, None => Vec::new(), }; if json_output { diff --git a/crates/fractal/tests/action_coverage.rs b/crates/fractal/tests/action_coverage.rs index 109365a1bc0..802bc223a9e 100644 --- a/crates/fractal/tests/action_coverage.rs +++ b/crates/fractal/tests/action_coverage.rs @@ -7307,3 +7307,61 @@ fn cli_devices_fabric_enable_status_and_disable_touch_only_st_peers() { format!("expose st3-client-v0 --socket {}\n", legacy.canonicalize().unwrap().display()) ); } + +#[test] +fn cli_fixture_fabric_exposes_only_paired_socket_and_preserves_normal_peers() { + use std::os::unix::fs::PermissionsExt as _; + if fractal_daemon::test_support::supervise_test() { + return; + } + let root = tempfile::tempdir().unwrap(); + let bin = root.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let calls = root.path().join("calls"); + let program = bin.join("fabric"); + std::fs::write(&program, format!( + "#!/bin/sh\necho \"$@\" >> '{}'\ncase \"$1\" in peers) echo '{{\"peers\":[{{\"node\":\"{}\",\"name\":\"st-device-0123456789ab\",\"allow\":[\"st3-client-v0\"]}},{{\"node\":\"{}\",\"name\":\"st-fixture-device-fedcba987654\",\"allow\":[\"client-control-fixture\"]}}]}}';; esac\n", + calls.display(), "0123456789abcdef".repeat(4), "fedcba9876543210".repeat(4) + )).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + let config = root.path().join("config.toml"); + let state = root.path().join("state"); + let paired = root.path().join("paired.sock"); + let privileged = root.path().join("privileged.sock"); + let write_config = |socket: &std::path::Path| { + std::fs::write(&config, format!( + "state_dir = '{}'\nsocket = '{}'\nclient_gateway_socket = '{}'\n", + state.display(), privileged.display(), socket.display() + )).unwrap(); + }; + write_config(&paired); + let run = |instance: bool, arguments: &[&str]| { + let mut command = fractal_daemon::test_support::command(test_bin!("fractal-fixture")); + command.env_remove("ST_AGENT").env_remove("ST_MISSION_RUN").env_remove("ST_INSTANCE") + .env("HOME", root.path().join("home")) + .env("XDG_CONFIG_HOME", root.path().join("config")) + .env("XDG_STATE_HOME", root.path().join("xdg-state")) + .env("XDG_RUNTIME_DIR", root.path().join("run")).env("PATH", &bin); + if instance { command.args(["--instance", "client-control-fixture"]); } + command.arg("--config").arg(&config).args(["--json", "devices", "fabric"]).args(arguments).output().unwrap() + }; + assert!(!run(true, &["enable", "--service", "st3-client-v0"]).status.success()); + assert!(!run(false, &["enable", "--service", "client-control-fixture"]).status.success()); + assert!(!calls.exists(), "rejected service changes never invoke Fabric"); + assert_eq!(cli_value(run(true, &["enable"])), json!({"service":"client-control-fixture","enabled":true})); + let expose = std::fs::read_to_string(&calls).unwrap(); + assert_eq!(expose, format!("expose client-control-fixture --socket {}\n", paired.display())); + assert!(!expose.contains(privileged.to_str().unwrap())); + assert_eq!(cli_value(run(true, &["status"])), json!({ + "service":"client-control-fixture","enabled":true,"granted":["st-fixture-device-fedcba987654"] + })); + assert_eq!(cli_value(run(true, &["disable"])), json!({ + "service":"client-control-fixture","enabled":false,"removed":["st-fixture-device-fedcba987654"] + })); + let recorded = std::fs::read_to_string(&calls).unwrap(); + assert!(recorded.ends_with("remove st-fixture-device-fedcba987654\nunexpose client-control-fixture\n")); + assert!(!recorded.contains("remove st-device-")); + write_config(&privileged); + assert!(!run(true, &["enable"]).status.success()); + assert_eq!(std::fs::read_to_string(&calls).unwrap(), recorded, "privileged sockets are never exposed"); +} diff --git a/docs/st3/ios-fabric-protocol.md b/docs/st3/ios-fabric-protocol.md index be238af49e4..1439530b1b7 100644 --- a/docs/st3/ios-fabric-protocol.md +++ b/docs/st3/ios-fabric-protocol.md @@ -120,6 +120,18 @@ Fractal only changes peers named `st-device-…` that it added, never a grant ma it never grants anything but the one client gateway service. Each member grants only the phones paired with it: each member is paired on its own. +The reserved `client-control-fixture` instance is separate: its `devices fabric enable` +selects only the exact `client-control-fixture` service and its configured paired-client +socket, never a privileged socket or production's legacy socket discovery. It refuses +production service names; other instances refuse the reserved fixture service. Fixture +pairings use distinct `st-fixture-device-…` inbound-only peers with only that service. +Status, revoke and disable scope cleanup to the instance's peer names, leaving normal +`st-device-…` peers and `st3-client-v0` unchanged. Use fresh test-app Fabric identities: +an already-trusted normal identity is never re-granted to the fixture. Native Rust and +Node clients address it as `fabric://NODE_ID/client-control-fixture`; the iOS bridge +already accepts an exact node/service target. Source support grants no setup, exposure, +pairing or enrollment authority: those effects require their separate approved gates. + What this does not cover yet: - **The first pairing cannot use Fabric.** Until it is paired, the phone has no grant, diff --git a/release-notes/fixture-instance-fabric-service.json b/release-notes/fixture-instance-fabric-service.json new file mode 100644 index 00000000000..23db8f95aab --- /dev/null +++ b/release-notes/fixture-instance-fabric-service.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Isolate the reserved fixture instance behind its own paired-client Fabric service and inbound-only peer namespace.", + "replay": { "status": "none", "detail": "No stored claim replay is required." }, + "schema": { "status": "none", "detail": "The client wire protocol and generated schemas are unchanged." }, + "rules": { "status": "changed", "detail": "Fixture exposure accepts only the client-control-fixture service and paired client socket; production exposure and fixture peer grants stay separate." }, + "compatibility": { "status": "none", "detail": "Normal instances retain st3-client-v0 and existing client alias behavior." }, + "downtime": { "status": "none", "detail": "Source changes do not configure or restart Fabric or daemon services." }, + "manual": { "status": "changed", "detail": "Any fixture exposure or peer addition requires separately reviewed scope and authorization before use." }, + "recovery": { "status": "changed", "detail": "Remove each fixture peer and the fixture exposure independently while preserving normal peers and exposure." } +} From 8491d22d2c803bde192eb1cc0eb785420dbc9f3b Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:09:56 +0100 Subject: [PATCH 2/4] fix(instance): allow only fixture-derived store and socket paths 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-kernel/src/config.rs | 2 + .../src/config/fixture_paths.rs | 214 ++++++++++++++++++ .../fractal-kernel/src/config/local_only.rs | 17 +- crates/fractal-kernel/src/fabric_gateway.rs | 13 +- .../fractal-seats-base/src/node_identity.rs | 1 + crates/fractal/src/main.rs | 3 + crates/fractal/tests/action_coverage.rs | 59 ++++- docs/st3/ios-fabric-protocol.md | 11 +- .../fixture-instance-path-allowlist.json | 11 + 9 files changed, 314 insertions(+), 17 deletions(-) create mode 100644 crates/fractal-kernel/src/config/fixture_paths.rs create mode 100644 release-notes/fixture-instance-path-allowlist.json diff --git a/crates/fractal-kernel/src/config.rs b/crates/fractal-kernel/src/config.rs index df776ecf4ce..1a97f70d719 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 fixture_paths; +pub use fixture_paths::FixturePathRefusal; mod local_only; pub use local_only::{CLIENT_CONTROL_FIXTURE, LocalOnlyRefusal, NetworkOperation, NetworkPolicy}; diff --git a/crates/fractal-kernel/src/config/fixture_paths.rs b/crates/fractal-kernel/src/config/fixture_paths.rs new file mode 100644 index 00000000000..3aa85c6ac05 --- /dev/null +++ b/crates/fractal-kernel/src/config/fixture_paths.rs @@ -0,0 +1,214 @@ +//! Reserved fixture paths are derived from the instance, never from configurable overrides. +use std::fs; +use std::path::{Path, PathBuf}; + +use super::{CLIENT_CONTROL_FIXTURE, Config, STORE_DATABASE_FILES}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +pub struct FixturePathRefusal { + pub code: &'static str, + pub path: &'static str, +} + +impl std::fmt::Display for FixturePathRefusal { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {} must be the fixture instance's own non-symlink path", self.code, self.path) + } +} + +impl std::error::Error for FixturePathRefusal {} + +fn refusal(path: &'static str) -> FixturePathRefusal { + FixturePathRefusal { code: "local-only-fixture-path-refused", path } +} + +/// The active reserved instance is authoritative even when state_dir was overridden. +/// Recognizing an explicit reserved state path is only a fallback without that instance. +fn fixture_root(state_dir: &Path) -> Option { + if let Some(instance) = crate::instance::current() + && instance.name() == CLIENT_CONTROL_FIXTURE + { + return Some(instance.root().to_owned()); + } + state_dir.ancestors().find(|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" + }) + }).map(Path::to_owned) +} + +// Validation must work before setup creates any fixture directories. Resolve the +// closest existing ancestor without creating files or following a missing leaf. +fn canonical_or_missing(path: &Path) -> std::io::Result { + match fs::canonicalize(path) { + Ok(path) => Ok(path), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let parent = path.parent().ok_or(error)?; + let leaf = path.file_name().ok_or_else(|| std::io::Error::other("missing path leaf"))?; + Ok(canonical_or_missing(parent)?.join(leaf)) + } + Err(error) => Err(error), + } +} + +fn exact_path(actual: &Path, expected: &Path, root: &Path, kind: &'static str) -> Result<(), FixturePathRefusal> { + let fail = || refusal(kind); + // Reject outside-root aliases even when they resolve back to the allowed path. + if !root.is_absolute() || actual != expected { + return Err(fail()); + } + match fs::symlink_metadata(root.parent().ok_or_else(fail)?) { + Ok(metadata) if metadata.file_type().is_symlink() => return Err(fail()), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(fail()), + } + let relative = expected.strip_prefix(root).map_err(|_| fail())?; + let mut prefix = root.to_owned(); + for component in std::iter::once(None).chain(relative.components().map(Some)) { + if let Some(component) = component { + prefix.push(component); + } + match fs::symlink_metadata(&prefix) { + Ok(metadata) if metadata.file_type().is_symlink() => return Err(fail()), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(fail()), + } + } + // Resolve the parent separately: a socket/database leaf may not exist yet. + let parent = actual.parent().ok_or_else(fail)?; + let leaf = actual.file_name().ok_or_else(fail)?; + let resolved = canonical_or_missing(parent).map_err(|_| fail())?.join(leaf); + let allowed = canonical_or_missing(root).map_err(|_| fail())?.join(relative); + if resolved != allowed { + return Err(fail()); + } + Ok(()) +} + +fn validate_at_root(config: &Config, root: &Path) -> Result<(), FixturePathRefusal> { + let state = fractal_client::product_dirs::resolve(root.join("state/st3"), root.join("state/fractal")); + exact_path(&config.state_dir, &state, root, "state_dir")?; + exact_path(&super::store_dir(config), &state, root, "store_dir")?; + exact_path(&config.store_path(), &state.join(STORE_DATABASE_FILES[0]), root, "store_path")?; + for name in &STORE_DATABASE_FILES[1..] { + exact_path(&state.join(name), &state.join(name), root, "store_path")?; + } + let socket = root.join("run/fractal-client.sock"); + exact_path(&config.client_gateway_socket, &socket, root, "client_gateway_socket")?; + let bound = super::socket_binding_path(&socket); + exact_path(&bound, &bound, root, "client_gateway_socket") +} + +impl Config { + /// Validate before any identity/store effects, again at client bind and Fabric exposure. + pub fn validate_fixture_paths(&self) -> Result<(), FixturePathRefusal> { + if !self.local_only() { + return Ok(()); + } + let root = fixture_root(&self.state_dir).ok_or_else(|| refusal("state_dir"))?; + validate_at_root(self, &root) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(root: &Path) -> Config { + Config { + state_dir: root.join("state/st3"), + socket: root.join("run/fractal.sock"), + client_gateway_socket: root.join("run/fractal-client.sock"), + ..Config::default() + } + } + + #[test] + fn only_own_fixture_paths_are_accepted_without_creating_them() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".st-instance/client-control-fixture"); + let config = fixture(&root); + validate_at_root(&config, &root).unwrap(); + config.validate_fixture_paths().unwrap(); + assert!(!root.exists()); + let mut explicit = fixture(&root); + explicit.store_dir = Some(explicit.state_dir.clone()); + validate_at_root(&explicit, &root).unwrap(); + } + + #[test] + fn ordinary_instances_keep_their_configurable_paths() { + let home = tempfile::tempdir().unwrap(); + let mut config = fixture(&home.path().join(".st-instance/ordinary")); + config.store_dir = Some(home.path().join("external-store")); + config.client_gateway_socket = home.path().join("external-client.sock"); + config.validate_fixture_paths().unwrap(); + assert!(!home.path().join("external-store").exists()); + } + + #[test] + fn default_other_instance_and_outside_sockets_are_refused() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".st-instance/client-control-fixture"); + for socket in [ + home.path().join("run/fractal.sock"), + home.path().join("run/fractal-client.sock"), + home.path().join(".st-instance/other/run/fractal.sock"), + home.path().join(".st-instance/other/run/fractal-client.sock"), + root.join("run/fractal.sock"), + home.path().join("outside.sock"), + ] { + let mut config = fixture(&root); + config.client_gateway_socket = socket; + assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "client_gateway_socket"); + } + } + + #[test] + fn state_and_store_overrides_cannot_move_reserved_instance_authority() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".st-instance/client-control-fixture"); + let other = home.path().join(".st-instance/other/state/st3"); + fs::create_dir_all(&other).unwrap(); + let mut config = fixture(&root); + config.store_dir = Some(other.clone()); + assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "store_dir"); + config.store_dir = None; + config.state_dir = other; + assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "state_dir"); + assert!(!root.exists()); + } + + #[test] + fn symlinked_state_store_database_and_socket_are_refused() { + use std::os::unix::fs::symlink; + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".st-instance/client-control-fixture"); + let config = fixture(&root); + let other = home.path().join("other"); + fs::create_dir_all(&other).unwrap(); + fs::create_dir_all(config.state_dir.parent().unwrap()).unwrap(); + symlink(&other, &config.state_dir).unwrap(); + assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "state_dir"); + fs::remove_file(&config.state_dir).unwrap(); + fs::create_dir_all(&config.state_dir).unwrap(); + let alias = root.join("store-alias"); + symlink(&config.state_dir, &alias).unwrap(); + let mut aliased = fixture(&root); + aliased.store_dir = Some(alias); + assert_eq!(validate_at_root(&aliased, &root).unwrap_err().path, "store_dir"); + symlink(other.join("claims.sqlite3"), config.store_path()).unwrap(); + assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "store_path"); + fs::remove_file(config.store_path()).unwrap(); + fs::create_dir_all(config.client_gateway_socket.parent().unwrap()).unwrap(); + symlink(other.join("client.sock"), &config.client_gateway_socket).unwrap(); + assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "client_gateway_socket"); + fs::remove_file(&config.client_gateway_socket).unwrap(); + fs::remove_dir(config.client_gateway_socket.parent().unwrap()).unwrap(); + symlink(&other, root.join("run")).unwrap(); + assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "client_gateway_socket"); + } +} diff --git a/crates/fractal-kernel/src/config/local_only.rs b/crates/fractal-kernel/src/config/local_only.rs index d53711ae2f3..3c857b81a23 100644 --- a/crates/fractal-kernel/src/config/local_only.rs +++ b/crates/fractal-kernel/src/config/local_only.rs @@ -76,7 +76,7 @@ impl NetworkPolicy { }) } - pub fn validate_config(self, config: &Config) -> Result<(), LocalOnlyRefusal> { + pub fn validate_config(self, config: &Config) -> anyhow::Result<()> { if config.fleet_id.is_some() || config.fleet.is_some() { self.require(NetworkOperation::FleetJoin)?; } @@ -86,6 +86,7 @@ impl NetworkPolicy { if !config.peers.is_empty() { self.require(NetworkOperation::PeerRelay)?; } + config.validate_fixture_paths()?; Ok(()) } } @@ -128,15 +129,21 @@ mod tests { #[test] fn stale_or_injected_network_configuration_is_refused() { - let mut config = Config::default(); + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".st-instance/client-control-fixture"); + let mut config = Config { + state_dir: root.join("state/st3"), + client_gateway_socket: root.join("run/fractal-client.sock"), + ..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); + assert_eq!(NetworkPolicy::LocalOnly.validate_config(&config).unwrap_err().downcast_ref::().unwrap().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); + assert_eq!(NetworkPolicy::LocalOnly.validate_config(&config).unwrap_err().downcast_ref::().unwrap().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); + assert_eq!(NetworkPolicy::LocalOnly.validate_config(&config).unwrap_err().downcast_ref::().unwrap().operation, NetworkOperation::PeerRelay); } } diff --git a/crates/fractal-kernel/src/fabric_gateway.rs b/crates/fractal-kernel/src/fabric_gateway.rs index 9a7b03c0b13..2e3baec1080 100644 --- a/crates/fractal-kernel/src/fabric_gateway.rs +++ b/crates/fractal-kernel/src/fabric_gateway.rs @@ -46,7 +46,7 @@ pub fn client_socket(config: &crate::config::Config) -> Result { }; let resolve = |path: &Path| fs::canonicalize(path).or_else(|_| std::path::absolute(path)); let resolved = resolve(&socket)?; - for privileged in [&config.socket, &crate::config::state_socket(&config.state_dir)] { + for privileged in [&config.socket, &config.state_dir.join("run/fractal.sock")] { anyhow::ensure!(resolved != resolve(privileged)?, "Fabric must never expose a privileged socket"); } Ok(socket) @@ -288,16 +288,19 @@ mod tests { fn fixture_exposure_rejects_privileged_socket_aliases() { let root = tempfile::tempdir().unwrap(); let mut config = crate::config::Config::default(); - config.state_dir = root.path().join(".st-instance/client-control-fixture/state/st3"); - config.socket = root.path().join("private.sock"); - config.client_gateway_socket = root.path().join("paired.sock"); + let fixture_root = root.path().join(".st-instance/client-control-fixture"); + config.state_dir = fixture_root.join("state/st3"); + config.socket = fixture_root.join("run/fractal.sock"); + config.client_gateway_socket = fixture_root.join("run/fractal-client.sock"); assert_eq!(client_socket(&config).unwrap(), config.client_gateway_socket); + fs::create_dir_all(config.socket.parent().unwrap()).unwrap(); fs::write(&config.socket, "").unwrap(); std::os::unix::fs::symlink(&config.socket, &config.client_gateway_socket).unwrap(); assert!(client_socket(&config).is_err()); fs::remove_file(&config.client_gateway_socket).unwrap(); fs::create_dir_all(&config.state_dir).unwrap(); - let state_socket = crate::config::state_socket(&config.state_dir); + let state_socket = config.state_dir.join("run/fractal.sock"); + fs::create_dir_all(state_socket.parent().unwrap()).unwrap(); fs::write(&state_socket, "").unwrap(); std::os::unix::fs::symlink(&state_socket, &config.client_gateway_socket).unwrap(); assert!(client_socket(&config).is_err()); diff --git a/crates/fractal-seats-base/src/node_identity.rs b/crates/fractal-seats-base/src/node_identity.rs index 7b9d44b1a34..569a4dc0ca1 100644 --- a/crates/fractal-seats-base/src/node_identity.rs +++ b/crates/fractal-seats-base/src/node_identity.rs @@ -416,6 +416,7 @@ fn apply_node(config: &mut Config, node: String) -> Result<()> { /// An explicit path is an operator-selected existing directory, never a mkdir request. /// Inspect both locations before opening SQLite or persisting a node pin. fn validate_store_location(config: &Config) -> Result { + config.validate_fixture_paths()?; let directory = store_dir(config); if config.store_dir.is_none() { reject_store_database_symlinks(&directory)?; diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 0a9ba901d9c..6de49f39ab2 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -7055,6 +7055,7 @@ fn open_daemon_store( config: &Config, identity: &fractal_daemon::node_identity::StateLock, ) -> Result { + config.validate_fixture_paths()?; let admitted = identity.admitted_store(); let store = match &admitted { Some(admitted) => Store::open_admitted(admitted.clone(), &config.node)?, @@ -7280,6 +7281,7 @@ async fn run_up(args: UpArgs) -> Result<()> { if !args.peer.is_empty() { config.peers = args.peer; } + config.network_policy().validate_config(&config)?; config.apply_fleet_file()?; let configured_node = config.node.clone(); let identity_started = std::time::SystemTime::now(); @@ -7327,6 +7329,7 @@ async fn run_up(args: UpArgs) -> Result<()> { startup.phase("bind-listeners"); let state_socket = config.state_dir.join("run/fractal.sock"); state_identity.verify_store_directory(&config)?; + config.network_policy().validate_config(&config)?; let (local_reserved, gateway_reserved) = tokio::try_join!( fractal_daemon::api::reserve_unix_socket(&config.socket, Some(&state_socket)), fractal_daemon::api::reserve_unix_socket(&config.client_gateway_socket, None), diff --git a/crates/fractal/tests/action_coverage.rs b/crates/fractal/tests/action_coverage.rs index 802bc223a9e..01dd3c61bbe 100644 --- a/crates/fractal/tests/action_coverage.rs +++ b/crates/fractal/tests/action_coverage.rs @@ -7325,9 +7325,12 @@ fn cli_fixture_fabric_exposes_only_paired_socket_and_preserves_normal_peers() { )).unwrap(); std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); let config = root.path().join("config.toml"); - let state = root.path().join("state"); - let paired = root.path().join("paired.sock"); - let privileged = root.path().join("privileged.sock"); + let instance = fractal_daemon::instance::Instance::new( + &root.path().join("home"), "client-control-fixture", + ).unwrap(); + let state = instance.state_home().join("st3"); + let paired = instance.run_dir().join("fractal-client.sock"); + let privileged = instance.run_dir().join("fractal.sock"); let write_config = |socket: &std::path::Path| { std::fs::write(&config, format!( "state_dir = '{}'\nsocket = '{}'\nclient_gateway_socket = '{}'\n", @@ -7346,7 +7349,7 @@ fn cli_fixture_fabric_exposes_only_paired_socket_and_preserves_normal_peers() { command.arg("--config").arg(&config).args(["--json", "devices", "fabric"]).args(arguments).output().unwrap() }; assert!(!run(true, &["enable", "--service", "st3-client-v0"]).status.success()); - assert!(!run(false, &["enable", "--service", "client-control-fixture"]).status.success()); + assert!(!run(false, &["enable", "--service", "st3-client-v0"]).status.success()); assert!(!calls.exists(), "rejected service changes never invoke Fabric"); assert_eq!(cli_value(run(true, &["enable"])), json!({"service":"client-control-fixture","enabled":true})); let expose = std::fs::read_to_string(&calls).unwrap(); @@ -7365,3 +7368,51 @@ fn cli_fixture_fabric_exposes_only_paired_socket_and_preserves_normal_peers() { assert!(!run(true, &["enable"]).status.success()); assert_eq!(std::fs::read_to_string(&calls).unwrap(), recorded, "privileged sockets are never exposed"); } + +#[test] +fn cli_fixture_path_overrides_refused_before_identity_store_or_bind() { + if fractal_daemon::test_support::supervise_test() { + return; + } + let root = tempfile::tempdir().unwrap(); + let home = root.path().join("home"); + let fixture = fractal_daemon::instance::Instance::new(&home, "client-control-fixture").unwrap(); + let other = root.path().join("other-instance"); + std::fs::create_dir_all(&other).unwrap(); + let database = other.join("claims.sqlite3"); + std::fs::write(&database, "untouched other-instance database").unwrap(); + let own_socket = fixture.run_dir().join("fractal-client.sock"); + let run = |flag: &str, path: &std::path::Path| { + let mut command = fractal_daemon::test_support::command(test_bin!("fractal-fixture")); + command.env_remove("ST_AGENT").env_remove("ST_MISSION_RUN").env_remove("ST_INSTANCE") + .env("HOME", &home) + .env("XDG_CONFIG_HOME", root.path().join("config")) + .env("XDG_STATE_HOME", root.path().join("state")) + .env("XDG_RUNTIME_DIR", root.path().join("run")) + .args(["--instance", "client-control-fixture", "up", "--web-listen", "off"]); + if flag != "--client-gateway-socket" { + command.arg("--client-gateway-socket").arg(&own_socket); + } + let output = command.arg(flag).arg(path).output().unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("local-only-fixture-path-refused"), + "{}", String::from_utf8_lossy(&output.stderr)); + assert_eq!(std::fs::read_to_string(&database).unwrap(), "untouched other-instance database"); + assert!(!fixture.state_home().exists(), "refusal precedes identity and database effects"); + }; + for (flag, path) in [ + ("--state-dir", other.clone()), + ("--store-dir", other.clone()), + ("--client-gateway-socket", root.path().join("run/fractal.sock")), + ("--client-gateway-socket", root.path().join("run/fractal-client.sock")), + ("--client-gateway-socket", other.join("fractal.sock")), + ("--client-gateway-socket", other.join("fractal-client.sock")), + ] { + run(flag, &path); + assert!(!fixture.root().exists()); + } + std::fs::create_dir_all(fixture.run_dir()).unwrap(); + std::os::unix::fs::symlink(&database, &own_socket).unwrap(); + run("--client-gateway-socket", &own_socket); + assert!(std::fs::symlink_metadata(&own_socket).unwrap().file_type().is_symlink()); +} diff --git a/docs/st3/ios-fabric-protocol.md b/docs/st3/ios-fabric-protocol.md index 1439530b1b7..24e39e09bfb 100644 --- a/docs/st3/ios-fabric-protocol.md +++ b/docs/st3/ios-fabric-protocol.md @@ -121,9 +121,14 @@ it never grants anything but the one client gateway service. Each member grants the phones paired with it: each member is paired on its own. The reserved `client-control-fixture` instance is separate: its `devices fabric enable` -selects only the exact `client-control-fixture` service and its configured paired-client -socket, never a privileged socket or production's legacy socket discovery. It refuses -production service names; other instances refuse the reserved fixture service. Fixture +selects only the exact `client-control-fixture` service and its own instance-derived +`run/fractal-client.sock`, never arbitrary configured sockets, privileged sockets or +production's legacy socket discovery. Its state and SQLite directory/database must +also be the instance-derived paths: outside-root overrides and symlink aliases fail +with `local-only-fixture-path-refused` before identity/store effects, at bind and at +exposure. The active instance root comes from HOME, not a state-dir override; an +explicit reserved state path is only a fallback when that instance is not active. +It refuses production service names; other instances refuse the reserved fixture service. Fixture pairings use distinct `st-fixture-device-…` inbound-only peers with only that service. Status, revoke and disable scope cleanup to the instance's peer names, leaving normal `st-device-…` peers and `st3-client-v0` unchanged. Use fresh test-app Fabric identities: diff --git a/release-notes/fixture-instance-path-allowlist.json b/release-notes/fixture-instance-path-allowlist.json new file mode 100644 index 00000000000..23e74a19902 --- /dev/null +++ b/release-notes/fixture-instance-path-allowlist.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Reject reserved fixture state, store and client socket overrides outside the instance-derived paths before startup effects.", + "replay": { "status": "none", "detail": "Existing claims are not rewritten." }, + "schema": { "status": "none", "detail": "No claim or protocol schema changes." }, + "rules": { "status": "changed", "detail": "The reserved fixture accepts only its own instance-derived state, store, database and paired client socket paths without symlink aliases." }, + "compatibility": { "status": "changed", "detail": "Reserved fixture path overrides are refused with local-only-fixture-path-refused; ordinary instance path configuration is unchanged." }, + "downtime": { "status": "none", "detail": "No deployment, setup or restart is performed by this source change." }, + "manual": { "status": "changed", "detail": "Fixture configuration must use its instance-derived paths before startup or exposure." }, + "recovery": { "status": "none", "detail": "Refused paths are rejected before identity or database access and require no store recovery." } +} From 83fc3db4efb89ffbcca4574fb96114424b4cd259 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:14:45 +0100 Subject: [PATCH 3/4] fix(instance): constrain fixture privileged listener before startup 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/config/fixture_paths.rs | 51 ++++++++++++++++++- .../fractal-kernel/src/config/local_only.rs | 1 + crates/fractal/tests/action_coverage.rs | 11 ++++ docs/st3/ios-fabric-protocol.md | 2 + .../fixture-instance-operator-socket.json | 11 ++++ 5 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 release-notes/fixture-instance-operator-socket.json diff --git a/crates/fractal-kernel/src/config/fixture_paths.rs b/crates/fractal-kernel/src/config/fixture_paths.rs index 3aa85c6ac05..b9a66e78cdd 100644 --- a/crates/fractal-kernel/src/config/fixture_paths.rs +++ b/crates/fractal-kernel/src/config/fixture_paths.rs @@ -96,6 +96,10 @@ fn validate_at_root(config: &Config, root: &Path) -> Result<(), FixturePathRefus for name in &STORE_DATABASE_FILES[1..] { exact_path(&state.join(name), &state.join(name), root, "store_path")?; } + let operator = root.join("run/fractal.sock"); + exact_path(&config.socket, &operator, root, "socket")?; + let operator_bound = super::socket_binding_path(&operator); + exact_path(&operator_bound, &operator_bound, root, "socket")?; let socket = root.join("run/fractal-client.sock"); exact_path(&config.client_gateway_socket, &socket, root, "client_gateway_socket")?; let bound = super::socket_binding_path(&socket); @@ -145,6 +149,7 @@ mod tests { let mut config = fixture(&home.path().join(".st-instance/ordinary")); config.store_dir = Some(home.path().join("external-store")); config.client_gateway_socket = home.path().join("external-client.sock"); + config.socket = home.path().join("external-operator.sock"); config.validate_fixture_paths().unwrap(); assert!(!home.path().join("external-store").exists()); } @@ -167,6 +172,50 @@ mod tests { } } + #[test] + fn privileged_socket_overrides_and_symlink_aliases_are_refused_before_effects() { + use std::os::unix::fs::symlink; + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".st-instance/client-control-fixture"); + for socket in [ + home.path().join("run/fractal.sock"), + home.path().join(".st-instance/other/run/fractal.sock"), + home.path().join("outside.sock"), + ] { + let mut config = fixture(&root); + config.socket = socket; + let error = validate_at_root(&config, &root).unwrap_err(); + assert_eq!(error.code, "local-only-fixture-path-refused"); + assert_eq!(error.path, "socket"); + assert!(!root.exists()); + } + let config = fixture(&root); + fs::create_dir_all(config.socket.parent().unwrap()).unwrap(); + symlink(home.path().join("normal.sock"), &config.socket).unwrap(); + assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "socket"); + fs::remove_file(&config.socket).unwrap(); + fs::remove_dir(config.socket.parent().unwrap()).unwrap(); + let outside = home.path().join("outside"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, root.join("run")).unwrap(); + assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "socket"); + assert!(!config.state_dir.exists()); + assert!(!config.store_path().exists()); + } + + #[test] + fn privileged_legacy_binding_leaf_cannot_alias_another_socket() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join("x".repeat(108)).join(".st-instance/client-control-fixture"); + let config = fixture(&root); + let bound = super::super::socket_binding_path(&config.socket); + assert_ne!(bound.as_ref(), config.socket); + fs::create_dir_all(bound.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(home.path().join("normal.sock"), bound.as_ref()).unwrap(); + assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "socket"); + assert!(!config.state_dir.exists()); + } + #[test] fn state_and_store_overrides_cannot_move_reserved_instance_authority() { let home = tempfile::tempdir().unwrap(); @@ -209,6 +258,6 @@ mod tests { fs::remove_file(&config.client_gateway_socket).unwrap(); fs::remove_dir(config.client_gateway_socket.parent().unwrap()).unwrap(); symlink(&other, root.join("run")).unwrap(); - assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "client_gateway_socket"); + assert_eq!(validate_at_root(&config, &root).unwrap_err().path, "socket"); } } diff --git a/crates/fractal-kernel/src/config/local_only.rs b/crates/fractal-kernel/src/config/local_only.rs index 3c857b81a23..ab858a9703c 100644 --- a/crates/fractal-kernel/src/config/local_only.rs +++ b/crates/fractal-kernel/src/config/local_only.rs @@ -133,6 +133,7 @@ mod tests { let root = home.path().join(".st-instance/client-control-fixture"); let mut config = Config { state_dir: root.join("state/st3"), + socket: root.join("run/fractal.sock"), client_gateway_socket: root.join("run/fractal-client.sock"), ..Config::default() }; diff --git a/crates/fractal/tests/action_coverage.rs b/crates/fractal/tests/action_coverage.rs index 01dd3c61bbe..4c5a0885cd6 100644 --- a/crates/fractal/tests/action_coverage.rs +++ b/crates/fractal/tests/action_coverage.rs @@ -7403,6 +7403,8 @@ fn cli_fixture_path_overrides_refused_before_identity_store_or_bind() { for (flag, path) in [ ("--state-dir", other.clone()), ("--store-dir", other.clone()), + ("--socket", root.path().join("run/fractal.sock")), + ("--socket", other.join("fractal.sock")), ("--client-gateway-socket", root.path().join("run/fractal.sock")), ("--client-gateway-socket", root.path().join("run/fractal-client.sock")), ("--client-gateway-socket", other.join("fractal.sock")), @@ -7415,4 +7417,13 @@ fn cli_fixture_path_overrides_refused_before_identity_store_or_bind() { std::os::unix::fs::symlink(&database, &own_socket).unwrap(); run("--client-gateway-socket", &own_socket); assert!(std::fs::symlink_metadata(&own_socket).unwrap().file_type().is_symlink()); + std::fs::remove_file(&own_socket).unwrap(); + let operator = fixture.run_dir().join("fractal.sock"); + std::os::unix::fs::symlink(&database, &operator).unwrap(); + run("--socket", &operator); + assert!(std::fs::symlink_metadata(&operator).unwrap().file_type().is_symlink()); + std::fs::remove_file(&operator).unwrap(); + std::fs::remove_dir(fixture.run_dir()).unwrap(); + std::os::unix::fs::symlink(&other, fixture.run_dir()).unwrap(); + run("--socket", &operator); } diff --git a/docs/st3/ios-fabric-protocol.md b/docs/st3/ios-fabric-protocol.md index 24e39e09bfb..cb200ae33be 100644 --- a/docs/st3/ios-fabric-protocol.md +++ b/docs/st3/ios-fabric-protocol.md @@ -128,6 +128,8 @@ also be the instance-derived paths: outside-root overrides and symlink aliases f with `local-only-fixture-path-refused` before identity/store effects, at bind and at exposure. The active instance root comes from HOME, not a state-dir override; an explicit reserved state path is only a fallback when that instance is not active. +The privileged operator listener is likewise fixed to `/run/fractal.sock`; +both that pathname and its actual legacy binding pathname reject symlink aliases. It refuses production service names; other instances refuse the reserved fixture service. Fixture pairings use distinct `st-fixture-device-…` inbound-only peers with only that service. Status, revoke and disable scope cleanup to the instance's peer names, leaving normal diff --git a/release-notes/fixture-instance-operator-socket.json b/release-notes/fixture-instance-operator-socket.json new file mode 100644 index 00000000000..b482b7ff1ec --- /dev/null +++ b/release-notes/fixture-instance-operator-socket.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Constrain the reserved fixture privileged operator listener and legacy binding alias to the fixture-derived paths.", + "replay": { "status": "none", "detail": "No claim replay or store mutation." }, + "schema": { "status": "none", "detail": "No protocol schema changes." }, + "rules": { "status": "changed", "detail": "Reserved fixture privileged socket overrides, symlink leaves and parent aliases are refused before identity, store and bind effects." }, + "compatibility": { "status": "none", "detail": "Ordinary instance privileged listener configuration is unchanged." }, + "downtime": { "status": "none", "detail": "Source publication does not configure or restart a service." }, + "manual": { "status": "changed", "detail": "Fixture privileged listeners must use the own instance root run/fractal.sock path." }, + "recovery": { "status": "none", "detail": "Refusal occurs before startup effects; no store recovery is needed." } +} From bf860c11e5a294c77c32b2b2591404792644fa3c Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:43:33 +0100 Subject: [PATCH 4/4] Pin fixture operations to its dedicated Fabric node 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-kernel/src/config.rs | 2 +- .../src/config/fixture_paths.rs | 71 +++++++- crates/fractal-kernel/src/fabric_gateway.rs | 30 ++- crates/fractal/src/main.rs | 17 +- crates/fractal/tests/action_coverage.rs | 23 ++- crates/smallclaims/src/fleet/transport.rs | 171 +++++++++++++++--- docs/st3/ios-fabric-protocol.md | 20 +- .../fixture-dedicated-fabric-node.json | 11 ++ 8 files changed, 297 insertions(+), 48 deletions(-) create mode 100644 release-notes/fixture-dedicated-fabric-node.json diff --git a/crates/fractal-kernel/src/config.rs b/crates/fractal-kernel/src/config.rs index 1a97f70d719..f173c4dab25 100644 --- a/crates/fractal-kernel/src/config.rs +++ b/crates/fractal-kernel/src/config.rs @@ -14,7 +14,7 @@ use crate::model::PlannerSpec; pub mod changes; mod client; mod fixture_paths; -pub use fixture_paths::FixturePathRefusal; +pub use fixture_paths::{FixturePathRefusal, fixture_fabric_context}; mod local_only; pub use local_only::{CLIENT_CONTROL_FIXTURE, LocalOnlyRefusal, NetworkOperation, NetworkPolicy}; diff --git a/crates/fractal-kernel/src/config/fixture_paths.rs b/crates/fractal-kernel/src/config/fixture_paths.rs index b9a66e78cdd..fe8c6e068db 100644 --- a/crates/fractal-kernel/src/config/fixture_paths.rs +++ b/crates/fractal-kernel/src/config/fixture_paths.rs @@ -88,6 +88,31 @@ fn exact_path(actual: &Path, expected: &Path, root: &Path, kind: &'static str) - Ok(()) } +fn fabric_context_at_root( + root: &Path, + home: &Path, + control: &Path, +) -> Result { + exact_path(home, &root.join("fabric"), root, "fabric_home")?; + exact_path(control, &root.join("fabric/run/control.sock"), root, "fabric_control_socket")?; + for name in ["identity.toml", "peers.toml", "config.toml"] { + exact_path(&home.join(name), &home.join(name), root, "fabric_home")?; + } + crate::fleet::transport::FabricExecutionContext::new(root.to_owned(), home.to_owned(), control.to_owned()) + .map_err(|_| refusal("fabric_home")) +} + +/// Pairing paths have only state_dir, so they share the same independent root authority. +pub fn fixture_fabric_context(state_dir: &Path) -> Result, FixturePathRefusal> { + if !super::NetworkPolicy::for_state_dir(state_dir).is_local_only() { + return Ok(None); + } + let root = fixture_root(state_dir).ok_or_else(|| refusal("state_dir"))?; + let state = fractal_client::product_dirs::resolve(root.join("state/st3"), root.join("state/fractal")); + exact_path(state_dir, &state, &root, "state_dir")?; + fabric_context_at_root(&root, &root.join("fabric"), &root.join("fabric/run/control.sock")).map(Some) +} + fn validate_at_root(config: &Config, root: &Path) -> Result<(), FixturePathRefusal> { let state = fractal_client::product_dirs::resolve(root.join("state/st3"), root.join("state/fractal")); exact_path(&config.state_dir, &state, root, "state_dir")?; @@ -103,7 +128,9 @@ fn validate_at_root(config: &Config, root: &Path) -> Result<(), FixturePathRefus let socket = root.join("run/fractal-client.sock"); exact_path(&config.client_gateway_socket, &socket, root, "client_gateway_socket")?; let bound = super::socket_binding_path(&socket); - exact_path(&bound, &bound, root, "client_gateway_socket") + exact_path(&bound, &bound, root, "client_gateway_socket")?; + fabric_context_at_root(root, &root.join("fabric"), &root.join("fabric/run/control.sock"))?; + Ok(()) } impl Config { @@ -115,6 +142,11 @@ impl Config { let root = fixture_root(&self.state_dir).ok_or_else(|| refusal("state_dir"))?; validate_at_root(self, &root) } + + pub fn fixture_fabric_context(&self) -> Result, FixturePathRefusal> { + self.validate_fixture_paths()?; + fixture_fabric_context(&self.state_dir) + } } #[cfg(test)] @@ -143,6 +175,43 @@ mod tests { validate_at_root(&explicit, &root).unwrap(); } + #[test] + fn dedicated_fabric_context_refuses_production_home_and_control() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".st-instance/client-control-fixture"); + let fabric_home = root.join("fabric"); + let control = fabric_home.join("run/control.sock"); + let context = fixture(&root).fixture_fabric_context().unwrap().unwrap(); + assert_eq!(context.home(), fabric_home); + assert_eq!(context.control_socket(), control); + let production = home.path().join(".local/share/fabric"); + assert_eq!(fabric_context_at_root(&root, &production, &production.join("run/control.sock")).unwrap_err().path, "fabric_home"); + assert_eq!(fabric_context_at_root(&root, &fabric_home, &production.join("run/control.sock")).unwrap_err().path, "fabric_control_socket"); + assert!(!root.exists()); + } + + #[test] + fn dedicated_fabric_context_rejects_home_control_and_identity_symlinks() { + use std::os::unix::fs::symlink; + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".st-instance/client-control-fixture"); + let fabric_home = root.join("fabric"); + let config = fixture(&root); + let production = home.path().join("production"); + fs::create_dir_all(&production).unwrap(); + fs::create_dir_all(&root).unwrap(); + symlink(&production, &fabric_home).unwrap(); + assert_eq!(config.fixture_fabric_context().unwrap_err().path, "fabric_home"); + fs::remove_file(&fabric_home).unwrap(); + fs::create_dir_all(fabric_home.join("run")).unwrap(); + let control = fabric_home.join("run/control.sock"); + symlink(production.join("control.sock"), &control).unwrap(); + assert_eq!(config.fixture_fabric_context().unwrap_err().path, "fabric_control_socket"); + fs::remove_file(&control).unwrap(); + symlink(production.join("identity.toml"), fabric_home.join("identity.toml")).unwrap(); + assert_eq!(config.fixture_fabric_context().unwrap_err().path, "fabric_home"); + } + #[test] fn ordinary_instances_keep_their_configurable_paths() { let home = tempfile::tempdir().unwrap(); diff --git a/crates/fractal-kernel/src/fabric_gateway.rs b/crates/fractal-kernel/src/fabric_gateway.rs index 2e3baec1080..ff67e874bc4 100644 --- a/crates/fractal-kernel/src/fabric_gateway.rs +++ b/crates/fractal-kernel/src/fabric_gateway.rs @@ -52,6 +52,14 @@ pub fn client_socket(config: &crate::config::Config) -> Result { Ok(socket) } +/// Fixture operations never consult the ambient or production Fabric daemon. +pub fn fabric_for_state_dir(program: PathBuf, state_dir: &Path) -> Result { + Ok(match crate::config::fixture_fabric_context(state_dir)? { + Some(context) => Fabric::with_context(program, context), + None => Fabric::new(program), + }) +} + /// `STATE/fabric-gateway.toml`, written by `st devices fabric enable`. Its presence is the switch. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] @@ -107,8 +115,8 @@ impl GatewayExposure { } } - fn fabric(&self) -> Fabric { - Fabric::new(self.fabric.clone()) + pub fn fabric(&self, state_dir: &Path) -> Result { + fabric_for_state_dir(self.fabric.clone(), state_dir) } } @@ -189,7 +197,10 @@ pub async fn grant(state_dir: &Path, node: &str) -> Grant { Ok(None) => return Grant::NotEnabled, Err(error) => return failed(error), }; - let fabric = exposure.fabric(); + let fabric = match exposure.fabric(state_dir) { + Ok(fabric) => fabric, + Err(error) => return failed(error), + }; let name = service_peer_name(&exposure.service, node); let peers = match fabric.peers().await { Ok(peers) => peers, @@ -229,9 +240,9 @@ pub async fn grant(state_dir: &Path, node: &str) -> Grant { pub async fn revoke(state_dir: &Path, node: &str) -> Result> { let exposure = GatewayExposure::load(state_dir)?; let fabric = match &exposure { - Some(exposure) => exposure.fabric(), + Some(exposure) => exposure.fabric(state_dir)?, None => match crate::fleet::transport::resolve_tool(None, "fabric") { - Some(program) => Fabric::new(program), + Some(program) => fabric_for_state_dir(program, state_dir)?, None => return Ok(None), }, }; @@ -330,8 +341,9 @@ mod tests { "add {PHONE} st-fixture-device-0123456789ab --inbound-only --allow {FIXTURE_SERVICE}\n" ))); assert_eq!(revoke(&fixture_state, OTHER).await.unwrap(), None); - assert!(granted_peers(&exposure.fabric(), FIXTURE_SERVICE).await.unwrap().is_empty()); - assert_eq!(granted_peers(&exposure.fabric(), DEFAULT_SERVICE).await.unwrap(), ["st-device-fedcba987654"]); + let fixture_fabric = exposure.fabric(&fixture_state).unwrap(); + assert!(granted_peers(&fixture_fabric, FIXTURE_SERVICE).await.unwrap().is_empty()); + assert_eq!(granted_peers(&fixture_fabric, DEFAULT_SERVICE).await.unwrap(), ["st-device-fedcba987654"]); assert_eq!(grant(&fixture_state, OTHER).await, Grant::AlreadyTrusted { peer: "st-device-fedcba987654".into(), }); @@ -340,7 +352,7 @@ mod tests { (PHONE, "st-fixture-device-0123456789ab"), (OTHER, "st-device-fedcba987654"), ]); - assert_eq!(granted_peers(&exposure.fabric(), DEFAULT_SERVICE).await.unwrap(), ["st-device-fedcba987654"]); + assert_eq!(granted_peers(&fixture_fabric, DEFAULT_SERVICE).await.unwrap(), ["st-device-fedcba987654"]); assert_eq!(revoke(&fixture_state, PHONE).await.unwrap().as_deref(), Some("st-fixture-device-0123456789ab")); assert!(calls(&log).ends_with("remove st-fixture-device-0123456789ab\n")); assert!(!calls(&log).contains("remove st-device-")); @@ -360,7 +372,7 @@ mod tests { fs::write( &program, format!( - "#!/bin/sh\necho \"$@\" >> {log}\ncase \"$1\" in peers) echo '{peers}';; esac\n", + "#!/bin/sh\nif [ \"$1\" = --home ]; then [ \"$2\" = \"$FABRIC_HOME\" ] || exit 1; shift 2; fi\necho \"$@\" >> {log}\ncase \"$1\" in peers) echo '{peers}';; esac\n", log = log.display() ), ) diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 6de49f39ab2..c99a02ce94a 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -9463,9 +9463,10 @@ async fn run_devices_fabric( command: DevicesFabricCommand, json_output: bool, ) -> Result<()> { - use fractal_daemon::fabric_gateway::{GatewayExposure, granted_peers}; - use fractal_daemon::fleet::transport::{Fabric, resolve_tool}; + use fractal_daemon::fabric_gateway::{GatewayExposure, fabric_for_state_dir, granted_peers}; + use fractal_daemon::fleet::transport::resolve_tool; let state_dir = &config.state_dir; + config.validate_fixture_paths()?; let exposure = GatewayExposure::load(state_dir)?; match command { DevicesFabricCommand::Enable { service } => { @@ -9478,7 +9479,7 @@ async fn run_devices_fabric( let program = resolve_tool(override_path.as_deref(), "fabric") .context("this machine cannot use Fabric: fabric is not installed")?; let program = std::fs::canonicalize(&program).unwrap_or(program); - let fabric = Fabric::new(program.clone()); + let fabric = fabric_for_state_dir(program.clone(), state_dir)?; if let Some(previous) = exposure.as_ref().filter(|previous| previous.service != service) { anyhow::bail!( "the client gateway is already exposed as {}; run fractal devices fabric disable first", @@ -9510,7 +9511,7 @@ async fn run_devices_fabric( println!("The client gateway is not exposed over Fabric."); return Ok(()); }; - let fabric = Fabric::new(exposure.fabric.clone()); + let fabric = exposure.fabric(state_dir)?; let mut removed = Vec::new(); for peer in granted_peers(&fabric, &exposure.service).await? { fabric.remove(&peer).await?; @@ -9519,7 +9520,11 @@ async fn run_devices_fabric( fabric .unexpose(&exposure.service) .await - .with_context(|| format!("removed {} device grant(s); run fabric unexpose {}", removed.len(), exposure.service))?; + .with_context(|| if config.local_only() { + format!("removed {} device grant(s); retry fractal --instance client-control-fixture devices fabric disable against its dedicated Fabric daemon only", removed.len()) + } else { + format!("removed {} device grant(s); run fabric unexpose {}", removed.len(), exposure.service) + })?; GatewayExposure::remove(state_dir)?; if json_output { println!("{}", json!({"service": exposure.service, "enabled": false, "removed": removed})); @@ -9532,7 +9537,7 @@ async fn run_devices_fabric( } DevicesFabricCommand::Status => { let peers = match &exposure { - Some(exposure) => granted_peers(&Fabric::new(exposure.fabric.clone()), &exposure.service).await?, + Some(exposure) => granted_peers(&exposure.fabric(state_dir)?, &exposure.service).await?, None => Vec::new(), }; if json_output { diff --git a/crates/fractal/tests/action_coverage.rs b/crates/fractal/tests/action_coverage.rs index 4c5a0885cd6..51036c53d2f 100644 --- a/crates/fractal/tests/action_coverage.rs +++ b/crates/fractal/tests/action_coverage.rs @@ -7318,10 +7318,11 @@ fn cli_fixture_fabric_exposes_only_paired_socket_and_preserves_normal_peers() { let bin = root.path().join("bin"); std::fs::create_dir_all(&bin).unwrap(); let calls = root.path().join("calls"); + let contexts = root.path().join("contexts"); let program = bin.join("fabric"); std::fs::write(&program, format!( - "#!/bin/sh\necho \"$@\" >> '{}'\ncase \"$1\" in peers) echo '{{\"peers\":[{{\"node\":\"{}\",\"name\":\"st-device-0123456789ab\",\"allow\":[\"st3-client-v0\"]}},{{\"node\":\"{}\",\"name\":\"st-fixture-device-fedcba987654\",\"allow\":[\"client-control-fixture\"]}}]}}';; esac\n", - calls.display(), "0123456789abcdef".repeat(4), "fedcba9876543210".repeat(4) + "#!/bin/sh\nprintf '%s|%s\\n' \"$FABRIC_HOME\" \"$*\" >> '{}'\n[ \"$1\" = --home ] || exit 1\n[ \"$2\" = \"$FABRIC_HOME\" ] || exit 1\nshift 2\necho \"$@\" >> '{}'\ncase \"$1\" in peers) echo '{{\"peers\":[{{\"node\":\"{}\",\"name\":\"st-device-0123456789ab\",\"allow\":[\"st3-client-v0\"]}},{{\"node\":\"{}\",\"name\":\"st-fixture-device-fedcba987654\",\"allow\":[\"client-control-fixture\"]}}]}}';; esac\n", + contexts.display(), calls.display(), "0123456789abcdef".repeat(4), "fedcba9876543210".repeat(4) )).unwrap(); std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); let config = root.path().join("config.toml"); @@ -7342,6 +7343,7 @@ fn cli_fixture_fabric_exposes_only_paired_socket_and_preserves_normal_peers() { let mut command = fractal_daemon::test_support::command(test_bin!("fractal-fixture")); command.env_remove("ST_AGENT").env_remove("ST_MISSION_RUN").env_remove("ST_INSTANCE") .env("HOME", root.path().join("home")) + .env("FABRIC_HOME", root.path().join("ambient-production-fabric")) .env("XDG_CONFIG_HOME", root.path().join("config")) .env("XDG_STATE_HOME", root.path().join("xdg-state")) .env("XDG_RUNTIME_DIR", root.path().join("run")).env("PATH", &bin); @@ -7364,9 +7366,26 @@ fn cli_fixture_fabric_exposes_only_paired_socket_and_preserves_normal_peers() { let recorded = std::fs::read_to_string(&calls).unwrap(); assert!(recorded.ends_with("remove st-fixture-device-fedcba987654\nunexpose client-control-fixture\n")); assert!(!recorded.contains("remove st-device-")); + let context_log = std::fs::read_to_string(&contexts).unwrap(); + let fabric_home = instance.root().join("fabric"); + let expected_prefix = format!("{}|--home {} ", fabric_home.display(), fabric_home.display()); + assert_eq!(context_log.lines().count(), 5); + assert!(context_log.lines().all(|line| line.starts_with(&expected_prefix))); write_config(&privileged); assert!(!run(true, &["enable"]).status.success()); assert_eq!(std::fs::read_to_string(&calls).unwrap(), recorded, "privileged sockets are never exposed"); + write_config(&paired); + let production = root.path().join("ambient-production-fabric"); + std::fs::create_dir(&production).unwrap(); + std::os::unix::fs::symlink(&production, &fabric_home).unwrap(); + let refused = run(true, &["enable"]); + assert!(!refused.status.success()); + assert!(String::from_utf8_lossy(&refused.stderr).contains("local-only-fixture-path-refused")); + std::fs::remove_file(&fabric_home).unwrap(); + std::fs::create_dir_all(fabric_home.join("run")).unwrap(); + std::os::unix::fs::symlink(production.join("control.sock"), fabric_home.join("run/control.sock")).unwrap(); + assert!(!run(true, &["enable"]).status.success()); + assert_eq!(std::fs::read_to_string(&contexts).unwrap(), context_log, "aliases are refused before Fabric runs"); } #[test] diff --git a/crates/smallclaims/src/fleet/transport.rs b/crates/smallclaims/src/fleet/transport.rs index 08367cc1738..98f3b27f900 100644 --- a/crates/smallclaims/src/fleet/transport.rs +++ b/crates/smallclaims/src/fleet/transport.rs @@ -245,16 +245,16 @@ struct Finished { stderr: String, } -async fn run_raw(program: &Path, arguments: &[&str]) -> Result { +async fn run_raw(program: &Path, arguments: &[&str], context: Option<&FabricExecutionContext>) -> Result { // Exec fails with "text file busy" while another process still holds the program open for // writing: Fabric replaced by `fabric update`, or a file a sibling fork has not yet let go // of. It clears within moments, so wait it out a few times instead of failing the call. let mut busy = 0; let output = loop { + let mut command = fabric_command(program, context)?; let attempt = tokio::time::timeout( COMMAND_TIMEOUT, - tokio::process::Command::new(program) - .args(arguments) + command.args(arguments) .stdin(std::process::Stdio::null()) .kill_on_drop(true) .output(), @@ -289,10 +289,10 @@ fn failed(program: &Path, arguments: &[&str], finished: Finished) -> anyhow::Err /// it rejected the option with a usage error (exit 2) and printed nothing on stdout, so the /// caller takes the text path in `legacy`. Every other failure is reported as a /// [`FabricError`] when Fabric printed one, and as an opaque command failure when it did not. -async fn run_json(program: &Path, arguments: &[&str]) -> Result> { +async fn run_json(program: &Path, arguments: &[&str], context: Option<&FabricExecutionContext>) -> Result> { let mut with_flag = arguments.to_vec(); with_flag.push("--json"); - let finished = run_raw(program, &with_flag).await?; + let finished = run_raw(program, &with_flag, context).await?; if finished.success { let value = serde_json::from_str(finished.stdout.trim()).with_context(|| { format!( @@ -312,8 +312,8 @@ async fn run_json(program: &Path, arguments: &[&str]) -> Result> { Err(failed(program, &with_flag, finished)) } -async fn run(program: &Path, arguments: &[&str]) -> Result { - let finished = run_raw(program, arguments).await?; +async fn run(program: &Path, arguments: &[&str], context: Option<&FabricExecutionContext>) -> Result { + let finished = run_raw(program, arguments, context).await?; if !finished.success { return Err(failed(program, arguments, finished)); } @@ -361,8 +361,8 @@ mod legacy { .map(|(_, code)| *code) } - pub(super) async fn run_dial(program: &Path, arguments: &[&str]) -> Result { - run(program, arguments).await.map_err(|error| { + pub(super) async fn run_dial(program: &Path, arguments: &[&str], context: Option<&FabricExecutionContext>) -> Result { + run(program, arguments, context).await.map_err(|error| { let Some(failure) = error.downcast_ref::() else { return error; }; @@ -382,7 +382,7 @@ mod legacy { /// The addresses `tailscale ip` reports. pub async fn tailscale_addresses(tailscale: &Path) -> Result> { - Ok(parse_tailscale_ips(&run(tailscale, &["ip"]).await?)) + Ok(parse_tailscale_ips(&run(tailscale, &["ip"], None).await?)) } pub const LEGACY_FLEET_PROTOCOL_PREFIX: &str = "st3/fleet/"; @@ -400,21 +400,82 @@ pub fn default_fabric_protocol(fleet_id: &str) -> String { format!("{LEGACY_FLEET_PROTOCOL_PREFIX}{fleet_id}") } +/// Immutable local daemon selection. Ordinary callers keep ambient defaults. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FabricExecutionContext { + root: PathBuf, + home: PathBuf, + control_socket: PathBuf, +} + +impl FabricExecutionContext { + pub fn new(root: PathBuf, home: PathBuf, control_socket: PathBuf) -> Result { + let context = Self { root, home, control_socket }; + context.validate()?; + Ok(context) + } + + pub fn home(&self) -> &Path { &self.home } + pub fn control_socket(&self) -> &Path { &self.control_socket } + + fn validate(&self) -> Result<()> { + anyhow::ensure!(self.root.is_absolute() && self.home.starts_with(&self.root) + && self.control_socket == self.home.join("run/control.sock"), + "Fabric execution context must select one absolute root, home and its own control socket"); + let parent = self.root.parent().context("Fabric root has no parent")?; + let mut prefix = parent.to_owned(); + for component in self.control_socket.strip_prefix(parent)?.components() { + anyhow::ensure!(matches!(component, std::path::Component::Normal(_)), + "Fabric execution context rejects path traversal"); + prefix.push(component); + match std::fs::symlink_metadata(&prefix) { + Ok(metadata) => anyhow::ensure!(!metadata.file_type().is_symlink(), "Fabric execution context rejects symlink aliases"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + for path in [parent.to_owned(), + self.home.join("identity.toml"), self.home.join("peers.toml"), self.home.join("config.toml")] { + match std::fs::symlink_metadata(&path) { + Ok(metadata) => anyhow::ensure!(!metadata.file_type().is_symlink(), "Fabric execution context rejects symlink aliases"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) + } +} + +fn fabric_command(program: &Path, context: Option<&FabricExecutionContext>) -> Result { + let mut command = tokio::process::Command::new(program); + if let Some(context) = context { + context.validate()?; + // Fabric 19e7f896 has global --home; control is derived as home/run/control.sock. + command.env("FABRIC_HOME", context.home()).arg("--home").arg(context.home()); + } + Ok(command) +} + /// The `fabric` command, which talks to the local Fabric daemon. #[derive(Clone, Debug)] pub struct Fabric { program: PathBuf, + context: Option, } impl Fabric { pub fn new(program: PathBuf) -> Self { - Self { program } + Self { program, context: None } + } + + pub fn with_context(program: PathBuf, context: FabricExecutionContext) -> Self { + Self { program, context: Some(context) } } /// Local endpoint state, queried without contacting any peer. Address changes or a /// restarted Fabric endpoint wake replication independently of failed-peer timers. pub async fn addresses(&self) -> Result> { - let value: Value = serde_json::from_str(&run(&self.program, &["addr"]).await?)?; + let value: Value = serde_json::from_str(&run(&self.program, &["addr"], self.context.as_ref()).await?)?; let addresses = value["addrs"] .as_array() .context("fabric addr needs addrs")?; @@ -424,7 +485,7 @@ impl Fabric { /// Fabric owns reconnecting this passive local event stream. Older builds lack the /// command; the worker then retains its address/suspend watcher and retry timers. pub fn peer_events(&self) -> Result { - Ok(tokio::process::Command::new(&self.program) + Ok(fabric_command(&self.program, self.context.as_ref())? .args(["peer-events", "--watch"]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) @@ -435,9 +496,9 @@ impl Fabric { /// This machine's Fabric NodeID. pub async fn id(&self) -> Result { - let id = match run_json(&self.program, &["id"]).await? { + let id = match run_json(&self.program, &["id"], self.context.as_ref()).await? { Some(value) => value["id"].as_str().unwrap_or_default().to_owned(), - None => run(&self.program, &["id"]).await?.trim().to_owned(), + None => run(&self.program, &["id"], self.context.as_ref()).await?.trim().to_owned(), }; anyhow::ensure!(!id.is_empty(), "fabric id printed nothing"); Ok(id) @@ -446,13 +507,13 @@ impl Fabric { /// Persist a loopback listener exposure so Fabric restores it after a restart. /// Repeating it is harmless; fleet leave and uninstall remove it explicitly. pub async fn expose(&self, protocol: &str, address: &str) -> Result<()> { - run(&self.program, &["expose", protocol, "--tcp", address]) + run(&self.program, &["expose", protocol, "--tcp", address], self.context.as_ref()) .await .map(|_| ()) } pub async fn unexpose(&self, protocol: &str) -> Result<()> { - run(&self.program, &["unexpose", protocol]) + run(&self.program, &["unexpose", protocol], self.context.as_ref()) .await .map(|_| ()) } @@ -463,7 +524,7 @@ impl Fabric { pub async fn expose_exec(&self, protocol: &str, argv: &[&str]) -> Result<()> { let mut arguments = vec!["expose", protocol, "--exec", "--"]; arguments.extend_from_slice(argv); - run(&self.program, &arguments).await.map(|_| ()) + run(&self.program, &arguments, self.context.as_ref()).await.map(|_| ()) } /// Expose the Unix socket at `socket` to trusted peers under `protocol`: Fabric opens one @@ -472,7 +533,7 @@ impl Fabric { /// any earlier exposure of `protocol`. pub async fn expose_socket(&self, protocol: &str, socket: &Path) -> Result<()> { let socket = socket.to_str().context("the socket path is not UTF-8")?; - run(&self.program, &["expose", protocol, "--socket", socket]) + run(&self.program, &["expose", protocol, "--socket", socket], self.context.as_ref()) .await .map(|_| ()) } @@ -484,6 +545,7 @@ impl Fabric { run( &self.program, &["add", node, name, "--inbound-only", "--allow", service], + self.context.as_ref(), ) .await .map(|_| ()) @@ -491,13 +553,13 @@ impl Fabric { /// Remove a trusted peer by NodeID or name. Fabric ends its admitted sessions. pub async fn remove(&self, peer: &str) -> Result<()> { - run(&self.program, &["remove", peer]).await.map(|_| ()) + run(&self.program, &["remove", peer], self.context.as_ref()).await.map(|_| ()) } /// The trusted peers in Fabric's `peers.toml`, as NodeID and local name. pub async fn peers(&self) -> Result> { - let Some(value) = run_json(&self.program, &["peers"]).await? else { - return Ok(legacy::parse_peers(&run(&self.program, &["peers"]).await?)); + let Some(value) = run_json(&self.program, &["peers"], self.context.as_ref()).await? else { + return Ok(legacy::parse_peers(&run(&self.program, &["peers"], self.context.as_ref()).await?)); }; let peers = value["peers"] .as_array() @@ -519,7 +581,7 @@ impl Fabric { /// that closes; the probe is what names it. `None` when the probe was answered by anything /// else (supported, a fault, a timeout, an unknown peer) or could not be run. pub async fn refusal(&self, node: &str, protocol: &str) -> Option { - let finished = run_raw(&self.program, &["probe", node, protocol, "--json"]) + let finished = run_raw(&self.program, &["probe", node, protocol, "--json"], self.context.as_ref()) .await .ok()?; let answer: Value = serde_json::from_str(finished.stdout.trim()).ok()?; @@ -541,12 +603,12 @@ impl Fabric { /// its path. Fabric reuses the socket while its listener lives. pub async fn dial_socket(&self, node: &str, protocol: &str) -> Result { let arguments = ["dial", node, protocol]; - let printed = match run_json(&self.program, &arguments).await? { + let printed = match run_json(&self.program, &arguments, self.context.as_ref()).await? { Some(value) => value["socket"] .as_str() .context("fabric dial printed no socket")? .to_owned(), - None => legacy::run_dial(&self.program, &arguments) + None => legacy::run_dial(&self.program, &arguments, self.context.as_ref()) .await? .trim() .to_owned(), @@ -562,12 +624,12 @@ impl Fabric { /// is a [`FabricError`]. pub async fn dial(&self, node: &str, protocol: &str) -> Result { let arguments = ["dial", node, protocol, "--tcp", "127.0.0.1:0"]; - let printed = match run_json(&self.program, &arguments).await { + let printed = match run_json(&self.program, &arguments, self.context.as_ref()).await { Ok(Some(value)) => Ok(value["address"] .as_str() .context("fabric dial printed no address")? .to_owned()), - Ok(None) => legacy::run_dial(&self.program, &arguments) + Ok(None) => legacy::run_dial(&self.program, &arguments, self.context.as_ref()) .await .map(|printed| printed.trim().to_owned()), Err(error) => Err(error), @@ -963,6 +1025,61 @@ mod tests { path } + #[tokio::test] + async fn isolated_context_pins_environment_and_all_command_paths() { + let root = tempfile::tempdir().unwrap(); + let boundary = root.path().join("instance"); + let home = boundary.join("fabric"); + let context = FabricExecutionContext::new(boundary, home.clone(), home.join("run/control.sock")).unwrap(); + let log = root.path().join("context-calls"); + let program = shim(root.path(), &format!( + "printf '%s|%s\\n' \"$FABRIC_HOME\" \"$*\" >> '{}'\n[ \"$1\" = --home ] || exit 1\n[ \"$2\" = \"$FABRIC_HOME\" ] || exit 1\nshift 2\ncase \"$1\" in peers) echo '{{\"peers\":[]}}';; esac", + log.display() + )); + let fabric = Fabric::with_context(program, context); + fabric.expose_socket("fixture", Path::new("/paired.sock")).await.unwrap(); + fabric.add_inbound_only("node", "test-peer", "fixture").await.unwrap(); + assert!(fabric.peers().await.unwrap().is_empty()); + fabric.remove("test-peer").await.unwrap(); + fabric.unexpose("fixture").await.unwrap(); + assert!(fabric.peer_events().unwrap().wait().await.unwrap().success()); + let prefix = format!("{}|--home {} ", home.display(), home.display()); + let calls = std::fs::read_to_string(log).unwrap(); + let commands: Vec<_> = calls.lines().map(|line| line.strip_prefix(&prefix).unwrap()).collect(); + assert_eq!(commands, [ + "expose fixture --socket /paired.sock", + "add node test-peer --inbound-only --allow fixture", + "peers --json", "remove test-peer", "unexpose fixture", "peer-events --watch", + ]); + } + + #[tokio::test] + async fn isolated_context_rejects_changed_home_and_control_aliases_before_spawn() { + let root = tempfile::tempdir().unwrap(); + let boundary = root.path().join("instance"); + let home = boundary.join("fabric"); + let context = FabricExecutionContext::new(boundary, home.clone(), home.join("run/control.sock")).unwrap(); + let log = root.path().join("must-not-run"); + let program = shim(root.path(), &format!("touch '{}'", log.display())); + let fabric = Fabric::with_context(program, context); + let outside = root.path().join("production-fabric"); + std::fs::create_dir(&outside).unwrap(); + std::fs::create_dir_all(home.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&outside, &home).unwrap(); + assert!(fabric.peers().await.is_err()); + assert!(fabric.peer_events().is_err()); + assert!(!log.exists()); + std::fs::remove_file(&home).unwrap(); + std::fs::create_dir_all(home.join("run")).unwrap(); + std::os::unix::fs::symlink(outside.join("control.sock"), home.join("run/control.sock")).unwrap(); + assert!(fabric.remove("node").await.is_err()); + assert!(!log.exists()); + std::fs::remove_file(home.join("run/control.sock")).unwrap(); + std::os::unix::fs::symlink(outside.join("identity.toml"), home.join("identity.toml")).unwrap(); + assert!(fabric.add_inbound_only("node", "test-peer", "fixture").await.is_err()); + assert!(!log.exists()); + } + #[tokio::test] async fn the_fabric_route_dials_through_the_cli_and_refuses_a_non_loopback_tunnel() { let root = tempfile::tempdir().unwrap(); diff --git a/docs/st3/ios-fabric-protocol.md b/docs/st3/ios-fabric-protocol.md index cb200ae33be..e173429a69b 100644 --- a/docs/st3/ios-fabric-protocol.md +++ b/docs/st3/ios-fabric-protocol.md @@ -134,11 +134,27 @@ It refuses production service names; other instances refuse the reserved fixture pairings use distinct `st-fixture-device-…` inbound-only peers with only that service. Status, revoke and disable scope cleanup to the instance's peer names, leaving normal `st-device-…` peers and `st3-client-v0` unchanged. Use fresh test-app Fabric identities: -an already-trusted normal identity is never re-granted to the fixture. Native Rust and -Node clients address it as `fabric://NODE_ID/client-control-fixture`; the iOS bridge +an already-trusted identity under another peer name is left untouched. Native Rust and +Node clients address the dedicated Fabric NodeID as `fabric://NODE_ID/client-control-fixture`; the iOS bridge already accepts an exact node/service target. Source support grants no setup, exposure, pairing or enrollment authority: those effects require their separate approved gates. +The fixture has a **dedicated Fabric node**, not another exposure on production's node. +Its immutable execution context derives `/fabric` and +`/fabric/run/control.sock`; exposure, grant, revoke and status always pin +both `FABRIC_HOME` and global `--home`. Other homes/control paths and symlink aliases +(including identity/peer/config files) fail before Fabric executes. Ordinary callers +retain their ambient Fabric defaults. Fabric `19e7f896` supports global `--home` +([CLI](https://github.com/compoundingtech/fabric/blob/19e7f896/src/main.rs#L37-L38)), +which wins over `FABRIC_HOME` and derives the control socket without an independent +control selector ([home](https://github.com/compoundingtech/fabric/blob/19e7f896/crates/fabric-config/src/home.rs#L20-L24), +[control](https://github.com/compoundingtech/fabric/blob/19e7f896/crates/fabric-config/src/home.rs#L162-L164)). +The separately approved packet must cover dedicated daemon start (`up --foreground`) +and stop (`down`) in its Fabric-mesh scope, using this same explicit home for both. +Record its actual `status --json` `node_id` separately from the Fractal daemon identity; +`id` is not read-only when identity files are missing. Do not use production daemon +restart, normal peers, or default-home exposure commands as fixture setup or rollback. + What this does not cover yet: - **The first pairing cannot use Fabric.** Until it is paired, the phone has no grant, diff --git a/release-notes/fixture-dedicated-fabric-node.json b/release-notes/fixture-dedicated-fabric-node.json new file mode 100644 index 00000000000..97ff195476b --- /dev/null +++ b/release-notes/fixture-dedicated-fabric-node.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Keep the client-control fixture on its own Fabric node, home and control socket rather than the ambient production Fabric daemon.", + "replay": {"status": "none", "detail": "No replay or projection changes."}, + "schema": {"status": "none", "detail": "No database or client wire schema changes."}, + "rules": {"status": "none", "detail": "No checkpoint or membership rule changes."}, + "compatibility": {"status": "changed", "detail": "Fixture exposure, grant, revoke and status derive the trusted instance root/fabric home and root/fabric/run/control.sock. Every command pins FABRIC_HOME and global --home, ignoring ambient daemon selection. Other homes, control sockets and symlink aliases are refused before Fabric executes. Ordinary Fabric callers keep existing defaults."}, + "downtime": {"status": "none", "detail": "No production daemon restart or exposure change is required."}, + "manual": {"status": "changed", "detail": "The approved fixture packet must start and stop a dedicated Fabric daemon under the fixture root/fabric home within its Fabric-mesh scope, and record the actual dedicated Fabric NodeID separately from the Fractal daemon identity. Source support grants no setup or execution authority."}, + "recovery": {"status": "changed", "detail": "Remove fixture peers and exposure through the same explicitly selected fixture home, then stop that dedicated daemon. Do not mutate or restart the production Fabric daemon."} +}