diff --git a/clients/typescript/fractal-client/README.md b/clients/typescript/fractal-client/README.md index b10a5205192..271b2a51888 100644 --- a/clients/typescript/fractal-client/README.md +++ b/clients/typescript/fractal-client/README.md @@ -90,6 +90,11 @@ 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 native TypeScript adapter recognizes canonical hex/base32 NodeID syntax and leaves Ed25519 key validity to Fabric. Unlike the Rust adapter's exact identity parser, it does not fall back to peer-name lookup for a syntactically valid but diff --git a/clients/typescript/fractal-client/fabric.mjs b/clients/typescript/fractal-client/fabric.mjs index f4dcd23d8be..562658da655 100644 --- a/clients/typescript/fractal-client/fabric.mjs +++ b/clients/typescript/fractal-client/fabric.mjs @@ -44,9 +44,10 @@ export class FabricTransportError extends Error { export class FabricEndpoint { #target; constructor(value) { - const match = /^fabric:\/\/([A-Za-z0-9_.-]+)\/st3-client-v0$/.exec(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); if (!match || match[1] === '.' || match[1] === '..') { - throw new FabricTransportError('invalid-endpoint', 'Expected fabric:///st3-client-v0'); + throw new FabricTransportError('invalid-endpoint', 'Expected fabric:///st3-client-v0 or fabric:///fractal-client-v0'); } this.#target = match[1]; } @@ -94,6 +95,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]); 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 956ffd4f343..0e99976f5eb 100644 --- a/clients/typescript/fractal-client/fabric.test.cjs +++ b/clients/typescript/fractal-client/fabric.test.cjs @@ -43,6 +43,15 @@ test('native Fabric parsing, roundtrip and pre-dial routing guards', async () => const endpoint = `fabric://${target}/st3-client-v0`; assert.equal(FabricEndpoint.parse(endpoint).toString(), endpoint); } + 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 suffix of ['/', '?x', '#fragment', '\n', '\r\n', '/extra']) { + assert.throws(() => FabricEndpoint.parse(`fabric://box/${service}${suffix}`)); + } + assert.throws(() => FabricEndpoint.parse(`prefixfabric://box/${service}`)); + } for (const endpoint of ['fabric:', 'fabric://box', 'fabric://box/other', 'fabric://user@box/st3-client-v0', 'fabric://box/st3-client-v0?token=x']) { assert.throws(() => FabricEndpoint.parse(endpoint)); } @@ -92,6 +101,22 @@ test('trusted names resolve case-insensitively and ambiguity/refusal fail closed } }); +test('both URI names dial the same exposed gateway service for names and NodeIDs', async () => { + const node = 'd75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a'; + const socket = path.join(root, 'gateway.sock'); + for (const service of ['st3-client-v0', 'fractal-client-v0']) { + for (const target of ['Box', node]) { + const gateway = fixture(`gateway-${service}-${target}`, [{ node, name: 'Box' }], socket); + assert.equal(await native.FabricEndpoint.parse(`fabric://${target}/${service}`).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, 'st3-client-v0', '--json'], + ]); + } + } +}); + 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 86555a6e73d..a534983bace 100644 --- a/crates/fractal-client/src/fabric.rs +++ b/crates/fractal-client/src/fabric.rs @@ -7,6 +7,7 @@ use smallclaims::fleet::transport::Fabric; use crate::ClientError; const PROTOCOL: &str = "st3-client-v0"; +const FRACTAL_PROTOCOL: &str = "fractal-client-v0"; const DIAL_TIMEOUT: Duration = Duration::from_secs(10); /// A peer's native client protocol, never a proxy URL or arbitrary Fabric protocol. @@ -25,10 +26,10 @@ impl fmt::Display for FabricEndpoint { impl FabricEndpoint { pub fn parse(value: &str) -> Result { - let invalid = || ClientError::Protocol(format!("invalid Fabric client endpoint; expected fabric:///{PROTOCOL}")); + let invalid = || ClientError::Protocol(format!("invalid Fabric client endpoint; expected fabric:///{PROTOCOL} or fabric:///{FRACTAL_PROTOCOL}")); let rest = value.strip_prefix("fabric://").ok_or_else(invalid)?; let (target, protocol) = rest.split_once('/').ok_or_else(invalid)?; - if protocol != PROTOCOL + if ![PROTOCOL, FRACTAL_PROTOCOL].contains(&protocol) || target.is_empty() || !target.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') @@ -118,6 +119,8 @@ 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. fabric .dial_socket(&node, PROTOCOL) .await @@ -160,6 +163,17 @@ mod tests { let url = format!("fabric://{target}/st3-client-v0"); assert_eq!(FabricEndpoint::parse(&url).unwrap().to_string(), url); } + for target in ["DEVBOX", "dev-box.local", NODE_ID] { + 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 suffix in ["/", "?x", "#fragment", "\n", "\r\n", "/extra"] { + let value = format!("fabric://box/{service}{suffix}"); + assert!(FabricEndpoint::parse(&value).is_err(), "{value:?}"); + } + assert!(FabricEndpoint::parse(&format!("prefixfabric://box/{service}")).is_err()); + } for value in ["fabric:box/st3-client-v0", "fabric:///st3-client-v0", "fabric://box/st3/pty/x", "fabric://box/st3-client-v0/", "fabric://box/st3-client-v0?x", "fabric://user@box/st3-client-v0", "fabric://box:1/st3-client-v0", "fabric://%62ox/st3-client-v0"] { assert!(FabricEndpoint::parse(value).is_err(), "{value}"); } @@ -207,6 +221,20 @@ mod tests { Fabric::new(program) } + #[tokio::test] + async fn both_uri_names_dial_the_exposed_gateway_service() { + let root = tempfile::tempdir().unwrap(); + let fabric = fake_fabric(root.path(), &format!( + "case \"$*\" in\n'peers --json') echo '{{\"peers\":[{{\"node\":\"{NODE_ID}\",\"name\":\"Box\"}}]}}';;\n'dial {NODE_ID} st3-client-v0 --json') echo '{{\"socket\":\"/native-tunnel.sock\"}}';;\n*) exit 1;;\nesac" + )); + for service in [PROTOCOL, FRACTAL_PROTOCOL] { + for target in ["Box", NODE_ID] { + let endpoint = FabricEndpoint::parse(&format!("fabric://{target}/{service}")).unwrap(); + assert_eq!(endpoint.dial_with(&fabric).await.unwrap(), PathBuf::from("/native-tunnel.sock")); + } + } + } + #[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 ed0ccaf91e7..5a8527db931 100644 --- a/crates/fractal-kernel/src/fabric_gateway.rs +++ b/crates/fractal-kernel/src/fabric_gateway.rs @@ -13,7 +13,8 @@ use serde_json::{Value, json}; use crate::fleet::transport::Fabric; -/// The service name `enable` uses when none is given. +/// 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"; const PEER_PREFIX: &str = "st-device-"; diff --git a/release-notes/fabric-client-uri-accepting-readers.json b/release-notes/fabric-client-uri-accepting-readers.json new file mode 100644 index 00000000000..901f7c85c1c --- /dev/null +++ b/release-notes/fabric-client-uri-accepting-readers.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Accept both Fabric native client URI service names in Rust and Node clients", + "replay": {"status": "none", "detail": "No claim history or projection changes."}, + "schema": {"status": "none", "detail": "No stored schema or gateway setting changes."}, + "rules": {"status": "none", "detail": "Trusted peer resolution, pairing authentication and client route restrictions are unchanged."}, + "compatibility": {"status": "changed", "detail": "Rust and Node native readers accept exactly st3-client-v0 and fractal-client-v0 URI services. Both dial the exposed st3-client-v0 gateway service. Formatters and gateway defaults still emit st3-client-v0; exposure is unchanged. Other services and URI suffixes remain refused, including trailing line breaks."}, + "downtime": {"status": "none", "detail": "No data migration is required."}, + "manual": {"status": "none", "detail": "No configuration edits or additional gateway exposures are required."}, + "recovery": {"status": "none", "detail": "No saved endpoint or gateway settings are rewritten. Older clients still require the st3-client-v0 URI spelling."} +}