diff --git a/core/Cargo.lock b/core/Cargo.lock index 6c8c14ad7..ca467ce70 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -1030,6 +1030,7 @@ dependencies = [ "solstone-core-journal", "solstone-core-sol-client", "solstone-core-sol-client-cli", + "solstone-core-sol-link", ] [[package]] @@ -1063,6 +1064,7 @@ dependencies = [ "spl-core", "spl-transport", "tokio", + "tokio-rustls", ] [[package]] diff --git a/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs b/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs index e8b17a761..27796ca15 100644 --- a/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs +++ b/core/crates/solstone-core-sol-client-cli/src/bin/resolve_parity_leaves.rs @@ -44,7 +44,7 @@ fn main() -> Result<(), String> { let lookup_args = match surface { "sol-chat" => vec!["chat".to_string()], "sol-import" => vec!["import".to_string()], - "sol-link" => vec!["link".to_string(), "join".to_string()], + "sol-link" => link_lookup_args(&argv), "sol-notify" => vec!["notify".to_string()], _ => argv, }; @@ -62,3 +62,11 @@ fn main() -> Result<(), String> { } Ok(()) } + +fn link_lookup_args(argv: &[String]) -> Vec { + match argv { + [command, verb, ..] if command == "link" => vec![String::from("link"), verb.clone()], + [verb, ..] => vec![String::from("link"), verb.clone()], + [] => vec![String::from("link")], + } +} diff --git a/core/crates/solstone-core-sol-client-cli/src/lib.rs b/core/crates/solstone-core-sol-client-cli/src/lib.rs index bb6422397..afbe28bf0 100644 --- a/core/crates/solstone-core-sol-client-cli/src/lib.rs +++ b/core/crates/solstone-core-sol-client-cli/src/lib.rs @@ -7,9 +7,10 @@ use std::path::Path; use solstone_core_sol_client::aggregate; use solstone_core_sol_client::command::{CommandContext, CommandOutput}; +use solstone_core_sol_client::resident::ResidentHandler; use solstone_core_sol_client::seam::{ BuildIdentityProvider, ChatEventSource, ClientItemIdProvider, Clock, FileProvider, - HttpTransport, LinkJoinPairingSeam, NotificationSink, + HttpTransport, LinkJoinPairingSeam, LinkServeRunner, NotificationSink, }; pub mod help; @@ -19,6 +20,7 @@ pub enum Outcome { Migrated { path: Vec }, Chat { args: Vec }, Import { args: Vec }, + Link { args: Vec }, Notify { args: Vec }, MovedStub { name: OsString }, Unsupported { args: Vec }, @@ -39,9 +41,18 @@ pub struct LinkDispatchSeams<'a> { pub clock: Option<&'a dyn Clock>, pub files: Option<&'a dyn FileProvider>, pub link_pairing: Option<&'a dyn LinkJoinPairingSeam>, + pub link_serve: Option<&'a dyn LinkServeRunner>, pub journal_root: Option<&'a Path>, } +pub enum LinkDispatch { + Buffered(CommandOutput), + Resident { + handler: ResidentHandler, + args: Vec, + }, +} + #[must_use] pub fn evaluate_args(args: &[OsString]) -> Outcome { match args { @@ -66,6 +77,20 @@ pub fn evaluate_args(args: &[OsString]) -> Outcome { }, ) } + [command, rest @ ..] if command == OsStr::new("link") => { + let path = [String::from("link"), String::from("serve")]; + if rest.first().is_some_and(|verb| verb == OsStr::new("serve")) + && match_generated_resident_surface_path("sol-link", &path).is_some() + { + Outcome::Link { + args: rest.to_vec(), + } + } else { + Outcome::Unsupported { + args: args.to_vec(), + } + } + } [command, rest @ ..] if command == OsStr::new("notify") => { match_generated_surface_path("sol-notify", &[String::from("notify")]).map_or_else( || Outcome::Unsupported { @@ -107,6 +132,7 @@ pub fn dispatch_sol_chat_with_seams( client_item_ids: seams.client_item_ids, notification_sink: None, link_pairing: None, + link_serve: None, journal_root: None, }) } @@ -136,6 +162,7 @@ pub fn dispatch_sol_import_with_seams( client_item_ids: seams.client_item_ids, notification_sink: None, link_pairing: None, + link_serve: None, journal_root: None, }) } @@ -165,6 +192,7 @@ pub fn dispatch_sol_notify_with_seams( client_item_ids: seams.client_item_ids, notification_sink: seams.notification_sink, link_pairing: None, + link_serve: None, journal_root: None, }) } @@ -176,18 +204,26 @@ pub fn dispatch_sol_link_with_seams( stdin: &str, today: &str, seams: LinkDispatchSeams<'_>, -) -> CommandOutput { - let Some((_, handler)) = - match_generated_surface_path("sol-link", &[String::from("link"), String::from("join")]) - else { - return CommandOutput::failure("Unsupported native sol command.\n", 64); +) -> LinkDispatch { + let Some((path, remaining)) = link_lookup_path(args) else { + return LinkDispatch::Buffered(CommandOutput::failure( + "Unsupported native sol command.\n", + 64, + )); }; - let remaining = match args { - [command, subcommand, rest @ ..] if command == "link" && subcommand == "join" => rest, - [subcommand, rest @ ..] if subcommand == "join" => rest, - rest => rest, + if let Some((_, handler)) = match_generated_resident_surface_path("sol-link", &path) { + return LinkDispatch::Resident { + handler, + args: remaining.to_vec(), + }; + } + let Some((_, handler)) = match_generated_surface_path("sol-link", &path) else { + return LinkDispatch::Buffered(CommandOutput::failure( + "Unsupported native sol command.\n", + 64, + )); }; - handler(CommandContext { + LinkDispatch::Buffered(handler(CommandContext { args: remaining, env, stdin, @@ -200,8 +236,9 @@ pub fn dispatch_sol_link_with_seams( client_item_ids: None, notification_sink: None, link_pairing: seams.link_pairing, + link_serve: seams.link_serve, journal_root: seams.journal_root, - }) + })) } fn evaluate_call(args: &[OsString]) -> Outcome { @@ -273,6 +310,7 @@ pub fn dispatch_sol_call_with_seams( client_item_ids: seams.client_item_ids, notification_sink: None, link_pairing: None, + link_serve: None, journal_root: None, }) } @@ -290,7 +328,11 @@ pub fn resolve_surface_leaf( if surface == "sol-call" { return resolve_sol_call_leaf(args); } - match_generated_surface_path(surface, args).map(|(entry, _handler)| entry) + match_generated_surface_path(surface, args) + .map(|(entry, _handler)| entry) + .or_else(|| { + match_generated_resident_surface_path(surface, args).map(|(entry, _handler)| entry) + }) } fn match_generated_path(args: &[OsString]) -> Option<(&'static aggregate::InventoryEntry, usize)> { @@ -339,14 +381,45 @@ fn match_generated_surface_path( }) } +fn match_generated_resident_surface_path( + surface: &str, + args: &[String], +) -> Option<(&'static aggregate::InventoryEntry, ResidentHandler)> { + let path = args.iter().map(String::as_str).collect::>(); + aggregate::resident_handler_for(&path).and_then(|(entry, handler)| { + if entry.surface == surface { + Some((entry, handler)) + } else { + None + } + }) +} + +fn link_lookup_path(args: &[String]) -> Option<(Vec, &[String])> { + match args { + [command, verb, rest @ ..] if command == "link" => { + Some((vec![String::from("link"), verb.clone()], rest)) + } + [verb, rest @ ..] => Some((vec![String::from("link"), verb.clone()], rest)), + [] => None, + } +} + #[cfg(test)] mod tests { use super::*; + use solstone_core_sol_client::seam::{ + ScriptedHttpTransport, ScriptedLinkJoinPairingSeam, ScriptedLinkServeRunner, + }; fn args(values: &[&str]) -> Vec { values.iter().map(OsString::from).collect() } + fn string_args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + #[test] fn routes_named_builtins_to_generated_authority() { assert_eq!( @@ -397,6 +470,47 @@ mod tests { ); } + #[test] + fn sol_link_dispatch_resolves_join_and_serve_from_full_or_trimmed_argv() { + let env = BTreeMap::new(); + let transport = ScriptedHttpTransport::new(vec![]); + let pairing = ScriptedLinkJoinPairingSeam::new(vec![]); + let serve = ScriptedLinkServeRunner::new(vec![]); + let seams = || LinkDispatchSeams { + transport: &transport, + clock: None, + files: None, + link_pairing: Some(&pairing), + link_serve: Some(&serve), + journal_root: None, + }; + + let join_args = string_args(&["link", "join"]); + match dispatch_sol_link_with_seams(&join_args, &env, "", "20260726", seams()) { + LinkDispatch::Buffered(output) => { + assert_eq!(output.exit, 2); + assert!( + output + .stderr + .contains("the following arguments are required: --code") + ); + } + LinkDispatch::Resident { .. } => panic!("link join must stay buffered"), + } + + let full_serve_args = string_args(&["link", "serve", "--help"]); + match dispatch_sol_link_with_seams(&full_serve_args, &env, "", "20260726", seams()) { + LinkDispatch::Resident { args, .. } => assert_eq!(args, string_args(&["--help"])), + LinkDispatch::Buffered(_) => panic!("link serve must resolve as resident"), + } + + let trimmed_serve_args = string_args(&["serve", "--help"]); + match dispatch_sol_link_with_seams(&trimmed_serve_args, &env, "", "20260726", seams()) { + LinkDispatch::Resident { args, .. } => assert_eq!(args, string_args(&["--help"])), + LinkDispatch::Buffered(_) => panic!("trimmed link serve must resolve as resident"), + } + } + #[test] fn classifies_unported_call_as_unsupported_without_spawn_path() { assert_eq!( diff --git a/core/crates/solstone-core-sol-client-cli/tests/parity.rs b/core/crates/solstone-core-sol-client-cli/tests/parity.rs index 4c9ada840..4056748bd 100644 --- a/core/crates/solstone-core-sol-client-cli/tests/parity.rs +++ b/core/crates/solstone-core-sol-client-cli/tests/parity.rs @@ -5,11 +5,12 @@ use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use serde_json::{Value, json}; +use solstone_core_sol_client::command::CommandContext; use solstone_core_sol_client::error::ClientError; use solstone_core_sol_client::seam::{ ChatInput, ExpectedHttpCall, FakeBuildIdentityProvider, FakeClientItemIdProvider, FakeClock, FixtureFileProvider, RecordedHttpCall, RecordingNotificationSink, ScriptedChatEventSource, - ScriptedHttpTransport, ScriptedLinkJoinPairingSeam, + ScriptedHttpTransport, ScriptedLinkJoinPairingSeam, ScriptedLinkServeRunner, }; use solstone_core_sol_client::sse::iter_sse_events; use solstone_core_sol_client::transport::{ @@ -17,8 +18,9 @@ use solstone_core_sol_client::transport::{ TimeoutPolicy, UploadRequest, }; use solstone_core_sol_client_cli::{ - DispatchSeams, LinkDispatchSeams, dispatch_sol_call_with_seams, dispatch_sol_chat_with_seams, - dispatch_sol_import_with_seams, dispatch_sol_link_with_seams, dispatch_sol_notify_with_seams, + DispatchSeams, LinkDispatch, LinkDispatchSeams, dispatch_sol_call_with_seams, + dispatch_sol_chat_with_seams, dispatch_sol_import_with_seams, dispatch_sol_link_with_seams, + dispatch_sol_notify_with_seams, }; const ACTIVITIES_VECTORS: &str = @@ -39,6 +41,8 @@ const IMPORT_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/i const LEDGER_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/ledger.jsonl"); const LINK_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/link.jsonl"); const LINK_JOIN_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/link_join.jsonl"); +const LINK_SERVE_VECTORS: &str = + include_str!("../../../fixtures/native-sol/parity/link_serve.jsonl"); const MOVED_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/moved.jsonl"); const NOTIFY_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/notify.jsonl"); const PROFILE_VECTORS: &str = include_str!("../../../fixtures/native-sol/parity/profile.jsonl"); @@ -70,6 +74,7 @@ fn native_matches_sol_call_parity_vectors() { .chain(load_vectors(LEDGER_VECTORS)) .chain(load_vectors(LINK_VECTORS)) .chain(load_vectors(LINK_JOIN_VECTORS)) + .chain(load_vectors(LINK_SERVE_VECTORS)) .chain(load_vectors(MOVED_VECTORS)) .chain(load_vectors(NOTIFY_VECTORS)) .chain(load_vectors(PROFILE_VECTORS)) @@ -120,6 +125,7 @@ fn run_vector(vector: &Value) { RecordingNotificationSink::new() }; let link_pairing = ScriptedLinkJoinPairingSeam::new(vec![]); + let link_serve = ScriptedLinkServeRunner::new(vec![]); let output = if vector["surface"].as_str() == Some("sol-chat") { dispatch_sol_chat_with_seams( @@ -171,7 +177,7 @@ fn run_vector(vector: &Value) { }, ) } else if vector["surface"].as_str() == Some("sol-link") { - dispatch_sol_link_with_seams( + let dispatch = dispatch_sol_link_with_seams( &argv, &env, stdin, @@ -181,9 +187,35 @@ fn run_vector(vector: &Value) { clock: Some(&clock), files: Some(&files), link_pairing: Some(&link_pairing), + link_serve: Some(&link_serve), journal_root: Some(std::path::Path::new("/native-sol-parity-journal")), }, - ) + ); + match dispatch { + LinkDispatch::Buffered(output) => output, + LinkDispatch::Resident { handler, args } => { + let output = handler(CommandContext { + args: &args, + env: &env, + stdin, + today, + transport: &transport, + clock: Some(&clock), + chat_events: None, + files: Some(&files), + build_identity: None, + client_item_ids: None, + notification_sink: None, + link_pairing: Some(&link_pairing), + link_serve: Some(&link_serve), + journal_root: Some(std::path::Path::new("/native-sol-parity-journal")), + }); + match output { + Err(output) => output, + Ok(_) => panic!("resident parity vector entered the serve loop"), + } + } + } } else { dispatch_sol_call_with_seams( &argv, @@ -203,6 +235,7 @@ fn run_vector(vector: &Value) { }; transport.assert_done(); link_pairing.assert_done(); + link_serve.assert_done(); let mut actual = json!({ "stdout": output.stdout, "stderr": output.stderr, diff --git a/core/crates/solstone-core-sol-client/src/aggregate.rs b/core/crates/solstone-core-sol-client/src/aggregate.rs index ced5d0e1a..19f86163d 100644 --- a/core/crates/solstone-core-sol-client/src/aggregate.rs +++ b/core/crates/solstone-core-sol-client/src/aggregate.rs @@ -3,6 +3,7 @@ use crate::command::{CommandContext, CommandOutput}; use crate::generated::inventory; +use crate::resident::ResidentHandler; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct InventoryEntry { @@ -18,6 +19,7 @@ pub struct InventoryEntry { pub route: Option<&'static str>, pub contract_operation_id: Option<&'static str>, pub handler: &'static str, + pub resident: bool, } pub type Handler = for<'a> fn(CommandContext<'a>) -> CommandOutput; @@ -32,10 +34,45 @@ pub fn handler_bindings() -> &'static [Handler] { inventory::HANDLERS } +#[must_use] +pub fn resident_handler_bindings() -> &'static [ResidentHandler] { + inventory::RESIDENT_HANDLERS +} + #[must_use] pub fn handler_for(path: &[&str]) -> Option<(&'static InventoryEntry, Handler)> { - inventory::ENTRIES - .iter() - .zip(inventory::HANDLERS.iter().copied()) - .find(|(entry, _handler)| entry.path == path) + let mut handler_index = 0; + for entry in inventory::ENTRIES { + if entry.resident { + continue; + } + let handler = inventory::HANDLERS + .get(handler_index) + .copied() + .expect("generated buffered handler table must match inventory"); + handler_index += 1; + if entry.path == path { + return Some((entry, handler)); + } + } + None +} + +#[must_use] +pub fn resident_handler_for(path: &[&str]) -> Option<(&'static InventoryEntry, ResidentHandler)> { + let mut handler_index = 0; + for entry in inventory::ENTRIES { + if !entry.resident { + continue; + } + let handler = inventory::RESIDENT_HANDLERS + .get(handler_index) + .copied() + .expect("generated resident handler table must match inventory"); + handler_index += 1; + if entry.path == path { + return Some((entry, handler)); + } + } + None } diff --git a/core/crates/solstone-core-sol-client/src/command.rs b/core/crates/solstone-core-sol-client/src/command.rs index b74a1cf2b..e3d10ea49 100644 --- a/core/crates/solstone-core-sol-client/src/command.rs +++ b/core/crates/solstone-core-sol-client/src/command.rs @@ -6,7 +6,7 @@ use std::path::Path; use crate::seam::{ BuildIdentityProvider, ChatEventSource, ClientItemIdProvider, Clock, FileProvider, - HttpTransport, LinkJoinPairingSeam, NotificationSink, + HttpTransport, LinkJoinPairingSeam, LinkServeRunner, NotificationSink, }; #[derive(Clone, Copy)] @@ -23,6 +23,7 @@ pub struct CommandContext<'a> { pub client_item_ids: Option<&'a dyn ClientItemIdProvider>, pub notification_sink: Option<&'a dyn NotificationSink>, pub link_pairing: Option<&'a dyn LinkJoinPairingSeam>, + pub link_serve: Option<&'a dyn LinkServeRunner>, pub journal_root: Option<&'a Path>, } diff --git a/core/crates/solstone-core-sol-client/src/generated/inventory.rs b/core/crates/solstone-core-sol-client/src/generated/inventory.rs index a3f3be0a4..779824df3 100644 --- a/core/crates/solstone-core-sol-client/src/generated/inventory.rs +++ b/core/crates/solstone-core-sol-client/src/generated/inventory.rs @@ -2,6 +2,7 @@ // Copyright (c) 2026 sol pbc use crate::aggregate::{Handler, InventoryEntry}; +use crate::resident::ResidentHandler; #[path = "../../../../../solstone/apps/activities/native/command.rs"] mod solstone_apps_activities_native_command_rs; @@ -62,6 +63,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/activities/api/day/{day}/records"), contract_operation_id: Some("activities.list"), handler: "list", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -76,6 +78,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/activities/api/day/{day}/record/{span_id}"), contract_operation_id: Some("activities.get"), handler: "get", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -90,6 +93,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/activities/api/day/{day}/records"), contract_operation_id: Some("activities.create"), handler: "create", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -104,6 +108,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/activities/api/day/{day}/record/{span_id}/update"), contract_operation_id: Some("activities.update"), handler: "update", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -118,6 +123,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/activities/api/day/{day}/record/{span_id}/mute"), contract_operation_id: Some("activities.mute"), handler: "mute", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -132,6 +138,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/activities/api/day/{day}/record/{span_id}/unmute"), contract_operation_id: Some("activities.unmute"), handler: "unmute", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -146,6 +153,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/awareness/api/state"), contract_operation_id: Some("awareness.status"), handler: "status", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -160,6 +168,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/awareness/api/imports"), contract_operation_id: Some("awareness.imports"), handler: "imports", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -174,6 +183,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/awareness/api/log"), contract_operation_id: Some("awareness.log"), handler: "log", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -188,6 +198,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/awareness/api/log"), contract_operation_id: Some("awareness.log-read"), handler: "log_read", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -202,6 +213,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/body/api/status"), contract_operation_id: Some("body.status"), handler: "status", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -216,6 +228,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/body/api/day/{day}"), contract_operation_id: Some("body.day"), handler: "day", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -230,6 +243,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/body/api/window"), contract_operation_id: Some("body.window"), handler: "window", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -244,6 +258,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/chat/start"), contract_operation_id: Some("chat.start"), handler: "start", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -258,6 +273,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/accept-merge-candidate"), contract_operation_id: Some("entities.accept-merge-candidate"), handler: "accept_merge_candidate", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -272,6 +288,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/{facet_name}/aka"), contract_operation_id: Some("entities.aka"), handler: "aka", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -286,6 +303,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/ambiguities"), contract_operation_id: Some("entities.ambiguities"), handler: "ambiguities", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -300,6 +318,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/{facet_name}/attach"), contract_operation_id: Some("entities.attach"), handler: "attach", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -314,6 +333,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/{facet_name}/detected"), contract_operation_id: Some("entities.detect"), handler: "detect", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -328,6 +348,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/dismiss-merge-candidate"), contract_operation_id: Some("entities.dismiss-merge-candidate"), handler: "dismiss_merge_candidate", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -342,6 +363,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/journal/entity/{entity_id}/history"), contract_operation_id: Some("entities.entity-history"), handler: "entity_history", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -356,6 +378,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/history"), contract_operation_id: Some("entities.history"), handler: "history", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -370,6 +393,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/{facet_name}"), contract_operation_id: Some("entities.list"), handler: "list", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -384,6 +408,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/merge"), contract_operation_id: Some("entities.merge"), handler: "merge", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -398,6 +423,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/merge-candidates"), contract_operation_id: Some("entities.merge-candidates"), handler: "merge_candidates", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -412,6 +438,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/move"), contract_operation_id: Some("entities.move"), handler: "move_entity", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -426,6 +453,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/network"), contract_operation_id: Some("entities.network"), handler: "network", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -440,6 +468,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/{facet_name}/observations"), contract_operation_id: Some("entities.observations"), handler: "observations", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -454,6 +483,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/{facet_name}/observe"), contract_operation_id: Some("entities.observe"), handler: "observe", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -468,6 +498,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/overview"), contract_operation_id: Some("entities.overview"), handler: "overview", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -482,6 +513,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/record-merge-candidate"), contract_operation_id: Some("entities.record-merge-candidate"), handler: "record_merge_candidate", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -496,6 +528,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/ambiguities/{ambiguity_id}/resolve"), contract_operation_id: Some("entities.resolve-ambiguity"), handler: "resolve_ambiguity", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -510,6 +543,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/journal/entity/{entity_id}/restore"), contract_operation_id: Some("entities.restore-version"), handler: "restore_version", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -524,6 +558,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/search"), contract_operation_id: Some("entities.search"), handler: "search", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -538,6 +573,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/merge/{merge_id}/undo"), contract_operation_id: Some("entities.undo-merge"), handler: "undo_merge", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -552,6 +588,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/entities/api/{facet_name}/update-description"), contract_operation_id: Some("entities.update"), handler: "update", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -566,6 +603,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/curation/api/facet/candidates"), contract_operation_id: Some("facets.list-candidates"), handler: "list_candidates", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -580,6 +618,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/curation/api/facet/accept"), contract_operation_id: Some("facets.accept"), handler: "accept", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -594,6 +633,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/curation/api/facet/dismiss"), contract_operation_id: Some("facets.dismiss"), handler: "dismiss", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -608,6 +648,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/import/api/journal-sources/{name}/staged"), contract_operation_id: Some("import.list-staged"), handler: "list_staged", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -622,6 +663,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/import/api/journal-sources/{name}/resolve-config"), contract_operation_id: Some("import.resolve-config"), handler: "resolve_config", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -636,6 +678,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/import/api/journal-sources/{name}/resolve-config-all"), contract_operation_id: Some("import.resolve-config-all"), handler: "resolve_config_all", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -650,6 +693,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/import/api/journal-sources/{name}/resolve-entity"), contract_operation_id: Some("import.resolve-entity"), handler: "resolve_entity", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -664,6 +708,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/import/api/journal-sources/{name}/resolve-facet"), contract_operation_id: Some("import.resolve-staged-facet"), handler: "resolve_staged_facet", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -678,6 +723,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/network/api/devices"), contract_operation_id: Some("link.devices"), handler: "authorized_clients", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -692,6 +738,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/network/api/devices"), contract_operation_id: Some("link.devices"), handler: "list", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -706,6 +753,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: None, contract_operation_id: None, handler: "observer_pause", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -720,6 +768,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/network/pair-start"), contract_operation_id: Some("link.pairStart"), handler: "pair", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -734,6 +783,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/network/private-link/disable"), contract_operation_id: Some("link.private-link.disable"), handler: "private_link_disable", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -748,6 +798,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/network/private-link/enable"), contract_operation_id: Some("link.private-link.setup"), handler: "private_link_setup", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -762,6 +813,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/network/api/private-link"), contract_operation_id: Some("link.private-link.status"), handler: "private_link_status", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -776,6 +828,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/network/api/status"), contract_operation_id: Some("link.status"), handler: "status", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -790,6 +843,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/network/unpair"), contract_operation_id: Some("link.unpair"), handler: "unpair", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -804,6 +858,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/convey/status"), contract_operation_id: Some("settings.convey.status"), handler: "convey_status", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -818,6 +873,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/config"), contract_operation_id: Some("settings.config.update"), handler: "identity_set", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -832,6 +888,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/config"), contract_operation_id: Some("settings.config.get"), handler: "identity_show", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -846,6 +903,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/config"), contract_operation_id: Some("settings.config.update"), handler: "keys_clear", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -860,6 +918,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/config"), contract_operation_id: Some("settings.config.update"), handler: "keys_set", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -874,6 +933,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/config"), contract_operation_id: Some("settings.config.get"), handler: "keys_show", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -888,6 +948,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/validate-keys"), contract_operation_id: Some("settings.keys.validate"), handler: "keys_validate", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -902,6 +963,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/observe"), contract_operation_id: Some("settings.observe.update"), handler: "observer_set", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -916,6 +978,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/observe"), contract_operation_id: Some("settings.observe.get"), handler: "observer_show", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -930,6 +993,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/config"), contract_operation_id: Some("settings.config.update"), handler: "processing_set", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -944,6 +1008,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/processing"), contract_operation_id: Some("settings.processing.get"), handler: "processing_show", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -958,6 +1023,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/config"), contract_operation_id: Some("settings.config.get"), handler: "show", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -972,6 +1038,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/config"), contract_operation_id: Some("settings.config.update"), handler: "transcribe_set_backend", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -986,6 +1053,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/settings/api/transcribe"), contract_operation_id: Some("settings.transcribe.get"), handler: "transcribe_show", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1000,6 +1068,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/sol/api/set-name"), contract_operation_id: Some("sol.set-name"), handler: "set_name", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1014,6 +1083,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/sol/api/reset"), contract_operation_id: Some("sol.reset"), handler: "reset", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1028,6 +1098,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/sol/api/set-owner"), contract_operation_id: Some("sol.set-owner"), handler: "set_owner", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1042,6 +1113,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/sol/api/sol-init"), contract_operation_id: Some("sol.sol-init"), handler: "sol_init", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1056,6 +1128,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/attribute-segment"), contract_operation_id: Some("speakers.attribute-segment"), handler: "attribute_segment", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1070,6 +1143,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/backfill"), contract_operation_id: Some("speakers.backfill"), handler: "backfill", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1084,6 +1158,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/backfill-last-seen"), contract_operation_id: Some("speakers.backfill-last-seen"), handler: "backfill_last_seen", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1098,6 +1173,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/bootstrap"), contract_operation_id: Some("speakers.bootstrap"), handler: "bootstrap", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1112,6 +1188,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/owner/build-from-tags"), contract_operation_id: Some("speakers.build-from-tags"), handler: "build_from_tags", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1126,6 +1203,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/owner/confirm-cli"), contract_operation_id: Some("speakers.confirm-owner"), handler: "confirm_owner", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1140,6 +1218,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/correct-attribution"), contract_operation_id: Some("speakers.correct"), handler: "correct", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1154,6 +1233,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/segments-cli/{day}"), contract_operation_id: Some("speakers.day-segments"), handler: "day_segments", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1168,6 +1248,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/owner/detect"), contract_operation_id: Some("speakers.detect"), handler: "detect", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1182,6 +1263,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/discovery/scan"), contract_operation_id: Some("speakers.discover"), handler: "discover", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1196,6 +1278,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/discovery/dismiss"), contract_operation_id: Some("speakers.dismiss-cluster"), handler: "dismiss_cluster", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1210,6 +1293,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/discovery/dismissals"), contract_operation_id: Some("speakers.dismissals"), handler: "dismissals", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1224,6 +1308,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/discovery/identify-cli"), contract_operation_id: Some("speakers.identify"), handler: "identify", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1238,6 +1323,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/discovery/identify/operations/{operation_id}"), contract_operation_id: Some("speakers.identify-operation"), handler: "identify_operation", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1252,6 +1338,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/discovery/identify/operations"), contract_operation_id: Some("speakers.identify-operations"), handler: "identify_operations", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1266,6 +1353,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/discovery/identify/undo"), contract_operation_id: Some("speakers.identify-undo"), handler: "identify_undo", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1280,6 +1368,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/name-variants/keep-separate"), contract_operation_id: Some("speakers.keep-separate-list"), handler: "keep_separate_list", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1294,6 +1383,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/link-import"), contract_operation_id: Some("speakers.link-import"), handler: "link_import", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1308,6 +1398,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/merge-names"), contract_operation_id: Some("speakers.merge-names"), handler: "merge_names", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1322,6 +1413,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/owner/ready"), contract_operation_id: Some("speakers.owner-ready"), handler: "owner_ready", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1336,6 +1428,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/discovery/cluster/{cluster_id}/presence"), contract_operation_id: Some("speakers.presence"), handler: "presence", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1350,6 +1443,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/propagate-correction"), contract_operation_id: Some("speakers.propagate-correction"), handler: "propagate_correction", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1364,6 +1458,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/owner/rebuild"), contract_operation_id: Some("speakers.rebuild-owner"), handler: "rebuild_owner", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1378,6 +1473,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/owner/reject-cli"), contract_operation_id: Some("speakers.reject-owner"), handler: "reject_owner", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1392,6 +1488,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/resolve-names"), contract_operation_id: Some("speakers.resolve-names"), handler: "resolve_names", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1406,6 +1503,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/seed-from-imports"), contract_operation_id: Some("speakers.seed-from-imports"), handler: "seed_from_imports", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1420,6 +1518,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/review-cli/{day}/{stream}/{segment_key}/{source}"), contract_operation_id: Some("speakers.sentences"), handler: "sentences", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1434,6 +1533,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/status"), contract_operation_id: Some("speakers.status"), handler: "status", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1448,6 +1548,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/suggest"), contract_operation_id: Some("speakers.suggest"), handler: "suggest", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1462,6 +1563,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/owner/tag-cli"), contract_operation_id: Some("speakers.tag-owner"), handler: "tag_owner", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1476,6 +1578,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/speakers/api/wipe"), contract_operation_id: Some("speakers.wipe"), handler: "wipe", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1490,6 +1593,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/support/api/register"), contract_operation_id: Some("support.register"), handler: "register", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1504,6 +1608,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/support/api/articles"), contract_operation_id: Some("support.search"), handler: "search", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1518,6 +1623,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/support/api/articles/{slug}"), contract_operation_id: Some("support.article"), handler: "article", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1532,6 +1638,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/support/api/tickets"), contract_operation_id: Some("support.create"), handler: "create", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1546,6 +1653,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/support/api/tickets"), contract_operation_id: Some("support.list"), handler: "list", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1560,6 +1668,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/support/api/tickets/{ticket_id}"), contract_operation_id: Some("support.show"), handler: "show", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1574,6 +1683,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/support/api/tickets/{ticket_id}/reply"), contract_operation_id: Some("support.reply"), handler: "reply", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1588,6 +1698,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/support/api/tickets/{ticket_id}/attachments"), contract_operation_id: Some("support.attach"), handler: "attach", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1602,6 +1713,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/support/api/feedback"), contract_operation_id: Some("support.feedback"), handler: "feedback", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1616,6 +1728,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/support/api/announcements"), contract_operation_id: Some("support.announcements"), handler: "announcements", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1630,6 +1743,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/support/api/diagnostics"), contract_operation_id: Some("support.diagnose"), handler: "diagnose", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1644,6 +1758,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/local/endpoint"), contract_operation_id: Some("thinking.local.endpoint.delete"), handler: "clear_local_endpoint", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1658,6 +1773,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/confidential/disable"), contract_operation_id: Some("thinking.confidential.disable"), handler: "confidential_disable", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1672,6 +1788,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/confidential/enable"), contract_operation_id: Some("thinking.confidential.enable"), handler: "confidential_enable", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1686,6 +1803,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/confidential/recheck"), contract_operation_id: Some("thinking.confidential.recheck"), handler: "confidential_recheck", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1700,6 +1818,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/providers"), contract_operation_id: Some("thinking.providers.get"), handler: "confidential_status", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1714,6 +1833,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/keys"), contract_operation_id: Some("thinking.keys.update"), handler: "keys_clear", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1728,6 +1848,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/keys"), contract_operation_id: Some("thinking.keys.update"), handler: "keys_set", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1742,6 +1863,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/keys"), contract_operation_id: Some("thinking.keys.get"), handler: "keys_show", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1756,6 +1878,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/validate-keys"), contract_operation_id: Some("thinking.keys.validate"), handler: "keys_validate", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1770,6 +1893,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/local/availability"), contract_operation_id: Some("thinking.local.availability"), handler: "local_availability", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1784,6 +1908,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/local/bootstrap"), contract_operation_id: Some("thinking.local.bootstrap"), handler: "local_bootstrap", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1798,6 +1923,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/local/bootstrap/status"), contract_operation_id: Some("thinking.local.bootstrap-status"), handler: "local_bootstrap_status", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1812,6 +1938,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/local/models"), contract_operation_id: Some("thinking.local.models"), handler: "local_models", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1826,6 +1953,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/providers/local/status"), contract_operation_id: Some("thinking.local.provider-status"), handler: "local_readiness", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1840,6 +1968,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/providers/local/status"), contract_operation_id: Some("thinking.local.provider-status"), handler: "local_status", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1854,6 +1983,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/providers"), contract_operation_id: Some("thinking.providers.update"), handler: "providers_set_active", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1868,6 +1998,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/providers"), contract_operation_id: Some("thinking.providers.get"), handler: "providers_show", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1882,6 +2013,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/scout/check"), contract_operation_id: Some("thinking.scout.check"), handler: "scout_check", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1896,6 +2028,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/scout/disable"), contract_operation_id: Some("thinking.scout.disable"), handler: "scout_disable", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1910,6 +2043,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/scout/enable"), contract_operation_id: Some("thinking.scout.enable"), handler: "scout_enable", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1924,6 +2058,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/scout/refresh"), contract_operation_id: Some("thinking.scout.refresh"), handler: "scout_refresh", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1938,6 +2073,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/scout"), contract_operation_id: Some("thinking.scout.status"), handler: "scout_status", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1952,6 +2088,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/thinking/api/local/endpoint"), contract_operation_id: Some("thinking.local.endpoint.set"), handler: "set_local_endpoint", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1966,6 +2103,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/transcripts/api/read/{day}"), contract_operation_id: Some("transcripts.read"), handler: "read", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1980,6 +2118,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/transcripts/api/day/{day}"), contract_operation_id: Some("transcripts.scan"), handler: "scan", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -1994,6 +2133,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/transcripts/api/segments/{day}"), contract_operation_id: Some("transcripts.segments"), handler: "segments", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2008,6 +2148,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/transcripts/api/segment/{day}/{stream}/{segment_key}"), contract_operation_id: Some("transcripts.speakers"), handler: "speakers", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2022,6 +2163,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/app/transcripts/api/stats/{month}"), contract_operation_id: Some("transcripts.stats"), handler: "stats", + resident: false, }, InventoryEntry { surface: "sol-chat", @@ -2036,6 +2178,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: None, contract_operation_id: None, handler: "chat", + resident: false, }, InventoryEntry { surface: "sol-import", @@ -2050,6 +2193,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: None, contract_operation_id: None, handler: "import_top_level", + resident: false, }, InventoryEntry { surface: "sol-link", @@ -2064,6 +2208,22 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: None, contract_operation_id: None, handler: "link_join", + resident: false, + }, + InventoryEntry { + surface: "sol-link", + path: &["link", "serve"], + kind: "top-level", + help: "serve a paired journal over the local link bridge", + authority_path: "solstone/think/native/link/authority.toml", + params_json: "[{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"label\",\"nargs\":1,\"options\":[\"--label\"],\"required\":false,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":5015,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"port\",\"nargs\":1,\"options\":[\"--port\"],\"required\":false,\"secondary\":[],\"type\":\"integer\"},{\"count\":false,\"default\":null,\"flag_value\":null,\"hidden\":false,\"is_flag\":false,\"kind\":\"option\",\"multiple\":false,\"name\":\"relay_url\",\"nargs\":1,\"options\":[\"--relay-url\"],\"required\":false,\"secondary\":[],\"type\":\"text\"},{\"count\":false,\"default\":false,\"flag_value\":true,\"hidden\":false,\"is_flag\":true,\"kind\":\"option\",\"multiple\":false,\"name\":\"direct\",\"nargs\":1,\"options\":[\"--direct\"],\"required\":false,\"secondary\":[],\"type\":\"boolean\"}]", + entry_type: "top-level-link", + operation_id: "link.serve", + method: None, + route: None, + contract_operation_id: None, + handler: "link_serve", + resident: true, }, InventoryEntry { surface: "sol-call", @@ -2078,6 +2238,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: None, contract_operation_id: None, handler: "identity", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2092,6 +2253,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: None, contract_operation_id: None, handler: "navigate", + resident: false, }, InventoryEntry { surface: "sol-notify", @@ -2106,6 +2268,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: None, contract_operation_id: None, handler: "notify", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2120,6 +2283,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/health/summary"), contract_operation_id: Some("health.summary"), handler: "summary", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2134,6 +2298,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/health/full"), contract_operation_id: Some("health.full"), handler: "full", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2148,6 +2313,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/health/range"), contract_operation_id: Some("health.for_range"), handler: "for_range", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2162,6 +2328,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/health/pipeline"), contract_operation_id: Some("health.pipeline"), handler: "pipeline", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2176,6 +2343,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/ledger"), contract_operation_id: Some("ledger.list"), handler: "list", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2190,6 +2358,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/ledger/decisions"), contract_operation_id: Some("ledger.decisions"), handler: "decisions", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2204,6 +2373,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/ledger/{item_id}"), contract_operation_id: Some("ledger.get"), handler: "get", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2218,6 +2388,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/ledger/{item_id}/close"), contract_operation_id: Some("ledger.close"), handler: "close", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2232,6 +2403,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/profile/{name}/brief"), contract_operation_id: Some("profile.brief"), handler: "brief", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2246,6 +2418,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/profile/{name}/cadence"), contract_operation_id: Some("profile.cadence"), handler: "cadence", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2260,6 +2433,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/profile/{name}"), contract_operation_id: Some("profile.full"), handler: "full", + resident: false, }, InventoryEntry { surface: "sol-call", @@ -2274,6 +2448,7 @@ pub const ENTRIES: &[InventoryEntry] = &[ route: Some("/api/profiles/active"), contract_operation_id: Some("profile.list-active"), handler: "list_active", + resident: false, }, ]; @@ -2438,3 +2613,6 @@ pub const HANDLERS: &[Handler] = &[ solstone_think_tools_native_profile_command_rs::full, solstone_think_tools_native_profile_command_rs::list_active, ]; + +pub const RESIDENT_HANDLERS: &[ResidentHandler] = + &[solstone_think_native_link_command_rs::link_serve]; diff --git a/core/crates/solstone-core-sol-client/src/seam.rs b/core/crates/solstone-core-sol-client/src/seam.rs index 4d2b9f606..752522cbd 100644 --- a/core/crates/solstone-core-sol-client/src/seam.rs +++ b/core/crates/solstone-core-sol-client/src/seam.rs @@ -173,6 +173,131 @@ pub trait LinkJoinPairingSeam: Send + Sync { ) -> Result; } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkServeEndpoint { + pub host: String, + pub port: u16, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LinkServeBundle { + pub private_key_pem: String, + pub client_cert_pem: String, + pub ca_chain_pem: Vec, + pub home_attestation: String, + pub instance_id: String, + pub home_label: String, + pub endpoints: Vec, + pub local_endpoints: serde_json::Value, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LinkServeRequest { + pub label: String, + pub port: u16, + pub direct: bool, + pub relay_origin: Option, + pub bundle: LinkServeBundle, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LinkServeFailure { + pub reason: String, + pub detail: String, + pub at: f64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LinkServeStatusSnapshot { + pub health: String, + pub state: String, + pub manager_alive: bool, + pub connected_age_seconds: Option, + pub last_connected_at: Option, + pub last_failure: Option, + pub next_retry_at: Option, + pub reconnect_count: u64, + pub active_requests: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkServeRelayErrorKind { + HomeOffline, + Unauthorized, + Unpaid, + UnknownInstance, + PairWindowClosed, + Overflow, + Abnormal, + UpgradeRejected, + Stalled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkServeRelayControlEndpoint { + EnrollDevice, + TokenRefresh, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkServeTransportErrorKind { + Io, + Tls, + Crypto, + Mux, + Http, + Json, + PairLink, + Pairing, + Rejected { + status: u16, + }, + Relay(LinkServeRelayErrorKind), + RelayControlRejected { + endpoint: LinkServeRelayControlEndpoint, + status: u16, + }, + NoEndpoint, + NotPaired, + LocalOffset, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LinkServeErrorKind { + InvalidBundle, + InvalidRelayUrl, + Bind { port: u16, addr_in_use: bool }, + RuntimeUnavailable, + BridgeCapability, + Transport(LinkServeTransportErrorKind), + Shutdown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkServeError { + pub kind: LinkServeErrorKind, +} + +impl LinkServeError { + #[must_use] + pub fn new(kind: LinkServeErrorKind) -> Self { + Self { kind } + } +} + +pub trait LinkServeSession: Send { + fn bound_port(&self) -> u16; + fn serve( + self: Box, + shutdown: &dyn crate::resident::ShutdownSignal, + ) -> Result<(), LinkServeError>; +} + +pub trait LinkServeRunner: Send + Sync { + fn start(&self, request: LinkServeRequest) + -> Result, LinkServeError>; +} + pub trait FileProvider { fn read(&self, path: &Path) -> IoResult>; fn read_to_string(&self, path: &Path) -> std::io::Result; @@ -263,12 +388,41 @@ pub enum RecordedLinkJoinPairingCall { Relay(LinkJoinRelayRequest), } +#[derive(Debug, Clone, PartialEq)] +pub struct ExpectedLinkServeSession { + pub bound_port: u16, + pub serve_result: Result<(), LinkServeError>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ExpectedLinkServeCall { + pub expected: LinkServeRequest, + pub result: Result, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RecordedLinkServeCall { + pub request: LinkServeRequest, +} + #[derive(Debug, Default)] pub struct ScriptedLinkJoinPairingSeam { calls: Mutex>, recorded: Mutex>, } +#[derive(Debug, Default)] +pub struct ScriptedLinkServeRunner { + calls: Mutex>, + recorded: Mutex>, +} + +#[derive(Debug)] +struct ScriptedLinkServeSession { + bound_port: u16, + serve_result: Result<(), LinkServeError>, +} + impl ScriptedLinkJoinPairingSeam { #[must_use] pub fn new(calls: Vec) -> Self { @@ -294,6 +448,67 @@ impl ScriptedLinkJoinPairingSeam { } } +impl ScriptedLinkServeRunner { + #[must_use] + pub fn new(calls: Vec) -> Self { + Self { + calls: Mutex::new(calls.into()), + recorded: Mutex::new(Vec::new()), + } + } + + pub fn assert_done(&self) { + assert!( + self.calls.lock().expect("scripted calls lock").is_empty(), + "scripted link serve calls were not exhausted" + ); + } + + #[must_use] + pub fn recorded(&self) -> Vec { + self.recorded.lock().expect("link serve calls lock").clone() + } +} + +impl LinkServeRunner for ScriptedLinkServeRunner { + fn start( + &self, + request: LinkServeRequest, + ) -> Result, LinkServeError> { + self.recorded + .lock() + .expect("link serve calls lock") + .push(RecordedLinkServeCall { + request: request.clone(), + }); + match self.calls.lock().expect("scripted calls lock").pop_front() { + Some(ExpectedLinkServeCall { expected, result }) => { + assert_eq!(request, expected); + result.map(|session| { + Box::new(ScriptedLinkServeSession { + bound_port: session.bound_port, + serve_result: session.serve_result, + }) as Box + }) + } + other => panic!("expected link serve call, got {other:?}"), + } + } +} + +impl LinkServeSession for ScriptedLinkServeSession { + fn bound_port(&self) -> u16 { + self.bound_port + } + + fn serve( + self: Box, + _shutdown: &dyn crate::resident::ShutdownSignal, + ) -> Result<(), LinkServeError> { + self.serve_result + } +} + impl RecordingNotificationSink { #[must_use] pub fn new() -> Self { diff --git a/core/crates/solstone-core-sol-link/Cargo.toml b/core/crates/solstone-core-sol-link/Cargo.toml index 17a27b111..781fd3f09 100644 --- a/core/crates/solstone-core-sol-link/Cargo.toml +++ b/core/crates/solstone-core-sol-link/Cargo.toml @@ -19,6 +19,7 @@ tokio = { workspace = true, features = ["rt", "net", "time", "io-util", "sync"] [dev-dependencies] rcgen = { workspace = true } +tokio-rustls = { version = "0.26.4", default-features = false, features = ["ring"] } [lints] workspace = true diff --git a/core/crates/solstone-core-sol-link/src/lib.rs b/core/crates/solstone-core-sol-link/src/lib.rs index d76dc7d77..68917094b 100644 --- a/core/crates/solstone-core-sol-link/src/lib.rs +++ b/core/crates/solstone-core-sol-link/src/lib.rs @@ -14,6 +14,9 @@ use spl_transport::{RelayControlEndpoint, RelayError, TransportError, tls}; mod direct_seam; mod pairing_entry; +mod serve; + +pub use serve::SplLinkServeRunner; #[derive(Debug, Clone, Copy, Default)] pub struct SplLinkJoinPairingSeam; diff --git a/core/crates/solstone-core-sol-link/src/serve.rs b/core/crates/solstone-core-sol-link/src/serve.rs new file mode 100644 index 000000000..3a2c0d681 --- /dev/null +++ b/core/crates/solstone-core-sol-link/src/serve.rs @@ -0,0 +1,1148 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +use std::future::Future; +use std::io::ErrorKind; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{Map, Value}; +use solstone_core_sol_client::resident::ShutdownSignal; +use solstone_core_sol_client::seam::{ + LinkServeBundle, LinkServeError, LinkServeErrorKind, LinkServeFailure, + LinkServeRelayControlEndpoint, LinkServeRelayErrorKind, LinkServeRequest, LinkServeRunner, + LinkServeSession, LinkServeStatusSnapshot, LinkServeTransportErrorKind, +}; +use spl_core::bridge::{BridgeNames, RequestHeaderPolicy}; +use spl_transport::client::{DialedCarrier, TransportClient}; +use spl_transport::credential::{Credential, EndpointAddr}; +use spl_transport::journal_bridge::{ + self, BridgePolicy, BridgeStartError, CapabilityGate, CarrierOpener, JournalBridgeConfig, + JournalBridgeHandle, JournalBridgeStatus, LocalResponse, +}; +use spl_transport::relay_pairing::enroll_device; +use spl_transport::{RelayControlEndpoint, RelayError, TransportError, tls}; + +const STATUS_PATH: &str = "/_solstone/link/status"; +const OBSERVER_HEADER: &str = "X-Solstone-Observer"; +const PROTOCOL_HEADER: &str = "X-Solstone-Protocol-Version"; +const OBSERVER_PROTOCOL_VERSION: &str = "2"; +const MAX_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, Default)] +pub struct SplLinkServeRunner; + +impl LinkServeRunner for SplLinkServeRunner { + fn start( + &self, + request: LinkServeRequest, + ) -> Result, LinkServeError> { + ServeStarter::default().start(request) + } +} + +struct ServeStarter { + enrollment: Arc, + clock: Arc, +} + +impl Default for ServeStarter { + fn default() -> Self { + Self { + enrollment: Arc::new(SplRelayEnrollment), + clock: Arc::new(SystemStatusClock), + } + } +} + +impl ServeStarter { + fn start( + &self, + request: LinkServeRequest, + ) -> Result, LinkServeError> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| LinkServeError::new(LinkServeErrorKind::RuntimeUnavailable))?; + let enrollment = self.enrollment.clone(); + let credential = runtime.block_on(credential_from_request(&request, enrollment))?; + let relay_only = credential.relay_origin.is_some() && credential.endpoints.is_empty(); + let client = Arc::new( + if relay_only { + TransportClient::new_relay_only(credential, None) + } else { + TransportClient::new(credential, None) + } + .map_err(|error| { + LinkServeError::new(LinkServeErrorKind::Transport(map_transport_error(error))) + })?, + ); + let tracker = Arc::new(StatusTracker::new(self.clock.clone())); + let opener = Arc::new(SolstoneCarrierOpener { + client, + label: request.label.clone(), + tracker: tracker.clone(), + }); + let policy = bridge_policy_for_port(request.port, tracker); + let endpoint_hosts = request + .bundle + .endpoints + .iter() + .map(|endpoint| endpoint.host.clone()) + .collect::>(); + let config = JournalBridgeConfig { + opener, + bridge_names: bridge_names(), + endpoint_hosts, + policy, + }; + let handle = runtime + .block_on(journal_bridge::start(config)) + .map_err(|error| map_bridge_start_error(error, request.port))?; + Ok(Box::new(SplLinkServeSession { + port: handle.port(), + runtime, + handle: Some(handle), + })) + } +} + +struct SplLinkServeSession { + port: u16, + runtime: tokio::runtime::Runtime, + handle: Option, +} + +impl LinkServeSession for SplLinkServeSession { + fn bound_port(&self) -> u16 { + self.port + } + + fn serve(mut self: Box, shutdown: &dyn ShutdownSignal) -> Result<(), LinkServeError> { + shutdown.wait(); + if let Some(handle) = self.handle.take() { + self.runtime.block_on(handle.shutdown_and_wait()); + } + Ok(()) + } +} + +struct SolstoneCarrierOpener { + client: Arc, + label: String, + tracker: Arc, +} + +impl CarrierOpener for SolstoneCarrierOpener { + fn proxy_headers( + &self, + upstream_headers: &[(String, String)], + ) -> Result, TransportError> { + let mut headers = upstream_headers.to_vec(); + headers.push((OBSERVER_HEADER.to_string(), self.label.clone())); + headers.push(( + PROTOCOL_HEADER.to_string(), + OBSERVER_PROTOCOL_VERSION.to_string(), + )); + Ok(headers) + } + + fn dial_carrier( + &self, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let result = self.client.dial_carrier().await; + match &result { + Ok(_) => self.tracker.carrier_open_succeeded(), + Err(error) => self.tracker.carrier_open_failed(error), + } + result + }) + } +} + +async fn credential_from_request( + request: &LinkServeRequest, + enrollment: Arc, +) -> Result { + let token = if request.direct { + None + } else if let Some(origin) = request.relay_origin.as_deref() { + Some( + enrollment + .enroll( + origin, + &request.bundle.instance_id, + &request.bundle.home_attestation, + ) + .await + .map_err(|error| { + LinkServeError::new(LinkServeErrorKind::Transport(map_transport_error(error))) + })?, + ) + } else { + None + }; + Ok(Credential { + client_key_pem: request.bundle.private_key_pem.clone(), + client_cert_pem: request.bundle.client_cert_pem.clone(), + ca_chain_pem: request.bundle.ca_chain_pem.clone(), + ca_fp_prefix: ca_fp_prefix(&request.bundle)?, + instance_id: request.bundle.instance_id.clone(), + home_label: request.bundle.home_label.clone(), + endpoints: request + .bundle + .endpoints + .iter() + .map(|endpoint| EndpointAddr { + host: endpoint.host.clone(), + port: endpoint.port, + }) + .collect(), + home_attestation: Some(request.bundle.home_attestation.clone()), + local_endpoints: Some(request.bundle.local_endpoints.clone()), + relay_origin: if request.direct { + None + } else { + request.relay_origin.clone() + }, + device_token: token, + device_token_expires_at: None, + }) +} + +fn ca_fp_prefix(bundle: &LinkServeBundle) -> Result, LinkServeError> { + let chain_pem = bundle + .ca_chain_pem + .iter() + .map(|cert| { + if cert.ends_with('\n') { + cert.clone() + } else { + format!("{cert}\n") + } + }) + .collect::(); + let certs = tls::parse_certs(&chain_pem).map_err(|error| { + LinkServeError::new(LinkServeErrorKind::Transport(map_transport_error(error))) + })?; + let Some(first) = certs.first() else { + return Err(LinkServeError::new(LinkServeErrorKind::InvalidBundle)); + }; + Ok(spl_core::ca::sha256(first.as_ref())[..16].to_vec()) +} + +fn bridge_names() -> BridgeNames { + BridgeNames { + capability_cookie_name: "__solstone_link_cap".to_string(), + upstream_cookie_prefix: String::new(), + observer_header_name: "x-solstone-observer".to_string(), + protocol_version_header_name: "x-solstone-protocol-version".to_string(), + } +} + +fn bridge_policy(tracker: Arc) -> BridgePolicy { + BridgePolicy { + port: 0, + capability_gate: CapabilityGate::Disabled, + stream_response: Arc::new(|_| true), + local_response: Arc::new(move |head, status| { + if head.path() != STATUS_PATH { + return None; + } + let body = status_body(&tracker.snapshot(*status)); + Some(LocalResponse { + status: 200, + content_type: "application/json".to_string(), + body, + }) + }), + attribution_headers: Arc::new(|_| Vec::new()), + request_headers: RequestHeaderPolicy::ForwardAll, + max_request_body_bytes: MAX_REQUEST_BODY_BYTES, + } +} + +fn bridge_policy_for_port(port: u16, tracker: Arc) -> BridgePolicy { + BridgePolicy { + port, + ..bridge_policy(tracker) + } +} + +fn status_body(snapshot: &LinkServeStatusSnapshot) -> Vec { + let mut root = Map::new(); + root.insert( + "active_requests".to_string(), + Value::Number(snapshot.active_requests.into()), + ); + root.insert( + "connected_age_seconds".to_string(), + option_f64(snapshot.connected_age_seconds), + ); + root.insert("health".to_string(), Value::String(snapshot.health.clone())); + root.insert( + "last_connected_at".to_string(), + option_f64(snapshot.last_connected_at), + ); + root.insert( + "last_failure".to_string(), + snapshot + .last_failure + .as_ref() + .map_or(Value::Null, |failure| { + let mut item = Map::new(); + item.insert("at".to_string(), number_or_null(failure.at)); + item.insert("detail".to_string(), Value::String(failure.detail.clone())); + item.insert("reason".to_string(), Value::String(failure.reason.clone())); + Value::Object(item) + }), + ); + root.insert( + "manager_alive".to_string(), + Value::Bool(snapshot.manager_alive), + ); + root.insert("next_retry_at".to_string(), Value::Null); + root.insert( + "reconnect_count".to_string(), + Value::Number(snapshot.reconnect_count.into()), + ); + root.insert("state".to_string(), Value::String(snapshot.state.clone())); + serde_json::to_vec(&Value::Object(root)).expect("status snapshot must serialize") +} + +fn option_f64(value: Option) -> Value { + value.map_or(Value::Null, number_or_null) +} + +fn number_or_null(value: f64) -> Value { + serde_json::Number::from_f64(value).map_or(Value::Null, Value::Number) +} + +struct StatusTracker { + inner: Mutex, + clock: Arc, +} + +#[derive(Debug, Default)] +struct StatusTrackerState { + last_connected_at: Option, + last_failure: Option, + reconnect_count: u64, +} + +impl StatusTracker { + fn new(clock: Arc) -> Self { + Self { + inner: Mutex::new(StatusTrackerState::default()), + clock, + } + } + + fn carrier_open_succeeded(&self) { + let mut state = self.inner.lock().expect("status tracker lock"); + state.last_connected_at = Some(self.clock.now_unix_seconds()); + } + + fn carrier_open_failed(&self, error: &TransportError) { + let mut state = self.inner.lock().expect("status tracker lock"); + state.reconnect_count = state.reconnect_count.saturating_add(1); + state.last_failure = Some(failure_from_transport(error, self.clock.now_unix_seconds())); + } + + fn snapshot(&self, bridge: JournalBridgeStatus) -> LinkServeStatusSnapshot { + let state = self.inner.lock().expect("status tracker lock"); + let now = self.clock.now_unix_seconds(); + let connected_age_seconds = if bridge.carrier_live { + state + .last_connected_at + .map(|connected| (now - connected).max(0.0)) + } else { + None + }; + LinkServeStatusSnapshot { + health: if bridge.listener_active && bridge.carrier_live { + "healthy".to_string() + } else { + "unhealthy".to_string() + }, + state: if bridge.carrier_live { + "connected".to_string() + } else if bridge.listener_active { + "disconnected".to_string() + } else { + "closed".to_string() + }, + manager_alive: bridge.listener_active, + connected_age_seconds, + last_connected_at: state.last_connected_at, + last_failure: state.last_failure.clone(), + next_retry_at: None, + reconnect_count: state.reconnect_count, + active_requests: bridge.active_requests, + } + } +} + +fn failure_from_transport(error: &TransportError, at: f64) -> LinkServeFailure { + let kind = map_transport_error_ref(error); + LinkServeFailure { + reason: serve_reason_code(&kind).to_string(), + detail: serve_failure_detail(&kind).to_string(), + at, + } +} + +trait StatusClock: Send + Sync { + fn now_unix_seconds(&self) -> f64; +} + +#[derive(Debug)] +struct SystemStatusClock; + +impl StatusClock for SystemStatusClock { + fn now_unix_seconds(&self) -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0.0, |duration| duration.as_secs_f64()) + } +} + +trait RelayEnrollment: Send + Sync { + fn enroll<'a>( + &'a self, + relay_origin: &'a str, + instance_id: &'a str, + home_attestation: &'a str, + ) -> Pin> + Send + 'a>>; +} + +#[derive(Debug)] +struct SplRelayEnrollment; + +impl RelayEnrollment for SplRelayEnrollment { + fn enroll<'a>( + &'a self, + relay_origin: &'a str, + instance_id: &'a str, + home_attestation: &'a str, + ) -> Pin> + Send + 'a>> { + Box::pin(enroll_device(relay_origin, instance_id, home_attestation)) + } +} + +fn map_bridge_start_error(error: BridgeStartError, port: u16) -> LinkServeError { + match error { + BridgeStartError::Capability(error) => { + drop(error); + LinkServeError::new(LinkServeErrorKind::BridgeCapability) + } + BridgeStartError::Bind(error) => LinkServeError::new(LinkServeErrorKind::Bind { + port, + addr_in_use: error.kind() == ErrorKind::AddrInUse, + }), + } +} + +fn map_transport_error(error: TransportError) -> LinkServeTransportErrorKind { + match error { + TransportError::Io(error) => { + drop(error); + LinkServeTransportErrorKind::Io + } + TransportError::Tls(message) => { + drop(message); + LinkServeTransportErrorKind::Tls + } + TransportError::Crypto(message) => { + drop(message); + LinkServeTransportErrorKind::Crypto + } + TransportError::Mux(error) => { + drop(error); + LinkServeTransportErrorKind::Mux + } + TransportError::Http(error) => { + drop(error); + LinkServeTransportErrorKind::Http + } + TransportError::Json(error) => { + drop(error); + LinkServeTransportErrorKind::Json + } + TransportError::PairLink(message) => { + drop(message); + LinkServeTransportErrorKind::PairLink + } + TransportError::Pairing(message) => { + drop(message); + LinkServeTransportErrorKind::Pairing + } + TransportError::Rejected { status, body } => { + drop(body); + LinkServeTransportErrorKind::Rejected { status } + } + TransportError::Relay(error) => LinkServeTransportErrorKind::Relay(map_relay_error(error)), + TransportError::RelayControlRejected { endpoint, status } => { + LinkServeTransportErrorKind::RelayControlRejected { + endpoint: map_relay_control_endpoint(endpoint), + status, + } + } + TransportError::NoEndpoint => LinkServeTransportErrorKind::NoEndpoint, + TransportError::NotPaired => LinkServeTransportErrorKind::NotPaired, + TransportError::LocalOffset => LinkServeTransportErrorKind::LocalOffset, + } +} + +fn map_transport_error_ref(error: &TransportError) -> LinkServeTransportErrorKind { + match error { + TransportError::Io(_) => LinkServeTransportErrorKind::Io, + TransportError::Tls(_) => LinkServeTransportErrorKind::Tls, + TransportError::Crypto(_) => LinkServeTransportErrorKind::Crypto, + TransportError::Mux(_) => LinkServeTransportErrorKind::Mux, + TransportError::Http(_) => LinkServeTransportErrorKind::Http, + TransportError::Json(_) => LinkServeTransportErrorKind::Json, + TransportError::PairLink(_) => LinkServeTransportErrorKind::PairLink, + TransportError::Pairing(_) => LinkServeTransportErrorKind::Pairing, + TransportError::Rejected { status, body: _ } => { + LinkServeTransportErrorKind::Rejected { status: *status } + } + TransportError::Relay(error) => LinkServeTransportErrorKind::Relay(map_relay_error(*error)), + TransportError::RelayControlRejected { endpoint, status } => { + LinkServeTransportErrorKind::RelayControlRejected { + endpoint: map_relay_control_endpoint(*endpoint), + status: *status, + } + } + TransportError::NoEndpoint => LinkServeTransportErrorKind::NoEndpoint, + TransportError::NotPaired => LinkServeTransportErrorKind::NotPaired, + TransportError::LocalOffset => LinkServeTransportErrorKind::LocalOffset, + } +} + +fn map_relay_error(error: RelayError) -> LinkServeRelayErrorKind { + match error { + RelayError::HomeOffline => LinkServeRelayErrorKind::HomeOffline, + RelayError::Unauthorized => LinkServeRelayErrorKind::Unauthorized, + RelayError::Unpaid => LinkServeRelayErrorKind::Unpaid, + RelayError::UnknownInstance => LinkServeRelayErrorKind::UnknownInstance, + RelayError::PairWindowClosed => LinkServeRelayErrorKind::PairWindowClosed, + RelayError::Overflow => LinkServeRelayErrorKind::Overflow, + RelayError::Abnormal => LinkServeRelayErrorKind::Abnormal, + RelayError::UpgradeRejected => LinkServeRelayErrorKind::UpgradeRejected, + RelayError::Stalled => LinkServeRelayErrorKind::Stalled, + } +} + +fn map_relay_control_endpoint(endpoint: RelayControlEndpoint) -> LinkServeRelayControlEndpoint { + match endpoint { + RelayControlEndpoint::EnrollDevice => LinkServeRelayControlEndpoint::EnrollDevice, + RelayControlEndpoint::TokenRefresh => LinkServeRelayControlEndpoint::TokenRefresh, + } +} + +fn serve_reason_code(kind: &LinkServeTransportErrorKind) -> &'static str { + match kind { + LinkServeTransportErrorKind::Io => "io", + LinkServeTransportErrorKind::Tls => "tls", + LinkServeTransportErrorKind::Crypto => "crypto", + LinkServeTransportErrorKind::Mux => "mux", + LinkServeTransportErrorKind::Http => "http", + LinkServeTransportErrorKind::Json => "json", + LinkServeTransportErrorKind::PairLink => "pair-link", + LinkServeTransportErrorKind::Pairing => "pairing", + LinkServeTransportErrorKind::Rejected { status: _ } => "rejected", + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::HomeOffline) => { + "relay-home-offline" + } + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::Unauthorized) => { + "relay-unauthorized" + } + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::Unpaid) => "relay-unpaid", + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::UnknownInstance) => { + "relay-unknown-instance" + } + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::PairWindowClosed) => { + "relay-pair-window-closed" + } + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::Overflow) => "relay-overflow", + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::Abnormal) => "relay-abnormal", + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::UpgradeRejected) => { + "relay-upgrade-rejected" + } + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::Stalled) => "relay-stalled", + LinkServeTransportErrorKind::RelayControlRejected { + endpoint, + status: _, + } => match endpoint { + LinkServeRelayControlEndpoint::EnrollDevice => "relay-control-enroll-device", + LinkServeRelayControlEndpoint::TokenRefresh => "relay-control-token-refresh", + }, + LinkServeTransportErrorKind::NoEndpoint => "no-endpoint", + LinkServeTransportErrorKind::NotPaired => "not-paired", + LinkServeTransportErrorKind::LocalOffset => "local-offset", + } +} + +fn serve_failure_detail(kind: &LinkServeTransportErrorKind) -> &'static str { + match kind { + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::HomeOffline) => { + "relay reports home offline" + } + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::Unauthorized) + | LinkServeTransportErrorKind::RelayControlRejected { .. } => { + "relay rejected link credentials" + } + LinkServeTransportErrorKind::NoEndpoint => "no journal endpoint is available", + LinkServeTransportErrorKind::NotPaired => "link credentials are missing", + _ => "link carrier failed", + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::time::Duration; + + use rcgen::{CertificateParams, KeyPair, PKCS_ECDSA_P256_SHA256}; + use rustls::ServerConfig; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + use serde_json::json; + use solstone_core_sol_client::seam::LinkServeEndpoint; + use spl_core::bridge::RequestHead; + use spl_core::frame::{FLAG_CLOSE, FLAG_DATA, Frame, FrameDecoder, RECOMMENDED_CHUNK}; + use spl_transport::credential::{Credential, EndpointAddr}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener as TokioTcpListener; + use tokio::sync::oneshot; + use tokio_rustls::TlsAcceptor; + + use super::*; + + #[derive(Debug, Clone, PartialEq, Eq)] + struct EnrollmentCall { + relay_origin: String, + instance_id: String, + home_attestation: String, + } + + #[derive(Debug, Default)] + struct FakeEnrollment { + calls: Arc>>, + } + + impl FakeEnrollment { + fn calls(&self) -> Vec { + self.calls.lock().expect("enrollment calls lock").clone() + } + } + + impl RelayEnrollment for FakeEnrollment { + fn enroll<'a>( + &'a self, + relay_origin: &'a str, + instance_id: &'a str, + home_attestation: &'a str, + ) -> Pin> + Send + 'a>> { + let calls = self.calls.clone(); + let call = EnrollmentCall { + relay_origin: relay_origin.to_string(), + instance_id: instance_id.to_string(), + home_attestation: home_attestation.to_string(), + }; + Box::pin(async move { + calls.lock().expect("enrollment calls lock").push(call); + Ok("device-token".to_string()) + }) + } + } + + #[derive(Debug)] + struct FixedStatusClock(Mutex); + + impl FixedStatusClock { + fn new(now: f64) -> Self { + Self(Mutex::new(now)) + } + + fn set(&self, now: f64) { + *self.0.lock().expect("clock lock") = now; + } + } + + impl StatusClock for FixedStatusClock { + fn now_unix_seconds(&self) -> f64 { + *self.0.lock().expect("clock lock") + } + } + + #[derive(Debug, Default)] + struct CountingOpener { + dials: AtomicUsize, + } + + impl CountingOpener { + fn dials(&self) -> usize { + self.dials.load(Ordering::SeqCst) + } + } + + impl CarrierOpener for CountingOpener { + fn proxy_headers( + &self, + upstream_headers: &[(String, String)], + ) -> Result, TransportError> { + Ok(upstream_headers.to_vec()) + } + + fn dial_carrier( + &self, + ) -> Pin> + Send + '_>> + { + Box::pin(async move { + self.dials.fetch_add(1, Ordering::SeqCst); + Err(TransportError::NoEndpoint) + }) + } + } + + struct TransportClientOpener { + client: Arc, + } + + impl CarrierOpener for TransportClientOpener { + fn proxy_headers( + &self, + upstream_headers: &[(String, String)], + ) -> Result, TransportError> { + Ok(upstream_headers.to_vec()) + } + + fn dial_carrier( + &self, + ) -> Pin> + Send + '_>> + { + Box::pin(self.client.dial_carrier()) + } + } + + fn ca_pem() -> String { + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("test key"); + let params = CertificateParams::new(Vec::::new()).expect("test params"); + params.self_signed(&key).expect("test ca").pem() + } + + fn serve_request(direct: bool, relay_origin: Option<&str>) -> LinkServeRequest { + let ca = ca_pem(); + LinkServeRequest { + label: "laptop".to_string(), + port: 5015, + direct, + relay_origin: relay_origin.map(str::to_string), + bundle: LinkServeBundle { + private_key_pem: "PRIVATE\n".to_string(), + client_cert_pem: "CERT\n".to_string(), + ca_chain_pem: vec![ca], + home_attestation: "attestation.jwt".to_string(), + instance_id: "home-instance".to_string(), + home_label: "Home".to_string(), + endpoints: vec![LinkServeEndpoint { + host: "192.168.1.10".to_string(), + port: 7657, + }], + local_endpoints: json!([{"ip": "192.168.1.10", "port": 7657}]), + }, + } + } + + fn bridge_status(listener_active: bool, carrier_live: bool) -> JournalBridgeStatus { + JournalBridgeStatus { + listener_active, + contacted: false, + carrier_live, + active_requests: 0, + } + } + + fn request_head(target: &str) -> RequestHead { + RequestHead { + method: "GET".to_string(), + target: target.to_string(), + headers: vec![("host".to_string(), "127.0.0.1:5015".to_string())], + } + } + + fn unused_loopback_port() -> u16 { + TcpListener::bind(("127.0.0.1", 0)) + .expect("bind probe") + .local_addr() + .expect("probe addr") + .port() + } + + fn self_signed_server() -> (CertificateDer<'static>, PrivateKeyDer<'static>) { + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("server key"); + let params = CertificateParams::new(vec!["spl.local".to_string()]).expect("server params"); + let cert = params.self_signed(&key).expect("server cert"); + let cert_der = CertificateDer::from(cert.der().to_vec()); + let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der())); + (cert_der, key_der) + } + + fn server_config(cert: CertificateDer<'static>, key: PrivateKeyDer<'static>) -> ServerConfig { + ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .expect("server protocol versions") + .with_no_client_auth() + .with_single_cert(vec![cert], key) + .expect("server config") + } + + fn transport_credential(pin: Vec, port: u16) -> Credential { + let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).expect("client key"); + let params = + CertificateParams::new(vec!["transport.test".to_string()]).expect("client cert params"); + let cert = params.self_signed(&key).expect("client cert"); + Credential { + client_key_pem: key.serialize_pem(), + client_cert_pem: cert.pem(), + ca_chain_pem: vec![cert.pem()], + ca_fp_prefix: pin, + instance_id: "test-instance".to_string(), + home_label: "Home".to_string(), + endpoints: vec![EndpointAddr { + host: "127.0.0.1".to_string(), + port, + }], + home_attestation: None, + local_endpoints: None, + relay_origin: None, + device_token: None, + device_token_expires_at: None, + } + } + + async fn read_framed_request( + tls: &mut tokio_rustls::server::TlsStream, + ) -> u32 { + let mut decoder = FrameDecoder::new(); + let mut stream_id = 1u32; + let mut closed = false; + let mut buf = [0u8; 4096]; + while !closed { + let n = tls.read(&mut buf).await.expect("read framed request"); + if n == 0 { + break; + } + decoder.feed(&buf[..n]); + for frame in decoder.drain().expect("decode request frame") { + stream_id = frame.stream_id; + if frame.flags & FLAG_CLOSE != 0 { + closed = true; + } + } + } + stream_id + } + + async fn write_response_frame( + tls: &mut tokio_rustls::server::TlsStream, + stream_id: u32, + flags: u8, + payload: Vec, + ) { + let frame = Frame::new(stream_id, flags, payload); + tls.write_all(&frame.encode().expect("encode response frame")) + .await + .expect("write response frame"); + tls.flush().await.expect("flush response frame"); + } + + async fn serve_latched_stream( + listener: TokioTcpListener, + acceptor: TlsAcceptor, + release: oneshot::Receiver<()>, + released: Arc, + ) { + let (tcp, _) = listener.accept().await.expect("accept transport peer"); + let mut tls = acceptor.accept(tcp).await.expect("accept tls"); + let stream_id = read_framed_request(&mut tls).await; + let mut first_chunk = vec![b'x'; RECOMMENDED_CHUNK * 2 + 1]; + first_chunk[0] = b'A'; + let content_length = first_chunk.len() + 1; + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: {content_length}\r\n\r\n" + ); + write_response_frame(&mut tls, stream_id, FLAG_DATA, head.into_bytes()).await; + write_response_frame(&mut tls, stream_id, FLAG_DATA, first_chunk).await; + release.await.expect("release latch"); + released.store(true, Ordering::SeqCst); + write_response_frame(&mut tls, stream_id, FLAG_DATA | FLAG_CLOSE, vec![b'B']).await; + let _ = tls.shutdown().await; + } + + async fn http_get(port: u16, target: &str) -> (SocketAddr, String) { + let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .expect("connect bridge"); + let peer = stream.peer_addr().expect("bridge peer addr"); + let request = + format!("GET {target} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"); + stream + .write_all(request.as_bytes()) + .await + .expect("write request"); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut response)) + .await + .expect("response timeout") + .expect("read response"); + (peer, String::from_utf8_lossy(&response).into_owned()) + } + + #[test] + fn direct_credentials_have_no_relay_fields_and_do_not_enroll() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let enrollment = Arc::new(FakeEnrollment::default()); + let request = serve_request(true, Some("https://poisoned.invalid")); + + let credential = runtime + .block_on(credential_from_request(&request, enrollment.clone())) + .expect("direct credential"); + + assert!(credential.relay_origin.is_none()); + assert!(credential.device_token.is_none()); + assert!(credential.device_token_expires_at.is_none()); + assert!(enrollment.calls().is_empty()); + } + + #[test] + fn relay_credentials_enroll_at_serve_time_in_memory() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let enrollment = Arc::new(FakeEnrollment::default()); + let request = serve_request(false, Some("https://relay.example")); + + let credential = runtime + .block_on(credential_from_request(&request, enrollment.clone())) + .expect("relay credential"); + + assert_eq!( + credential.relay_origin.as_deref(), + Some("https://relay.example") + ); + assert_eq!(credential.device_token.as_deref(), Some("device-token")); + assert!(credential.device_token_expires_at.is_none()); + assert_eq!( + enrollment.calls(), + vec![EnrollmentCall { + relay_origin: "https://relay.example".to_string(), + instance_id: "home-instance".to_string(), + home_attestation: "attestation.jwt".to_string(), + }] + ); + } + + #[test] + fn status_tracker_uses_one_shared_update_point_for_times_and_failures() { + let clock = Arc::new(FixedStatusClock::new(100.0)); + let tracker = StatusTracker::new(clock.clone()); + tracker.carrier_open_failed(&TransportError::NoEndpoint); + let failed = tracker.snapshot(bridge_status(true, false)); + assert_eq!(failed.reconnect_count, 1); + assert_eq!( + failed.last_failure.as_ref().map(|failure| failure.at), + Some(100.0) + ); + + clock.set(110.0); + tracker.carrier_open_succeeded(); + clock.set(115.5); + let connected = tracker.snapshot(bridge_status(true, true)); + assert_eq!(connected.last_connected_at, Some(110.0)); + assert_eq!(connected.connected_age_seconds, Some(5.5)); + assert_eq!(connected.reconnect_count, 1); + } + + #[test] + fn bridge_policy_status_is_local_and_attribution_hook_is_empty() { + let tracker = Arc::new(StatusTracker::new(Arc::new(FixedStatusClock::new(10.0)))); + let policy = bridge_policy_for_port(5015, tracker); + let status = bridge_status(true, false); + assert_eq!(policy.port, 5015); + assert!((policy.stream_response)(&request_head("/ordinary"))); + let local = (policy.local_response)(&request_head(STATUS_PATH), &status) + .expect("status local response"); + assert_eq!(local.status, 200); + assert_eq!(local.content_type, "application/json"); + let body: serde_json::Value = + serde_json::from_slice(&local.body).expect("status json body"); + assert_eq!( + body.as_object() + .expect("status object") + .keys() + .cloned() + .collect::>(), + vec![ + "active_requests", + "connected_age_seconds", + "health", + "last_connected_at", + "last_failure", + "manager_alive", + "next_retry_at", + "reconnect_count", + "state", + ] + ); + assert!((policy.local_response)(&request_head("/not-status"), &status).is_none()); + assert!((policy.attribution_headers)(&request_head(STATUS_PATH)).is_empty()); + } + + #[test] + fn solstone_adapter_adds_no_wildcard_bind_host_literal() { + let source = include_str!("serve.rs"); + let wildcard_v4 = ["0", "0", "0", "0"].join("."); + let wildcard_v6 = ":".repeat(2); + let named_loopback = format!("{}{}", "local", "host"); + for host in [wildcard_v4, wildcard_v6, named_loopback] { + assert!(!source.contains(&format!("{host:?}"))); + } + } + + #[test] + fn status_request_does_not_open_carrier_but_ordinary_request_does() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + runtime.block_on(async { + let port = unused_loopback_port(); + let opener = Arc::new(CountingOpener::default()); + let tracker = Arc::new(StatusTracker::new(Arc::new(FixedStatusClock::new(0.0)))); + let handle = journal_bridge::start(JournalBridgeConfig { + opener: opener.clone(), + bridge_names: bridge_names(), + endpoint_hosts: vec!["192.168.1.10".to_string()], + policy: bridge_policy_for_port(port, tracker), + }) + .await + .expect("bridge start"); + let bound = handle.port(); + + let (peer, status_response) = http_get(bound, STATUS_PATH).await; + assert_eq!( + peer, + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), bound) + ); + assert!(status_response.starts_with("HTTP/1.1 200")); + assert!(status_response.contains("Content-Type: application/json\r\n")); + assert!(status_response.contains("Content-Length: ")); + assert_eq!(opener.dials(), 0); + + let (_peer, ordinary_response) = http_get(bound, "/ordinary").await; + assert!(ordinary_response.starts_with("HTTP/1.1 502")); + assert!(opener.dials() >= 1); + + handle.shutdown_and_wait().await; + }); + } + + #[test] + fn proxied_response_streams_before_upstream_completion() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + runtime.block_on(async { + let (server_cert, server_key) = self_signed_server(); + let pin = spl_core::ca::sha256(server_cert.as_ref())[..16].to_vec(); + let listener = TokioTcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind transport peer"); + let transport_port = listener.local_addr().expect("transport addr").port(); + let client = Arc::new( + TransportClient::new(transport_credential(pin, transport_port), None) + .expect("transport client"), + ); + let acceptor = TlsAcceptor::from(Arc::new(server_config(server_cert, server_key))); + let (release_tx, release_rx) = oneshot::channel(); + let released = Arc::new(AtomicBool::new(false)); + let server = tokio::spawn(serve_latched_stream( + listener, + acceptor, + release_rx, + released.clone(), + )); + let bridge_port = unused_loopback_port(); + let tracker = Arc::new(StatusTracker::new(Arc::new(FixedStatusClock::new(0.0)))); + let handle = journal_bridge::start(JournalBridgeConfig { + opener: Arc::new(TransportClientOpener { client }), + bridge_names: bridge_names(), + endpoint_hosts: vec!["127.0.0.1".to_string()], + policy: bridge_policy_for_port(bridge_port, tracker), + }) + .await + .expect("bridge start"); + let bound = handle.port(); + + let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", bound)) + .await + .expect("connect bridge"); + let request = format!( + "GET /ordinary HTTP/1.1\r\nHost: 127.0.0.1:{bound}\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(request.as_bytes()) + .await + .expect("write request"); + + let mut response = Vec::new(); + let header_end = loop { + let mut buf = [0u8; 1024]; + let n = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)) + .await + .expect("response head timeout") + .expect("read response head"); + assert_ne!(n, 0, "bridge closed before response head"); + response.extend_from_slice(&buf[..n]); + if let Some(index) = response.windows(4).position(|window| window == b"\r\n\r\n") { + break index + 4; + } + }; + assert!(String::from_utf8_lossy(&response[..header_end]).starts_with("HTTP/1.1 200")); + let mut body = response.split_off(header_end); + + // A buffering implementation cannot satisfy this read until the + // upstream producer emits `B` or closes the response after the latch. + if body.is_empty() { + body.resize(1, 0); + tokio::time::timeout(Duration::from_secs(2), stream.read_exact(&mut body[..1])) + .await + .expect("first streamed body byte timeout") + .expect("read first streamed body byte"); + } + assert_eq!(body[0], b'A'); + assert!(!released.load(Ordering::SeqCst)); + + release_tx.send(()).expect("release upstream stream"); + tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut body)) + .await + .expect("response completion timeout") + .expect("read response completion"); + assert_eq!(body.last().copied(), Some(b'B')); + assert!(released.load(Ordering::SeqCst)); + + server.await.expect("server task"); + handle.shutdown_and_wait().await; + }); + } +} diff --git a/core/crates/solstone-core-sol/Cargo.toml b/core/crates/solstone-core-sol/Cargo.toml index 9db8f23b5..968bfda32 100644 --- a/core/crates/solstone-core-sol/Cargo.toml +++ b/core/crates/solstone-core-sol/Cargo.toml @@ -14,5 +14,8 @@ solstone-core-journal = { workspace = true } solstone-core-sol-client = { workspace = true } solstone-core-sol-client-cli = { workspace = true } +[target.'cfg(not(target_os = "ios"))'.dependencies] +solstone-core-sol-link = { workspace = true } + [lints] workspace = true diff --git a/core/crates/solstone-core-sol/src/bin/solstone-resident-fixture.rs b/core/crates/solstone-core-sol/src/bin/solstone-resident-fixture.rs index 520bd8392..b11c1babd 100644 --- a/core/crates/solstone-core-sol/src/bin/solstone-resident-fixture.rs +++ b/core/crates/solstone-core-sol/src/bin/solstone-resident-fixture.rs @@ -77,6 +77,7 @@ fn main() -> ExitCode { client_item_ids: None, notification_sink: None, link_pairing: None, + link_serve: None, journal_root: None, }, ) diff --git a/core/crates/solstone-core-sol/src/lib.rs b/core/crates/solstone-core-sol/src/lib.rs index 072f22990..98cbcdda0 100644 --- a/core/crates/solstone-core-sol/src/lib.rs +++ b/core/crates/solstone-core-sol/src/lib.rs @@ -26,12 +26,17 @@ use solstone_core_sol_client::seam::{ BuildIdentityProvider, ChatEventSource, ChatInput, ClientItemIdProvider, Clock, FileProvider, HttpTransport, NotificationSink, NotificationSinkError, ProcessOutput, ProcessSpawner, }; +#[cfg(target_os = "ios")] +use solstone_core_sol_client::seam::{LinkServeError, LinkServeErrorKind, LinkServeRunner}; use solstone_core_sol_client::sse::SseDecoder; use solstone_core_sol_client::transport::UreqHttpTransport; use solstone_core_sol_client_cli::{ - DispatchSeams, Outcome, dispatch_sol_call_with_seams, dispatch_sol_chat_with_seams, - dispatch_sol_import_with_seams, dispatch_sol_notify_with_seams, evaluate_args, help, + DispatchSeams, LinkDispatch, LinkDispatchSeams, Outcome, dispatch_sol_call_with_seams, + dispatch_sol_chat_with_seams, dispatch_sol_import_with_seams, dispatch_sol_link_with_seams, + dispatch_sol_notify_with_seams, evaluate_args, help, }; +#[cfg(not(target_os = "ios"))] +use solstone_core_sol_link::SplLinkServeRunner; mod generated; mod skills; @@ -92,6 +97,12 @@ fn run_with_stdin_provider( [command, rest @ ..] if command == OsStr::new("import") => { run_top_level_native(public_argv0, &args, "import", rest, stdin_provider) } + [command, rest @ ..] + if command == OsStr::new("link") + && rest.first().is_some_and(|verb| verb == OsStr::new("serve")) => + { + run_top_level_native(public_argv0, &args, "link", rest, stdin_provider) + } [command, rest @ ..] if command == OsStr::new("notify") => { run_top_level_native(public_argv0, &args, "notify", rest, stdin_provider) } @@ -489,6 +500,52 @@ fn run_dispatched( let chat_events = ChannelChatEventSource::default(); let notification_sink = UnixNotificationSink::new(journal.path.join("health/callosum.sock")); + if let Outcome::Link { .. } = outcome { + #[cfg(not(target_os = "ios"))] + let link_serve_runner = SplLinkServeRunner; + #[cfg(target_os = "ios")] + let link_serve_runner = UnavailableLinkServeRunner; + let dispatch = dispatch_sol_link_with_seams( + &args, + &env, + &stdin, + &today, + LinkDispatchSeams { + transport: &transport, + clock: Some(&clock), + files: Some(&files), + link_pairing: None, + link_serve: Some(&link_serve_runner), + journal_root: Some(&journal.path), + }, + ); + return match dispatch { + LinkDispatch::Buffered(output) => render_output(output), + LinkDispatch::Resident { + handler, + args: resident_args, + } => { + let context = CommandContext { + args: &resident_args, + env: &env, + stdin: &stdin, + today: &today, + transport: &transport, + clock: Some(&clock), + chat_events: None, + files: Some(&files), + build_identity: None, + client_item_ids: None, + notification_sink: None, + link_pairing: None, + link_serve: Some(&link_serve_runner), + journal_root: Some(&journal.path), + }; + run_resident_command(handler, context) + } + }; + } + let output = match outcome { Outcome::Migrated { .. } | Outcome::MovedStub { .. } => dispatch_sol_call_with_seams( &args, @@ -550,11 +607,26 @@ fn run_dispatched( notification_sink: Some(¬ification_sink), }, ), + Outcome::Link { .. } => unreachable!("link outcome handled before buffered dispatch"), Outcome::Unsupported { .. } => unsupported_output(), }; render_output(output) } +#[cfg(target_os = "ios")] +#[derive(Debug, Default)] +struct UnavailableLinkServeRunner; + +#[cfg(target_os = "ios")] +impl LinkServeRunner for UnavailableLinkServeRunner { + fn start( + &self, + _request: solstone_core_sol_client::seam::LinkServeRequest, + ) -> Result, LinkServeError> { + Err(LinkServeError::new(LinkServeErrorKind::RuntimeUnavailable)) + } +} + fn delegate_to_compat(public_argv0: &str, all_args: &[OsString]) -> ExitCode { let existing_sentinel = env::var_os(COMPAT_SENTINEL); if let Err(output) = compat_env_preflight(existing_sentinel.as_deref()) { diff --git a/core/crates/solstone-core-sol/tests/resident.rs b/core/crates/solstone-core-sol/tests/resident.rs index c3ec2321a..1c6480845 100644 --- a/core/crates/solstone-core-sol/tests/resident.rs +++ b/core/crates/solstone-core-sol/tests/resident.rs @@ -14,6 +14,7 @@ use std::time::{Duration, Instant}; use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; use solstone_core_sol_client::aggregate::{self, Handler}; +use solstone_core_sol_client::resident::ResidentHandler; fn bin() -> &'static str { env!("CARGO_BIN_EXE_solstone-resident-fixture") @@ -117,6 +118,7 @@ fn assert_graceful_shutdown_and_released_port(status: ExitStatus, port: u16) { } fn assert_buffered_handler_slice(_handlers: &'static [Handler]) {} +fn assert_resident_handler_slice(_handlers: &'static [ResidentHandler]) {} #[test] fn resident_startup_line_arrives_before_child_exit_unlike_buffered_output() { @@ -160,8 +162,13 @@ fn resident_fixture_is_absent_from_inventory_and_handlers_are_buffered() { } let handlers = aggregate::handler_bindings(); - assert_eq!(handlers.len(), aggregate::entries().len()); + let resident_handlers = aggregate::resident_handler_bindings(); + assert_eq!( + handlers.len() + resident_handlers.len(), + aggregate::entries().len() + ); // The helper's &'static [Handler] parameter is the compile-time assertion // that generated inventory bindings stay on the buffered handler lane. assert_buffered_handler_slice(handlers); + assert_resident_handler_slice(resident_handlers); } diff --git a/core/fixtures/native-sol/parity/link_serve.jsonl b/core/fixtures/native-sol/parity/link_serve.jsonl new file mode 100644 index 000000000..3f198c11f --- /dev/null +++ b/core/fixtures/native-sol/parity/link_serve.jsonl @@ -0,0 +1,3 @@ +{"id":"link-serve-help","surface":"sol-link","argv":["link","serve","--help"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"usage: sol link serve [-h] [--label LABEL] [--port PORT]\n [--relay-url RELAY_URL] [--direct]\n\noptions:\n -h, --help show this help message and exit\n --label LABEL Observer link bundle label\n --port PORT Loopback port to serve on (default: 5015)\n --relay-url RELAY_URL\n Override the spl relay URL\n --direct PL-direct only: dial the journal over the LAN secure\n listener, never the spl relay. Use when the home is\n reachable directly (same LAN/VPN) to avoid any relay\n dependency.\n","stderr":"","exit":0,"requests":[]},"normalizations":[]} +{"id":"link-serve-invalid-port","surface":"sol-link","argv":["link","serve","--port","0"],"env":{},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"","stderr":"--port must be between 1 and 65535\n","exit":2,"requests":[]},"normalizations":[]} +{"id":"link-serve-missing-labeled-bundle","surface":"sol-link","argv":["link","serve","--label","missing","--relay-url","https://relay.example","--direct"],"env":{"HOME":"/tmp/native-sol-parity-home"},"stdin":"","files":{},"clock":{"today":"20260723"},"transport":{"requests":[]},"expected":{"stdout":"","stderr":"invalid link bundle for label 'missing' at /tmp/native-sol-parity-home/.config/solstone-observer/spl/missing: PL bundle not found: /tmp/native-sol-parity-home/.config/solstone-observer/spl/missing. Run `sol link join` to pair this device.\n","exit":1,"requests":[]},"normalizations":[]} diff --git a/scripts/build_native_sol_inventory.py b/scripts/build_native_sol_inventory.py index 9fe959ea7..19dc6323f 100644 --- a/scripts/build_native_sol_inventory.py +++ b/scripts/build_native_sol_inventory.py @@ -52,7 +52,7 @@ FINAL_HTTP_TOTAL = 152 FINAL_JOURNAL_PYTHON_COMPAT_TOTAL = 23 FINAL_TOP_LEVEL_CHAT_TOTAL = 1 FINAL_TOP_LEVEL_IMPORT_TOTAL = 1 -FINAL_TOP_LEVEL_LINK_TOTAL = 1 +FINAL_TOP_LEVEL_LINK_TOTAL = 2 FINAL_TOP_LEVEL_NOTIFY_TOTAL = 1 FINAL_STUB_COUNTS = {"moved-stub": 2, "local": 1} FINAL_HTTP_GROUP_COUNTS = { @@ -93,6 +93,7 @@ class AuthorityEntry: route: str | None contract_operation_id: str | None handler: str + resident: bool def rust_string(value: str) -> str: @@ -184,6 +185,13 @@ def parse_entry( raise ValueError(f"{label}: handler {handler!r} is not a Rust identifier") if re.search(rf"\bpub\s+fn\s+{re.escape(handler)}\s*\(", source_text) is None: raise ValueError(f"{label}: handler {handler!r} is missing from {source}") + resident = raw_entry.get("resident", False) + if not isinstance(resident, bool): + raise ValueError(f"{label}: resident must be a boolean") + if resident and (surface == "sol-call" or kind != "top-level"): + raise ValueError( + f"{label}: resident entries must be non-sol-call top-level commands" + ) method = raw_entry.get("method") route = raw_entry.get("route") @@ -226,6 +234,7 @@ def parse_entry( route=route, contract_operation_id=contract_operation_id, handler=handler, + resident=resident, ) @@ -302,6 +311,7 @@ def render(entries: list[AuthorityEntry], output: Path) -> str: "// Copyright (c) 2026 sol pbc", "", "use crate::aggregate::{Handler, InventoryEntry};", + "use crate::resident::ResidentHandler;", "", ] seen_modules: set[str] = set() @@ -335,6 +345,7 @@ def render(entries: list[AuthorityEntry], output: Path) -> str: f" route: {rust_option(entry.route)},", f" contract_operation_id: {rust_option(entry.contract_operation_id)},", f" handler: {rust_string(entry.handler)},", + f" resident: {str(entry.resident).lower()},", " },", ] ) @@ -342,6 +353,15 @@ def render(entries: list[AuthorityEntry], output: Path) -> str: lines.append("") lines.append("pub const HANDLERS: &[Handler] = &[") for entry in entries: + if entry.resident: + continue + lines.append(f" {entry.module}::{entry.handler},") + lines.append("];") + lines.append("") + lines.append("pub const RESIDENT_HANDLERS: &[ResidentHandler] = &[") + for entry in entries: + if not entry.resident: + continue lines.append(f" {entry.module}::{entry.handler},") lines.append("];") lines.append("") diff --git a/solstone/apps/activities/native/command.rs b/solstone/apps/activities/native/command.rs index 8d0954351..ddab4a936 100644 --- a/solstone/apps/activities/native/command.rs +++ b/solstone/apps/activities/native/command.rs @@ -841,6 +841,7 @@ mod tests { client_item_ids: None, notification_sink: None, link_pairing: None, + link_serve: None, journal_root: None, }); diff --git a/solstone/apps/support/native/command.rs b/solstone/apps/support/native/command.rs index 0a1325862..b2e0d0f81 100644 --- a/solstone/apps/support/native/command.rs +++ b/solstone/apps/support/native/command.rs @@ -1328,6 +1328,7 @@ mod tests { client_item_ids: None, notification_sink: None, link_pairing: None, + link_serve: None, journal_root: None, }); diff --git a/solstone/think/native/chat/command.rs b/solstone/think/native/chat/command.rs index 6f30ba634..dfcc89357 100644 --- a/solstone/think/native/chat/command.rs +++ b/solstone/think/native/chat/command.rs @@ -708,6 +708,7 @@ mod tests { client_item_ids: None, notification_sink: None, link_pairing: None, + link_serve: None, journal_root: None, }); diff --git a/solstone/think/native/import/command.rs b/solstone/think/native/import/command.rs index 455b9a51e..b6a49b8c9 100644 --- a/solstone/think/native/import/command.rs +++ b/solstone/think/native/import/command.rs @@ -605,6 +605,7 @@ mod tests { client_item_ids: Some(client_item_ids), notification_sink: None, link_pairing: None, + link_serve: None, journal_root: None, }) } diff --git a/solstone/think/native/link/authority.toml b/solstone/think/native/link/authority.toml index 6818d6905..247e7426a 100644 --- a/solstone/think/native/link/authority.toml +++ b/solstone/think/native/link/authority.toml @@ -18,3 +18,19 @@ params = [ { name = "as_role", kind = "option", type = "text", required = false, nargs = 1, multiple = false, options = ["--as"], secondary = [], hidden = false, is_flag = false, count = false }, { name = "label", kind = "option", type = "text", required = false, nargs = 1, multiple = false, options = ["--label"], secondary = [], hidden = false, is_flag = false, count = false }, ] + +[[entries]] +surface = "sol-link" +path = ["link", "serve"] +kind = "top-level" +help = "serve a paired journal over the local link bridge" +operation_id = "link.serve" +entry_type = "top-level-link" +handler = "link_serve" +resident = true +params = [ + { name = "label", kind = "option", type = "text", required = false, nargs = 1, multiple = false, options = ["--label"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "port", kind = "option", type = "integer", required = false, nargs = 1, multiple = false, default = 5015, options = ["--port"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "relay_url", kind = "option", type = "text", required = false, nargs = 1, multiple = false, options = ["--relay-url"], secondary = [], hidden = false, is_flag = false, count = false }, + { name = "direct", kind = "option", type = "boolean", required = false, nargs = 1, multiple = false, default = false, options = ["--direct"], secondary = [], hidden = false, is_flag = true, count = false, flag_value = true }, +] diff --git a/solstone/think/native/link/command.rs b/solstone/think/native/link/command.rs index a0a9cd70f..2f22314fb 100644 --- a/solstone/think/native/link/command.rs +++ b/solstone/think/native/link/command.rs @@ -13,15 +13,21 @@ use spl_core::pairlink::{PairLinkError, ParsedPairLink}; use crate::command::{CommandContext, CommandOutput}; use crate::json_format::{json_compact_ascii, json_pretty_ascii}; +use crate::resident::ResidentCommand; use crate::seam::{ LinkJoinCredential, LinkJoinDirectRequest, LinkJoinPairTarget, LinkJoinPairingError, LinkJoinPairingErrorKind, LinkJoinRelayControlEndpoint, LinkJoinRelayErrorKind, - LinkJoinRelayRequest, + LinkJoinRelayRequest, LinkServeBundle, LinkServeEndpoint, LinkServeError, LinkServeErrorKind, + LinkServeRequest, LinkServeTransportErrorKind, }; const HELP: &str = "usage: sol link join [-h] [--home HOME] --code CODE [--as AS_ROLE]\n [--label LABEL]\n\noptions:\n -h, --help show this help message and exit\n --home HOME Receiver base URL\n --code CODE pair-link URL\n --as AS_ROLE Optional tag to join as\n --label LABEL Local credentials label (defaults to this machine's hostname)\n"; const USAGE: &str = "usage: sol link join [-h] [--home HOME] --code CODE [--as AS_ROLE]\n [--label LABEL]\n"; +const SERVE_HELP: &str = "usage: sol link serve [-h] [--label LABEL] [--port PORT]\n [--relay-url RELAY_URL] [--direct]\n\noptions:\n -h, --help show this help message and exit\n --label LABEL Observer link bundle label\n --port PORT Loopback port to serve on (default: 5015)\n --relay-url RELAY_URL\n Override the spl relay URL\n --direct PL-direct only: dial the journal over the LAN secure\n listener, never the spl relay. Use when the home is\n reachable directly (same LAN/VPN) to avoid any relay\n dependency.\n"; +const SERVE_USAGE: &str = "usage: sol link serve [-h] [--label LABEL] [--port PORT]\n [--relay-url RELAY_URL] [--direct]\n"; const DEFAULT_CLIENT_LABEL: &str = "linked-system"; +const DEFAULT_SERVE_PORT: u16 = 5015; +const DEFAULT_RELAY_URL: &str = "https://link.solstone.app"; const PAIR_LINK_PREFIX: &str = "https://go.solstone.app/p#"; const LOCAL_ENDPOINTS_MAX_BYTES: usize = 16 * 1024; const PEER_STATE_GUIDANCE: &str = "Peer join requires an initialized link identity. Run 'sol call link pair' on this journal first, then retry.\n"; @@ -204,6 +210,77 @@ pub fn link_join(ctx: CommandContext<'_>) -> CommandOutput { )) } +pub fn link_serve(ctx: CommandContext<'_>) -> Result, CommandOutput> { + let parsed = match parse_serve_args(ctx.args) { + Ok(parsed) => parsed, + Err(error) => return Err(argparse_serve_error(error)), + }; + if parsed.help { + return Err(CommandOutput::success(SERVE_HELP)); + } + if let Some(unknown) = parsed.unknown { + return Err(argparse_serve_error(format!( + "unrecognized arguments: {unknown}" + ))); + } + let port = match parsed.port { + Some(port) => port, + None => DEFAULT_SERVE_PORT, + }; + if port == 0 { + return Err(CommandOutput::failure( + "--port must be between 1 and 65535\n", + 2, + )); + } + let selection = match resolve_serve_bundle(parsed.label.as_deref(), ctx.env) { + Ok(selection) => selection, + Err(error) => return Err(CommandOutput::failure(format!("{error}\n"), 1)), + }; + let relay_origin = if parsed.direct { + None + } else { + Some(resolve_serve_relay_url( + parsed.relay_url.as_deref(), + ctx.env, + )) + }; + let Some(runner) = ctx.link_serve else { + return Err(CommandOutput::failure( + "Link serve seam is unavailable.\n", + 1, + )); + }; + let request = LinkServeRequest { + label: selection.label.clone(), + port, + direct: parsed.direct, + relay_origin, + bundle: selection.bundle, + }; + let session = match runner.start(request) { + Ok(session) => session, + Err(error) => { + return Err(CommandOutput::failure( + format!("{}\n", serve_error_text(error)), + 1, + )); + } + }; + let startup = format!( + "forwarding 127.0.0.1:{} -> home {} over pl\n", + session.bound_port(), + selection.label + ); + Ok(ResidentCommand::new( + startup, + move |shutdown| match session.serve(shutdown) { + Ok(()) => CommandOutput::success(""), + Err(error) => CommandOutput::failure(format!("{}\n", serve_error_text(error)), 1), + }, + )) +} + #[derive(Debug, Default, Clone, PartialEq, Eq)] struct ParsedArgs { home: Option, @@ -214,6 +291,21 @@ struct ParsedArgs { unknown: Option, } +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct ParsedServeArgs { + label: Option, + port: Option, + relay_url: Option, + direct: bool, + help: bool, + unknown: Option, +} + +struct ServeBundleSelection { + label: String, + bundle: LinkServeBundle, +} + fn parse_args(args: &[String]) -> Result { let mut parsed = ParsedArgs::default(); let mut index = 0; @@ -249,6 +341,48 @@ fn parse_args(args: &[String]) -> Result { Ok(parsed) } +fn parse_serve_args(args: &[String]) -> Result { + let mut parsed = ParsedServeArgs::default(); + let mut index = 0; + while index < args.len() { + let token = &args[index]; + if token == "-h" || token == "--help" { + parsed.help = true; + } else if let Some(value) = token.strip_prefix("--label=") { + parsed.label = Some(value.to_string()); + } else if token == "--label" { + index += 1; + parsed.label = Some(take_value(args, index, "--label")?.to_string()); + } else if let Some(value) = token.strip_prefix("--port=") { + parsed.port = Some(parse_serve_port(value)?); + } else if token == "--port" { + index += 1; + parsed.port = Some(parse_serve_port(take_raw_value(args, index, "--port")?)?); + } else if let Some(value) = token.strip_prefix("--relay-url=") { + parsed.relay_url = Some(value.to_string()); + } else if token == "--relay-url" { + index += 1; + parsed.relay_url = Some(take_value(args, index, "--relay-url")?.to_string()); + } else if token == "--direct" { + parsed.direct = true; + } else if parsed.unknown.is_none() { + parsed.unknown = Some(token.clone()); + } + index += 1; + } + Ok(parsed) +} + +fn parse_serve_port(value: &str) -> Result { + let parsed = value + .parse::() + .map_err(|_| format!("argument --port: invalid int value: '{value}'"))?; + if !(1..=65535).contains(&parsed) { + return Ok(0); + } + u16::try_from(parsed).map_err(|_| "--port must be between 1 and 65535".to_string()) +} + fn take_value<'a>(args: &'a [String], index: usize, option: &str) -> Result<&'a str, String> { let Some(value) = args.get(index).map(String::as_str) else { return Err(format!("argument {option}: expected one argument")); @@ -259,6 +393,207 @@ fn take_value<'a>(args: &'a [String], index: usize, option: &str) -> Result<&'a Ok(value) } +fn take_raw_value<'a>(args: &'a [String], index: usize, option: &str) -> Result<&'a str, String> { + let Some(value) = args.get(index).map(String::as_str) else { + return Err(format!("argument {option}: expected one argument")); + }; + Ok(value) +} + +fn argparse_serve_error(error: String) -> CommandOutput { + CommandOutput::failure(format!("{SERVE_USAGE}sol link serve: error: {error}\n"), 2) +} + +fn resolve_serve_bundle( + label: Option<&str>, + env: &BTreeMap, +) -> Result { + if let Some(label) = label { + let bundle_dir = observer_bundle_dir(label, env)?; + let bundle = load_serve_bundle(&bundle_dir).map_err(|error| { + format!( + "invalid link bundle for label '{label}' at {}: {error}. Run `sol link join` to pair this device.", + bundle_dir.display() + ) + })?; + return Ok(ServeBundleSelection { + label: label.to_string(), + bundle, + }); + } + + let root = observer_spl_root(env)?; + let mut bundles = BTreeMap::new(); + if root.is_dir() { + for entry in fs::read_dir(&root).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let label = entry.file_name().to_string_lossy().to_string(); + if let Ok(bundle) = load_serve_bundle(&path) { + bundles.insert(label, bundle); + } + } + } + if bundles.is_empty() { + return Err(format!( + "no observer link bundles found under {}. Run `sol link join` to pair this device.", + root.display() + )); + } + if bundles.len() > 1 { + let labels = bundles.keys().cloned().collect::>().join(", "); + return Err(format!( + "multiple observer link bundles found: {labels}. Pass --label to choose one." + )); + } + let (label, bundle) = bundles.into_iter().next().expect("bundle count checked"); + Ok(ServeBundleSelection { label, bundle }) +} + +fn observer_spl_root(env: &BTreeMap) -> Result { + let base = env + .get("XDG_CONFIG_HOME") + .filter(|value| !value.is_empty()) + .map_or_else( + || { + env.get("HOME") + .filter(|value| !value.is_empty()) + .map(|home| PathBuf::from(home).join(".config")) + }, + |xdg| Some(PathBuf::from(xdg)), + ); + let Some(base) = base else { + return Err("Could not resolve home directory for observer credentials.".to_string()); + }; + Ok(base.join("solstone-observer").join("spl")) +} + +fn load_serve_bundle(bundle_dir: &Path) -> Result { + if !bundle_dir.is_dir() { + return Err(format!("PL bundle not found: {}", bundle_dir.display())); + } + let missing = BUNDLE_FILES + .iter() + .filter(|name| !bundle_dir.join(name).exists()) + .map(|name| bundle_dir.join(name).display().to_string()) + .collect::>(); + if !missing.is_empty() { + return Err(format!("missing PL bundle file: {}", missing.join(", "))); + } + let private_key_pem = read_bundle_text(bundle_dir, "private.pem")?; + let client_cert_pem = read_bundle_text(bundle_dir, "cert.pem")?; + let chain_pem = read_bundle_text(bundle_dir, "chain.pem")?; + let ca_chain_pem = split_pem_certificates(&chain_pem)?; + let home_attestation = read_bundle_text(bundle_dir, "home_attestation.jwt")?; + let peer_text = read_bundle_text(bundle_dir, "peer.json")?; + let peer: Value = serde_json::from_str(&peer_text) + .map_err(|error| format!("invalid peer.json in {}: {error}", bundle_dir.display()))?; + let local_endpoints = match peer.get("local_endpoints") { + Some(Value::Null) | None => Value::Array(Vec::new()), + Some(Value::Array(_)) => peer + .get("local_endpoints") + .cloned() + .expect("local_endpoints presence checked"), + Some(_) => return Err("peer.json local_endpoints must be a list".to_string()), + }; + let endpoints = serve_endpoints_from_value(&local_endpoints)?; + Ok(LinkServeBundle { + private_key_pem, + client_cert_pem, + ca_chain_pem, + home_attestation, + instance_id: peer + .get("instance_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + home_label: peer + .get("home_label") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + endpoints, + local_endpoints, + }) +} + +fn read_bundle_text(bundle_dir: &Path, name: &str) -> Result { + fs::read_to_string(bundle_dir.join(name)).map_err(|error| error.to_string()) +} + +fn split_pem_certificates(chain_pem: &str) -> Result, String> { + const BEGIN: &str = "-----BEGIN CERTIFICATE-----"; + const END: &str = "-----END CERTIFICATE-----"; + let mut certs = Vec::new(); + let mut rest = chain_pem; + while let Some(begin) = rest.find(BEGIN) { + let after_begin = &rest[begin..]; + let Some(end) = after_begin.find(END) else { + return Err("chain.pem contains an incomplete certificate".to_string()); + }; + let end_index = end + END.len(); + let mut cert = after_begin[..end_index].to_string(); + cert.push('\n'); + certs.push(cert); + rest = &after_begin[end_index..]; + } + if certs.is_empty() { + return Err("chain.pem contains no certificates".to_string()); + } + Ok(certs) +} + +fn serve_endpoints_from_value(value: &Value) -> Result, String> { + let Value::Array(items) = value else { + return Err("peer.json local_endpoints must be a list".to_string()); + }; + let mut endpoints = Vec::new(); + for item in items { + let Value::Object(map) = item else { + return Err("peer.json local_endpoints entries must be objects".to_string()); + }; + let host = map + .get("ip") + .or_else(|| map.get("host")) + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_string(); + if host.is_empty() { + return Err("LAN endpoint missing ip".to_string()); + } + let port = match map.get("port") { + None | Some(Value::Null) => 7657, + Some(Value::Number(number)) => number + .as_u64() + .and_then(|value| u16::try_from(value).ok()) + .ok_or_else(|| "LAN endpoint has invalid port".to_string())?, + Some(Value::String(value)) => value + .parse::() + .map_err(|_| "LAN endpoint has invalid port".to_string())?, + Some(_) => return Err("LAN endpoint has invalid port".to_string()), + }; + endpoints.push(LinkServeEndpoint { host, port }); + } + Ok(endpoints) +} + +fn resolve_serve_relay_url(value: Option<&str>, env: &BTreeMap) -> String { + if let Some(value) = value.filter(|value| !value.trim().is_empty()) { + return value.trim().trim_end_matches('/').to_string(); + } + if let Some(value) = env + .get("SOL_LINK_RELAY_URL") + .filter(|value| !value.trim().is_empty()) + { + return value.trim().trim_end_matches('/').to_string(); + } + DEFAULT_RELAY_URL.to_string() +} + fn argparse_error(error: String) -> CommandOutput { CommandOutput::failure(format!("{USAGE}sol link join: error: {error}\n"), 2) } @@ -650,6 +985,121 @@ fn pairing_error_text(error: LinkJoinPairingError) -> String { } } +fn serve_error_text(error: LinkServeError) -> String { + match error.kind { + LinkServeErrorKind::InvalidBundle => { + "Link credentials are invalid. Run sol link join before sol link serve.".to_string() + } + LinkServeErrorKind::InvalidRelayUrl => { + "Relay URL is invalid. Check --relay-url and retry.".to_string() + } + LinkServeErrorKind::Bind { port, addr_in_use } => { + if addr_in_use { + format!( + "cannot bind 127.0.0.1:{port}: address already in use. Another `sol link serve` or Convey may already be using that port." + ) + } else { + format!("cannot bind 127.0.0.1:{port}: bind failed") + } + } + LinkServeErrorKind::RuntimeUnavailable => { + "Native link runtime is unavailable. Reinstall solstone-core and retry.".to_string() + } + LinkServeErrorKind::BridgeCapability => { + "Native link bridge setup failed before serving. Retry after reinstalling solstone-core." + .to_string() + } + LinkServeErrorKind::Shutdown => { + "Native link serve shutdown failed.".to_string() + } + LinkServeErrorKind::Transport(kind) => serve_transport_error_text(kind), + } +} + +fn serve_transport_error_text(kind: LinkServeTransportErrorKind) -> String { + match kind { + LinkServeTransportErrorKind::Io => { + "Link transport I/O failed while serving. Check that the journal is reachable on LAN/VPN or relay, then retry.".to_string() + } + LinkServeTransportErrorKind::Tls => { + "Secure link handshake failed. Re-run sol link join if the journal certificate or pairing changed.".to_string() + } + LinkServeTransportErrorKind::Crypto => { + "Link credential material is invalid. Re-run sol link join for this observer.".to_string() + } + LinkServeTransportErrorKind::Mux => { + "SPL stream framing failed while serving. Retry; re-pair if it continues.".to_string() + } + LinkServeTransportErrorKind::Http => { + "The journal response over the link could not be parsed. Update both peers or retry.".to_string() + } + LinkServeTransportErrorKind::Json => { + "Relay or bridge JSON could not be parsed. Check the relay URL and retry.".to_string() + } + LinkServeTransportErrorKind::PairLink => { + "Stored pairing data is invalid. Re-run sol link join for this observer.".to_string() + } + LinkServeTransportErrorKind::Pairing => { + "Link credential or relay enrollment failed. Re-run sol link join if retrying does not fix it.".to_string() + } + LinkServeTransportErrorKind::Rejected { status } => { + format!("The paired journal rejected the link request with HTTP {status}.") + } + LinkServeTransportErrorKind::Relay(error) => serve_relay_error_text(error).to_string(), + LinkServeTransportErrorKind::RelayControlRejected { endpoint, status } => { + match endpoint { + crate::seam::LinkServeRelayControlEndpoint::EnrollDevice => format!( + "Relay enrollment was rejected with HTTP {status}. Re-run sol link join if the bundle attestation is stale." + ), + crate::seam::LinkServeRelayControlEndpoint::TokenRefresh => format!( + "Relay token refresh was rejected with HTTP {status}. Re-run sol link join for this observer." + ), + } + } + LinkServeTransportErrorKind::NoEndpoint => { + "No journal endpoint is available. Re-run sol link join or pass --relay-url unless using --direct intentionally.".to_string() + } + LinkServeTransportErrorKind::NotPaired => { + "Link credentials are missing. Run sol link join before sol link serve.".to_string() + } + LinkServeTransportErrorKind::LocalOffset => { + "Local offset lookup failed. Check the system clock and retry.".to_string() + } + } +} + +fn serve_relay_error_text(error: crate::seam::LinkServeRelayErrorKind) -> &'static str { + match error { + crate::seam::LinkServeRelayErrorKind::HomeOffline => { + "The relay reports the home journal is offline. Start the journal or use --direct on LAN/VPN." + } + crate::seam::LinkServeRelayErrorKind::Unauthorized => { + "The relay rejected this observer token. Re-run sol link join for this observer." + } + crate::seam::LinkServeRelayErrorKind::Unpaid => { + "The relay account is not available. Check relay service/account status or use --direct." + } + crate::seam::LinkServeRelayErrorKind::UnknownInstance => { + "The relay does not know this journal instance. Re-run sol link join." + } + crate::seam::LinkServeRelayErrorKind::PairWindowClosed => { + "The relay pairing window is closed. Re-run sol link join from a fresh code." + } + crate::seam::LinkServeRelayErrorKind::Overflow => { + "The relay is temporarily overloaded. Retry or use --direct on LAN/VPN." + } + crate::seam::LinkServeRelayErrorKind::Abnormal => { + "The relay connection closed abnormally. Retry or use --direct on LAN/VPN." + } + crate::seam::LinkServeRelayErrorKind::UpgradeRejected => { + "The relay rejected the WebSocket upgrade. Check --relay-url and retry." + } + crate::seam::LinkServeRelayErrorKind::Stalled => { + "The relay connection stalled. Retry or use --direct on LAN/VPN." + } + } +} + fn relay_control_endpoint_code(endpoint: LinkJoinRelayControlEndpoint) -> &'static str { match endpoint { LinkJoinRelayControlEndpoint::EnrollDevice => "relay-control-enroll-device", @@ -878,8 +1328,11 @@ mod tests { use std::sync::Mutex; use std::sync::atomic::{AtomicU64, Ordering}; + use crate::resident::ShutdownSignal; use crate::seam::{ - ExpectedLinkJoinPairingCall, FakeClock, ScriptedHttpTransport, ScriptedLinkJoinPairingSeam, + ExpectedLinkJoinPairingCall, ExpectedLinkServeCall, ExpectedLinkServeSession, FakeClock, + LinkServeRelayControlEndpoint, LinkServeRelayErrorKind, ScriptedHttpTransport, + ScriptedLinkJoinPairingSeam, ScriptedLinkServeRunner, }; use serde_json::json; use spl_core::crockford; @@ -901,6 +1354,12 @@ mod tests { previous: nix::sys::stat::Mode, } + struct ImmediateShutdown; + + impl ShutdownSignal for ImmediateShutdown { + fn wait(&self) {} + } + #[cfg(unix)] impl UmaskGuard { fn set(mask: u32) -> Self { @@ -978,10 +1437,36 @@ mod tests { client_item_ids: None, notification_sink: None, link_pairing: Some(seam), + link_serve: None, journal_root: Some(journal_root), }) } + fn run_serve<'a>( + args: &[&str], + env: &'a BTreeMap, + runner: &'a ScriptedLinkServeRunner, + ) -> Result, CommandOutput> { + let argv = Box::leak(string_args(args).into_boxed_slice()); + let transport = Box::leak(Box::new(ScriptedHttpTransport::new(vec![]))); + link_serve(CommandContext { + args: argv, + env, + stdin: "", + today: "20260726", + transport, + clock: None, + chat_events: None, + files: None, + build_identity: None, + client_item_ids: None, + notification_sink: None, + link_pairing: None, + link_serve: Some(runner), + journal_root: None, + }) + } + fn credential(local_endpoints: Value) -> LinkJoinCredential { LinkJoinCredential { client_key_pem: "PRIVATE\n".to_string(), @@ -1013,6 +1498,52 @@ mod tests { ]) } + fn serve_bundle(config: &Path, label: &str, local_endpoints: Value) -> LinkServeBundle { + const CERT: &str = "-----BEGIN CERTIFICATE-----\nAA==\n-----END CERTIFICATE-----\n"; + let bundle_dir = config.join("solstone-observer").join("spl").join(label); + fs::create_dir_all(&bundle_dir).expect("serve bundle dir"); + fs::write(bundle_dir.join("private.pem"), "PRIVATE\n").expect("private key"); + fs::write(bundle_dir.join("cert.pem"), CERT).expect("client cert"); + fs::write(bundle_dir.join("chain.pem"), CERT).expect("chain"); + fs::write(bundle_dir.join("home_attestation.jwt"), "attestation.jwt").expect("attestation"); + fs::write( + bundle_dir.join("peer.json"), + json!({ + "instance_id": "home-instance", + "home_label": "Home", + "local_endpoints": local_endpoints.clone(), + }) + .to_string(), + ) + .expect("peer json"); + LinkServeBundle { + private_key_pem: "PRIVATE\n".to_string(), + client_cert_pem: CERT.to_string(), + ca_chain_pem: vec![CERT.to_string()], + home_attestation: "attestation.jwt".to_string(), + instance_id: "home-instance".to_string(), + home_label: "Home".to_string(), + endpoints: serve_endpoints_from_value(&local_endpoints).expect("serve endpoints"), + local_endpoints, + } + } + + fn expected_serve_request( + label: &str, + port: u16, + direct: bool, + relay_origin: Option<&str>, + bundle: LinkServeBundle, + ) -> LinkServeRequest { + LinkServeRequest { + label: label.to_string(), + port, + direct, + relay_origin: relay_origin.map(str::to_string), + bundle, + } + } + fn assert_bundle_files_exist(bundle: &Path) { for name in BUNDLE_FILES { assert!(bundle.join(name).is_file(), "{name}"); @@ -1104,6 +1635,283 @@ mod tests { seam.assert_done(); } + #[test] + fn serve_help_is_python_byte_exact() { + let env = BTreeMap::new(); + let runner = ScriptedLinkServeRunner::new(vec![]); + let output = match run_serve(&["--help"], &env, &runner) { + Err(output) => output, + Ok(_) => panic!("help must not enter resident serve"), + }; + assert_eq!(output, CommandOutput::success(SERVE_HELP)); + assert_eq!(SERVE_HELP.len(), 638); + assert_eq!(SERVE_USAGE.len(), 114); + runner.assert_done(); + } + + #[test] + fn serve_argv_errors_exit_two_before_starting() { + let cases = [ + ( + vec!["--unknown"], + format!("{SERVE_USAGE}sol link serve: error: unrecognized arguments: --unknown\n"), + ), + ( + vec!["--label"], + format!( + "{SERVE_USAGE}sol link serve: error: argument --label: expected one argument\n" + ), + ), + ( + vec!["--port", "abc"], + format!( + "{SERVE_USAGE}sol link serve: error: argument --port: invalid int value: 'abc'\n" + ), + ), + ( + vec!["--port", "0"], + "--port must be between 1 and 65535\n".to_string(), + ), + ]; + for (args, expected_stderr) in cases { + let env = BTreeMap::new(); + let runner = ScriptedLinkServeRunner::new(vec![]); + let output = match run_serve(&args, &env, &runner) { + Err(output) => output, + Ok(_) => panic!("argv error must not enter resident serve"), + }; + assert_eq!(output.stdout, ""); + assert_eq!(output.stderr, expected_stderr); + assert_eq!(output.exit, 2); + runner.assert_done(); + } + } + + #[test] + fn serve_bundle_resolution_names_sorted_labels_and_supports_single_default() { + let temp = temp_dir("serve-bundles"); + let config = temp.join("config"); + let env = base_env(&config, &temp.join("home")); + let alpha = serve_bundle( + &config, + "alpha", + json!([{"ip": "192.168.1.10", "port": 7657}]), + ); + let beta = serve_bundle( + &config, + "beta", + json!([{"ip": "192.168.1.10", "port": 7657}]), + ); + let runner = ScriptedLinkServeRunner::new(vec![]); + let ambiguous = match run_serve(&[], &env, &runner) { + Err(output) => output, + Ok(_) => panic!("ambiguous bundle must not enter resident serve"), + }; + assert_eq!( + ambiguous.stderr, + "multiple observer link bundles found: alpha, beta. Pass --label to choose one.\n" + ); + assert_eq!(ambiguous.exit, 1); + runner.assert_done(); + + let explicit_runner = ScriptedLinkServeRunner::new(vec![ExpectedLinkServeCall { + expected: expected_serve_request("beta", 5016, false, Some(DEFAULT_RELAY_URL), beta), + result: Ok(ExpectedLinkServeSession { + bound_port: 5016, + serve_result: Ok(()), + }), + }]); + let explicit = match run_serve( + &["--label", "beta", "--port", "5016"], + &env, + &explicit_runner, + ) { + Ok(resident) => resident, + Err(output) => panic!("explicit bundle failed before resident: {output:?}"), + }; + assert_eq!( + explicit.startup(), + "forwarding 127.0.0.1:5016 -> home beta over pl\n" + ); + assert_eq!( + explicit.serve(&ImmediateShutdown), + CommandOutput::success("") + ); + explicit_runner.assert_done(); + + fs::remove_dir_all(config.join("solstone-observer").join("spl").join("beta")) + .expect("remove beta"); + let default_runner = ScriptedLinkServeRunner::new(vec![ExpectedLinkServeCall { + expected: expected_serve_request( + "alpha", + DEFAULT_SERVE_PORT, + false, + Some(DEFAULT_RELAY_URL), + alpha, + ), + result: Ok(ExpectedLinkServeSession { + bound_port: DEFAULT_SERVE_PORT, + serve_result: Ok(()), + }), + }]); + let defaulted = match run_serve(&[], &env, &default_runner) { + Ok(resident) => resident, + Err(output) => panic!("single bundle failed before resident: {output:?}"), + }; + assert_eq!( + defaulted.startup(), + "forwarding 127.0.0.1:5015 -> home alpha over pl\n" + ); + default_runner.assert_done(); + } + + #[test] + fn serve_direct_omits_relay_even_with_poisoned_relay_inputs() { + let temp = temp_dir("serve-direct"); + let config = temp.join("config"); + let mut env = base_env(&config, &temp.join("home")); + env.insert( + "SOL_LINK_RELAY_URL".to_string(), + "https://poisoned.invalid".to_string(), + ); + let bundle = serve_bundle( + &config, + "direct", + json!([{"ip": "192.168.1.10", "port": 7657}]), + ); + let runner = ScriptedLinkServeRunner::new(vec![ExpectedLinkServeCall { + expected: expected_serve_request("direct", 6001, true, None, bundle), + result: Ok(ExpectedLinkServeSession { + bound_port: 6001, + serve_result: Ok(()), + }), + }]); + + let resident = match run_serve( + &[ + "--label", + "direct", + "--port", + "6001", + "--relay-url", + "https://also-poisoned.invalid", + "--direct", + ], + &env, + &runner, + ) { + Ok(resident) => resident, + Err(output) => panic!("direct serve failed before resident: {output:?}"), + }; + + assert_eq!( + resident.startup(), + "forwarding 127.0.0.1:6001 -> home direct over pl\n" + ); + assert_eq!(runner.recorded()[0].request.relay_origin, None); + runner.assert_done(); + } + + #[test] + fn serve_non_argv_failures_exit_one() { + let temp = temp_dir("serve-failures"); + let config = temp.join("config"); + let env = base_env(&config, &temp.join("home")); + let bundle = serve_bundle( + &config, + "laptop", + json!([{"ip": "192.168.1.10", "port": 7657}]), + ); + let bind_runner = ScriptedLinkServeRunner::new(vec![ExpectedLinkServeCall { + expected: expected_serve_request( + "laptop", + DEFAULT_SERVE_PORT, + false, + Some(DEFAULT_RELAY_URL), + bundle.clone(), + ), + result: Err(LinkServeError::new(LinkServeErrorKind::Bind { + port: DEFAULT_SERVE_PORT, + addr_in_use: true, + })), + }]); + let bind = match run_serve(&["--label", "laptop"], &env, &bind_runner) { + Err(output) => output, + Ok(_) => panic!("bind failure must not return resident"), + }; + assert_eq!(bind.exit, 1); + assert_eq!( + bind.stderr, + "cannot bind 127.0.0.1:5015: address already in use. Another `sol link serve` or Convey may already be using that port.\n" + ); + bind_runner.assert_done(); + + let enroll_runner = ScriptedLinkServeRunner::new(vec![ExpectedLinkServeCall { + expected: expected_serve_request( + "laptop", + DEFAULT_SERVE_PORT, + false, + Some(DEFAULT_RELAY_URL), + bundle, + ), + result: Err(LinkServeError::new(LinkServeErrorKind::Transport( + LinkServeTransportErrorKind::RelayControlRejected { + endpoint: LinkServeRelayControlEndpoint::EnrollDevice, + status: 401, + }, + ))), + }]); + let enrollment = match run_serve(&["--label", "laptop"], &env, &enroll_runner) { + Err(output) => output, + Ok(_) => panic!("enrollment failure must not return resident"), + }; + assert_eq!(enrollment.exit, 1); + assert_eq!( + enrollment.stderr, + "Relay enrollment was rejected with HTTP 401. Re-run sol link join if the bundle attestation is stale.\n" + ); + enroll_runner.assert_done(); + } + + #[test] + fn serve_transport_error_text_covers_every_variant_without_secret_leaks() { + let kinds = [ + LinkServeTransportErrorKind::Io, + LinkServeTransportErrorKind::Tls, + LinkServeTransportErrorKind::Crypto, + LinkServeTransportErrorKind::Mux, + LinkServeTransportErrorKind::Http, + LinkServeTransportErrorKind::Json, + LinkServeTransportErrorKind::PairLink, + LinkServeTransportErrorKind::Pairing, + LinkServeTransportErrorKind::Rejected { status: 403 }, + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::HomeOffline), + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::Unauthorized), + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::Unpaid), + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::UnknownInstance), + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::PairWindowClosed), + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::Overflow), + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::Abnormal), + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::UpgradeRejected), + LinkServeTransportErrorKind::Relay(LinkServeRelayErrorKind::Stalled), + LinkServeTransportErrorKind::RelayControlRejected { + endpoint: LinkServeRelayControlEndpoint::EnrollDevice, + status: 401, + }, + LinkServeTransportErrorKind::RelayControlRejected { + endpoint: LinkServeRelayControlEndpoint::TokenRefresh, + status: 401, + }, + LinkServeTransportErrorKind::NoEndpoint, + LinkServeTransportErrorKind::NotPaired, + LinkServeTransportErrorKind::LocalOffset, + ]; + for kind in kinds { + let text = serve_transport_error_text(kind); + assert_no_secret_substrings(&text); + } + } + #[test] fn missing_code_exits_like_argparse() { let env = BTreeMap::new(); diff --git a/solstone/think/native/notify/command.rs b/solstone/think/native/notify/command.rs index e440da619..7a4d25ff4 100644 --- a/solstone/think/native/notify/command.rs +++ b/solstone/think/native/notify/command.rs @@ -274,6 +274,7 @@ mod tests { client_item_ids: None, notification_sink: sink.map(|sink| sink as &dyn crate::seam::NotificationSink), link_pairing: None, + link_serve: None, journal_root: None, }) } diff --git a/solstone/think/tools/native/health/command.rs b/solstone/think/tools/native/health/command.rs index 9ed2cee5d..585102935 100644 --- a/solstone/think/tools/native/health/command.rs +++ b/solstone/think/tools/native/health/command.rs @@ -449,6 +449,7 @@ mod tests { client_item_ids: None, notification_sink: None, link_pairing: None, + link_serve: None, journal_root: None, }); @@ -554,6 +555,7 @@ mod tests { client_item_ids: None, notification_sink: None, link_pairing: None, + link_serve: None, journal_root: None, }); diff --git a/tests/test_native_sol_conformance.py b/tests/test_native_sol_conformance.py index d4e56a8da..e1683aeac 100644 --- a/tests/test_native_sol_conformance.py +++ b/tests/test_native_sol_conformance.py @@ -39,6 +39,7 @@ def test_native_sol_conformance_self_test_detects_authority_route_mismatch() -> route="/app/activities/api/day/{day}/wrong", contract_operation_id="activities.list", handler="list", + resident=False, ) ] diff --git a/tests/test_native_sol_inventory.py b/tests/test_native_sol_inventory.py index b89355e38..1213bacd5 100644 --- a/tests/test_native_sol_inventory.py +++ b/tests/test_native_sol_inventory.py @@ -54,6 +54,61 @@ def test_inventory_discovery_uses_real_adjacency_without_central_list( assert "_private" not in rendered +def test_resident_entries_generate_resident_handlers(tmp_path: Path) -> None: + native = tmp_path / "solstone" / "think" / "native" / "link" + native.mkdir(parents=True) + (native / "command.rs").write_text( + "// SPDX-License-Identifier: AGPL-3.0-only\n" + "// Copyright (c) 2026 sol pbc\n\n" + "pub fn link_join() {}\n" + "pub fn link_serve() {}\n" + ) + (native / "authority.toml").write_text( + 'schema = "native-sol-authority-v1"\n' + 'source = "command.rs"\n\n' + "[[entries]]\n" + 'surface = "sol-link"\n' + 'path = ["link", "join"]\n' + 'kind = "top-level"\n' + 'help = "join"\n' + "params = []\n" + 'operation_id = "link.join"\n' + 'entry_type = "top-level-link"\n' + 'handler = "link_join"\n\n' + "[[entries]]\n" + 'surface = "sol-link"\n' + 'path = ["link", "serve"]\n' + 'kind = "top-level"\n' + 'help = "serve"\n' + "params = []\n" + 'operation_id = "link.serve"\n' + 'entry_type = "top-level-link"\n' + 'handler = "link_serve"\n' + "resident = true\n" + ) + + entries = inventory.discover(tmp_path) + rendered = inventory.render( + entries, + tmp_path + / "core" + / "crates" + / "solstone-core-sol-client" + / "src" + / "generated" + / "inventory.rs", + ) + + assert [entry.resident for entry in entries] == [False, True] + assert "use crate::resident::ResidentHandler;" in rendered + assert "pub const HANDLERS: &[Handler] = &[" in rendered + assert "pub const RESIDENT_HANDLERS: &[ResidentHandler] = &[" in rendered + assert "solstone_think_native_link_command_rs::link_join," in rendered + assert "solstone_think_native_link_command_rs::link_serve," in rendered + assert "resident: false," in rendered + assert "resident: true," in rendered + + def entry( tmp_path: Path, *, @@ -66,6 +121,7 @@ def entry( method: str | None = None, route: str | None = None, contract_operation_id: str | None = None, + resident: bool = False, ) -> inventory.AuthorityEntry: return inventory.AuthorityEntry( authority=tmp_path / authority_name, @@ -83,6 +139,7 @@ def entry( route=route, contract_operation_id=contract_operation_id, handler="fixture_handler", + resident=resident, )