use std::path::Path;
use std::path::PathBuf;
use clap::Args;
use net_mcp::spec::Implementation;
use net_mcp::wrap::{
CredentialOverride, DelegationAudit, DelegationGate, ServerPublisher, Substitutability,
WrapConfig,
};
use net_sdk::delegation::RevocationRegistry;
use net_sdk::identity::EntityId;
use tokio::sync::broadcast;
use crate::commands::aggregator::RemoteAttachArgs;
use crate::context::{
build_attached_mesh, load_operator_identity, require_remote_attach, resolve_profile,
};
use crate::error::{generic, invalid_args, sdk, CliError};
use crate::output::{emit_stream_row, OutputFormat};
use crate::parsers::parse_u64_flexible;
#[derive(serde::Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
enum WrapEvent<'a> {
Wrapped {
name: &'a str,
tools: &'a [String],
skipped: &'a [String],
visibility: &'a str,
scope: &'a str,
allowed_origins: &'a [u64],
#[serde(skip_serializing_if = "Option::is_none")]
delegation_root: Option<&'a str>,
},
ToolsChanged {
added: Vec<String>,
removed: Vec<String>,
},
ServerExited,
}
#[derive(Args, Debug)]
pub struct WrapArgs {
pub name: String,
#[arg(long, conflicts_with = "no_credentials")]
pub credentialed: bool,
#[arg(long)]
pub no_credentials: bool,
#[arg(long)]
pub force: bool,
#[arg(long)]
pub substitutable: bool,
#[arg(long = "env", value_name = "KEY=VALUE")]
pub env: Vec<String>,
#[arg(long = "allow", value_name = "ORIGIN")]
pub allow: Vec<String>,
#[arg(long = "owner-root", value_name = "ENTITY_ID_HEX")]
pub owner_root: Option<String>,
#[arg(long = "revocation-store", value_name = "PATH")]
pub revocation_store: Option<PathBuf>,
#[arg(long)]
pub identity: Option<PathBuf>,
#[command(flatten)]
pub remote: RemoteAttachArgs,
#[arg(last = true, required = true, value_name = "COMMAND")]
pub command: Vec<String>,
}
pub async fn run(
args: WrapArgs,
output: Option<OutputFormat>,
config_path: Option<&Path>,
profile_name: &str,
) -> Result<(), CliError> {
let profile = resolve_profile(config_path, profile_name).await?;
let remote = require_remote_attach(&profile, &args.remote, || {
invalid_args(
"net-mesh wrap needs a mesh peer to join. Pass \
--node-addr/--node-pubkey/--node-id/--psk-hex (or set them in your \
profile) pointing at a running mesh node.",
)
})?;
let identity_path = args
.identity
.as_deref()
.or(profile.identity.as_deref())
.ok_or_else(|| {
invalid_args(
"net-mesh wrap needs an operator identity: pass --identity <PATH> or set \
`identity = \"...\"` in your profile. Owner-only scoping keys on it, \
so an ephemeral key would admit nobody.",
)
})?;
let identity = load_operator_identity(identity_path).await?;
let mesh =
std::sync::Arc::new(build_attached_mesh("0.0.0.0:0", Some(identity), &remote).await?);
let (program, prog_args) = args
.command
.split_first()
.ok_or_else(|| invalid_args("the wrapped command after `--` is empty"))?;
let envs = parse_env_pairs(&args.env)?;
let allow = parse_allow_origins(&args.allow)?;
let mut config = WrapConfig::owner_only(
Implementation {
name: format!("net-wrap/{}", args.name),
version: env!("CARGO_PKG_VERSION").to_string(),
},
mesh.origin_hash(),
);
config.credential_override =
resolve_credential_override(args.credentialed, args.no_credentials);
config.force = args.force;
config.substitutability = if args.substitutable {
Substitutability::ProviderEquivalent
} else {
Substitutability::ProviderLocal
};
for &origin in &allow {
config.scope.allow(origin);
}
if let Some(owner_root_hex) = &args.owner_root {
let owner_root = parse_owner_root(owner_root_hex)?;
let mut gate =
DelegationGate::new(owner_root, std::sync::Arc::new(RevocationRegistry::new()))
.with_audit(std::sync::Arc::new(|a: &DelegationAudit| {
eprintln!(
"net-mesh wrap: delegated invoke admitted — tool={} leaf={} root={}",
a.tool,
hex::encode(a.leaf.as_bytes()),
hex::encode(a.root.as_bytes()),
);
}));
let rev_path = args
.revocation_store
.clone()
.or_else(net_sdk::revocation::default_revocation_store_path);
if let Some(p) = &rev_path {
gate = gate.with_revocation_store(p.clone());
}
config.delegation = Some(std::sync::Arc::new(gate));
eprintln!(
"net-mesh wrap: delegation gate enabled (owner root {owner_root_hex}); \
chain-rooted callers are verified + audited{}",
rev_path
.map(|p| format!("; revocations honored from {}", p.display()))
.unwrap_or_default()
);
}
let publisher = ServerPublisher::new(std::sync::Arc::clone(&mesh));
let mut publication = publisher
.publish_server(program, prog_args, &envs, config)
.await
.map_err(|e| sdk(format!("wrap failed: {e}")))?;
let fmt = OutputFormat::resolve_stream(output);
emit_stream_row(
fmt,
&WrapEvent::Wrapped {
name: &args.name,
tools: publication.tools(),
skipped: publication.skipped_tools(),
visibility: "owner_only",
scope: "same_root_identity",
allowed_origins: &allow,
delegation_root: args.owner_root.as_deref(),
},
)
.map_err(|e| generic(format!("write output: {e}")))?;
let mut changed = publication.client().subscribe_list_changed();
let client = std::sync::Arc::clone(publication.client());
let server_exited = loop {
tokio::select! {
_ = tokio::signal::ctrl_c() => break false,
_ = client.closed() => break true,
recv = changed.recv() => match recv {
Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {
match publication.refresh().await {
Ok(delta) if !delta.is_empty() => {
let _ = emit_stream_row(
fmt,
&WrapEvent::ToolsChanged {
added: delta.added,
removed: delta.removed,
},
);
}
Ok(_) => {}
Err(e) => eprintln!("refresh failed: {e}"),
}
}
Err(broadcast::error::RecvError::Closed) => break false,
},
}
};
if server_exited {
if let Err(e) = publication.withdraw().await {
eprintln!("withdrawing capabilities on server exit failed: {e}");
}
let _ = emit_stream_row(fmt, &WrapEvent::ServerExited);
} else {
drop(publication);
}
drop(publisher);
match std::sync::Arc::try_unwrap(mesh) {
Ok(mesh) => {
mesh.shutdown().await.ok();
}
Err(_) => {
eprintln!("note: mesh still has other references at exit; skipping graceful shutdown");
}
}
Ok(())
}
fn parse_env_pairs(raw: &[String]) -> Result<Vec<(String, String)>, CliError> {
raw.iter()
.map(|kv| {
kv.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
.ok_or_else(|| invalid_args(format!("--env {kv:?} must be KEY=VALUE")))
})
.collect()
}
fn parse_allow_origins(raw: &[String]) -> Result<Vec<u64>, CliError> {
raw.iter()
.map(|s| parse_u64_flexible(s).map_err(|e| invalid_args(format!("--allow {s:?}: {e}"))))
.collect()
}
fn parse_owner_root(raw: &str) -> Result<EntityId, CliError> {
let trimmed = raw
.strip_prefix("0x")
.or_else(|| raw.strip_prefix("0X"))
.unwrap_or(raw);
let bytes = hex::decode(trimmed)
.map_err(|e| invalid_args(format!("--owner-root: invalid hex: {e}")))?;
let arr: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
invalid_args(format!(
"--owner-root must be 32 bytes (64 hex chars), got {}",
bytes.len()
))
})?;
Ok(EntityId::from_bytes(arr))
}
fn resolve_credential_override(credentialed: bool, no_credentials: bool) -> CredentialOverride {
if credentialed {
CredentialOverride::Credentialed
} else if no_credentials {
CredentialOverride::NoCredentials
} else {
CredentialOverride::Detect
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_pairs_parse_and_reject_missing_equals() {
let ok = parse_env_pairs(&["A=1".to_string(), "B=x=y".to_string()]).unwrap();
assert_eq!(
ok,
vec![("A".into(), "1".into()), ("B".into(), "x=y".into())]
);
assert!(parse_env_pairs(&["nope".to_string()]).is_err());
}
#[test]
fn owner_root_parses_64_hex_and_rejects_bad_input() {
let id = net_sdk::Identity::generate();
let hexed = hex::encode(id.entity_id().as_bytes());
assert_eq!(
parse_owner_root(&hexed).unwrap().as_bytes(),
id.entity_id().as_bytes()
);
assert_eq!(
parse_owner_root(&format!("0x{hexed}")).unwrap().as_bytes(),
id.entity_id().as_bytes()
);
assert_eq!(
parse_owner_root(&format!("0X{hexed}")).unwrap().as_bytes(),
id.entity_id().as_bytes()
);
assert!(parse_owner_root("deadbeef").is_err());
assert!(parse_owner_root(&"zz".repeat(32)).is_err());
}
#[test]
fn allow_origins_parse_decimal_and_hex() {
let got = parse_allow_origins(&["7".to_string(), "0x2a".to_string()]).unwrap();
assert_eq!(got, vec![7, 42]);
assert!(parse_allow_origins(&["nan".to_string()]).is_err());
}
#[test]
fn credential_override_precedence() {
assert_eq!(
resolve_credential_override(true, false),
CredentialOverride::Credentialed
);
assert_eq!(
resolve_credential_override(false, true),
CredentialOverride::NoCredentials
);
assert_eq!(
resolve_credential_override(false, false),
CredentialOverride::Detect
);
}
fn wrapped_event(allow: &[u64]) -> serde_json::Value {
let tools = vec!["echo".to_string()];
let skipped: Vec<String> = Vec::new();
serde_json::to_value(WrapEvent::Wrapped {
name: "gh",
tools: &tools,
skipped: &skipped,
visibility: "owner_only",
scope: "same_root_identity",
allowed_origins: allow,
delegation_root: None,
})
.unwrap()
}
#[test]
fn wrapped_event_reports_the_delegation_root_when_a_gate_is_enabled() {
let tools = vec!["echo".to_string()];
let skipped: Vec<String> = Vec::new();
let root_hex = "aa".repeat(32);
let v = serde_json::to_value(WrapEvent::Wrapped {
name: "gh",
tools: &tools,
skipped: &skipped,
visibility: "owner_only",
scope: "same_root_identity",
allowed_origins: &[],
delegation_root: Some(&root_hex),
})
.unwrap();
assert_eq!(v["delegation_root"], root_hex);
assert!(wrapped_event(&[]).get("delegation_root").is_none());
}
#[test]
fn wrapped_event_reports_widened_allow_origins() {
let v = wrapped_event(&[7, 42]);
assert_eq!(v["event"], "wrapped");
assert_eq!(v["scope"], "same_root_identity");
assert_eq!(v["allowed_origins"], serde_json::json!([7, 42]));
}
#[test]
fn wrapped_event_default_scope_is_same_root_only() {
let v = wrapped_event(&[]);
assert_eq!(v["allowed_origins"], serde_json::json!([]));
}
}