From 47d16eb71416f4fe7f36f2d4e5e4e610c0cd643c Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 18 Jul 2026 17:21:12 +0200 Subject: [PATCH] Add offline resource capability discovery --- README.md | 1 + crates/geth-cli/src/lib.rs | 121 ++++++++++++++++++++- crates/geth-node/src/lib.rs | 2 +- crates/geth-node/src/resource_contracts.rs | 72 ++++++++++-- docs/roadmap.md | 10 ++ docs/user-workflows.md | 6 + 6 files changed, 200 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2121efe..1b27356 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,7 @@ The bootstrap implementation provides: Wintun guidance. - `geth resource list` - `geth resource create ` +- `geth resource capabilities [family]` - `geth keychain init [--admin-key ] [--signing-key ]` - `geth keychain status` - `geth keychain admin-add --admin-key --signing-key [--principal ]` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 7de1d51..779023f 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -483,7 +483,7 @@ pub enum Command { #[command(subcommand)] command: OverlayCommand, }, - /// List and create resource registrations + /// List/create resources and discover authorization capabilities Resource { #[command(subcommand)] command: ResourceCommand, @@ -974,6 +974,15 @@ pub enum ResourceCommand { List, /// Register a named resource Create { kind: String, name: String }, + /// List resource ID patterns and the capabilities they accept + #[command( + alias = "catalog", + after_help = "Examples:\n geth resource capabilities\n geth resource capabilities kv\n geth resource capabilities --json" + )] + Capabilities { + #[arg(help = "Optional resource family to describe, such as kv, ssh, or pipe")] + family: Option, + }, } #[derive(Debug, Subcommand)] @@ -1831,6 +1840,13 @@ async fn run_inner(cli: Cli) -> Result<()> { print_completions(*shell); return Ok(()); } + if let Command::Resource { + command: ResourceCommand::Capabilities { family }, + } = &cli.command + { + print_resource_capabilities(family.as_deref(), output)?; + return Ok(()); + } if cli.home.is_some() && matches!( &cli.command, @@ -2292,6 +2308,82 @@ fn print_guide(topic: Option, output: OutputMode) -> Result<()> { Ok(()) } +fn resource_capability_contracts( + family: Option<&str>, +) -> Result> { + let contracts = geth_node::resource_contracts::contracts(); + let Some(family) = family else { + return Ok(contracts.iter().collect()); + }; + let canonical_family = match family { + "cas" | "file-root" => "cas-file-root", + "ssh-proxy" => "ssh", + other => other, + }; + let selected = contracts + .iter() + .filter(|contract| contract.family == canonical_family) + .collect::>(); + if selected.is_empty() { + let available = contracts + .iter() + .map(|contract| contract.family) + .collect::>() + .join(", "); + bail!( + "invalid resource family {family:?}; available families: {available}\nnext: run `geth resource capabilities` to inspect the complete catalog" + ); + } + Ok(selected) +} + +fn print_resource_capabilities(family: Option<&str>, output: OutputMode) -> Result<()> { + let contracts = resource_capability_contracts(family)?; + if output.is_machine() { + let families = contracts + .iter() + .map(|contract| { + serde_json::json!({ + "family": contract.family, + "resource_id_patterns": contract.resource_ids, + "capabilities": contract.capabilities, + "operations_and_host_effects": contract.mutation_points, + }) + }) + .collect::>(); + output.print(&serde_json::json!({ + "type": "resource-capabilities", + "families": families, + "grant_example": "geth auth grant --signing-key ", + "note": "replace angle-bracket placeholders; discovery does not grant access", + }))?; + return Ok(()); + } + + for (index, contract) in contracts.iter().enumerate() { + if index > 0 { + println!(); + } + println!("{}", contract.family); + println!(" resource IDs:"); + for resource_id in contract.resource_ids { + println!(" {resource_id}"); + } + println!(" capabilities:"); + for capability in contract.capabilities { + println!(" {capability}"); + } + println!(" operations and host effects:"); + for point in contract.mutation_points { + println!(" - {point}"); + } + } + println!(); + println!("grant: geth auth grant --signing-key "); + println!("note: replace angle-bracket placeholders; discovery does not grant access"); + Ok(()) +} + fn print_completions(shell: Shell) { let mut command = documented_cli_command(); generate(shell, &mut command, "geth", &mut stdout()); @@ -2500,6 +2592,9 @@ fn request_for_command(command: Command) -> Result { Command::Resource { command: ResourceCommand::Create { kind, name }, } => ControlRequest::ResourceCreate { kind, name }, + Command::Resource { + command: ResourceCommand::Capabilities { .. }, + } => bail!("resource capability discovery is handled directly by the CLI"), Command::Keychain { command: KeychainCommand::Init { @@ -5487,6 +5582,30 @@ mod tests { assert!(Cli::try_parse_from(["geth", "status", "--json", "--jsonl"]).is_err()); } + #[test] + fn resource_capabilities_are_available_without_daemon_state() { + let all = resource_capability_contracts(None).expect("complete catalog"); + assert!(all.len() >= 8); + let kv = resource_capability_contracts(Some("kv")).expect("kv catalog"); + assert_eq!(kv.len(), 1); + assert_eq!(kv[0].resource_ids, &["resource:kv:"]); + assert!(kv[0].capabilities.contains(&"kv.write_prefix:")); + assert_eq!( + resource_capability_contracts(Some("cas")).expect("cas alias")[0].family, + "cas-file-root" + ); + assert!(resource_capability_contracts(Some("unknown")).is_err()); + + let parsed = Cli::try_parse_from(["geth", "resource", "capabilities", "ssh"]) + .expect("parse capability discovery"); + assert!(matches!( + parsed.command, + Command::Resource { + command: ResourceCommand::Capabilities { family: Some(family) } + } if family == "ssh" + )); + } + #[tokio::test] async fn ephemeral_daemon_rejects_an_explicit_persistent_home() { let parsed = Cli::try_parse_from([ diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 5ef848b..047a3ce 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -5,7 +5,7 @@ mod local_control; mod local_transport; mod peer_client; mod peer_control; -mod resource_contracts; +pub mod resource_contracts; mod runtime; pub mod service; mod sync; diff --git a/crates/geth-node/src/resource_contracts.rs b/crates/geth-node/src/resource_contracts.rs index 3f8fd52..c3ecae6 100644 --- a/crates/geth-node/src/resource_contracts.rs +++ b/crates/geth-node/src/resource_contracts.rs @@ -1,20 +1,20 @@ //! Reviewable resource-family contracts for daemon handlers. //! //! These contracts are intentionally small and static. They make every -//! resource family name its resource ID pattern, remote capabilities, and +//! resource family name its resource ID pattern, resource-scoped capabilities, and //! mutation or host-access points in one place while the implementation //! continues to live in the focused handler functions in `lib.rs` and //! `local_control.rs`. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) struct ResourceModuleContract { - pub(crate) family: &'static str, - pub(crate) resource_ids: &'static [&'static str], - pub(crate) capabilities: &'static [&'static str], - pub(crate) mutation_points: &'static [&'static str], +pub struct ResourceModuleContract { + pub family: &'static str, + pub resource_ids: &'static [&'static str], + pub capabilities: &'static [&'static str], + pub mutation_points: &'static [&'static str], } -pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[ +pub const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[ ResourceModuleContract { family: "cas-file-root", resource_ids: &["resource:cas:local", "resource:cas-tree:"], @@ -29,7 +29,12 @@ pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[ ResourceModuleContract { family: "kv", resource_ids: &["resource:kv:"], - capabilities: &["kv.read", "kv.write_prefix:", "kv.write_key:"], + capabilities: &[ + "kv.read", + "kv.write", + "kv.write_prefix:", + "kv.write_key:", + ], mutation_points: &[ "local SQLite-backed KV create/set", "authorized sync imports newer or equal timestamp entries idempotently", @@ -87,7 +92,14 @@ pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[ "resource:ssh-proxy:local", ], capabilities: &[ + "ssh_cert.request", + "ssh_cert.read", + "ssh_cert.approve", + "ssh_cert.import", "ssh_cert.sync", + "ssh_revocation.publish", + "ssh_revocation.read", + "ssh_revocation.import", "ssh_revocation.sync", "ssh_proxy.connect", "ssh_proxy.admin_shell", @@ -102,7 +114,7 @@ pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[ ResourceModuleContract { family: "overlay", resource_ids: &["resource:overlay:"], - capabilities: &["overlay.route"], + capabilities: &["overlay.join", "overlay.route"], mutation_points: &[ "local overlay network membership metadata", "daemon-lifetime packet queue and optional TUN/Wintun injection", @@ -111,7 +123,8 @@ pub(crate) const RESOURCE_MODULE_CONTRACTS: &[ResourceModuleContract] = &[ }, ]; -pub(crate) fn contracts() -> &'static [ResourceModuleContract] { +#[must_use] +pub fn contracts() -> &'static [ResourceModuleContract] { RESOURCE_MODULE_CONTRACTS } @@ -160,4 +173,43 @@ mod tests { ); } } + + #[test] + fn catalog_includes_operator_facing_grant_vocabulary() { + let capabilities = RESOURCE_MODULE_CONTRACTS + .iter() + .flat_map(|contract| contract.capabilities.iter().copied()) + .collect::>(); + + for expected in [ + "cas.fetch", + "kv.read", + "kv.write", + "db.sync", + "document.read", + "pubsub.publish", + "pubsub.subscribe", + "pipe.connect", + "pipe.listen", + "pipe.forward", + "ssh_cert.request", + "ssh_cert.read", + "ssh_cert.approve", + "ssh_cert.import", + "ssh_cert.sync", + "ssh_revocation.publish", + "ssh_revocation.read", + "ssh_revocation.import", + "ssh_revocation.sync", + "ssh_proxy.connect", + "ssh_proxy.admin_shell", + "overlay.join", + "overlay.route", + ] { + assert!( + capabilities.contains(expected), + "missing capability {expected}" + ); + } + } } diff --git a/docs/roadmap.md b/docs/roadmap.md index 4c86145..081f850 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -132,6 +132,16 @@ For deployment-readiness work that cuts across feature areas, see - `[x]` Tests and automation documentation define the single-document and line-oriented contracts. +- `[x]` Make resource and capability vocabulary discoverable offline. + Acceptance criteria: + - `[x]` `geth resource capabilities [family]` lists exact resource ID + patterns, capability strings, and important operations or host effects. + - `[x]` Discovery works before initialization and without a running daemon, + and explicitly states that viewing the catalog does not grant access. + - `[x]` Human, JSON, and JSONL forms include a copy-paste grant template. + - `[x]` Tests cover the full catalog, family filtering, common aliases, and + unknown-family recovery. + - `[ ]` Publish copy-paste installation entrypoints for release artifacts. Acceptance criteria: - `[ ]` Linux, macOS, and Windows installation instructions verify artifact diff --git a/docs/user-workflows.md b/docs/user-workflows.md index 085d48d..4b36aa7 100644 --- a/docs/user-workflows.md +++ b/docs/user-workflows.md @@ -172,6 +172,7 @@ authorized node fetch it over Iroh. On the provider: ```sh +geth resource capabilities cas geth cas add ./archive.tar geth node grant workstation resource:cas:local cas.fetch \ --signing-key ~/.ssh/id_ed25519_sk @@ -197,6 +198,7 @@ building transport, peer authentication, and retry handling myself. Start locally with one of: ```sh +geth resource capabilities kv geth kv create preferences geth kv set preferences theme dark @@ -215,6 +217,10 @@ per-peer/per-stream cursors and retry state. File-root application is conservative: it does not overwrite local edits, and ambiguous changes become durable conflicts for `geth cas conflict list`. +`geth resource capabilities [family]` is an offline catalog of exact resource +ID patterns, grant capability strings, and operations or host effects. It does +not require a running daemon and discovery does not create a grant. + ## Automate Reliably User story: as an automation author, I want explicit homes, readiness checks,