#![allow(clippy::expect_used, clippy::unwrap_used)]
mod harness;
use std::collections::BTreeSet;
use serde_json::{Value, json};
use tailscale_cli::stub::Reply;
use tailscale_mcp::gating::Preset;
use tailscale_mcp::meta::{Tier, ToolMeta};
use tailscale_rest::fake::Response;
use harness::Setup;
#[derive(Default)]
struct Arrangement {
cli: Vec<(Vec<&'static str>, Reply)>,
api: Vec<(&'static str, &'static str, Response)>,
}
impl Arrangement {
fn cli(mut self, argv: &[&'static str], reply: Reply) -> Self {
self.cli.push((argv.to_vec(), reply));
self
}
fn api(mut self, method: &'static str, path: &'static str, response: Response) -> Self {
self.api.push((method, path, response));
self
}
}
struct Contract {
tool: &'static str,
success: (Value, Arrangement),
failure: (Value, Arrangement, &'static str),
}
fn contracts() -> Vec<Contract> {
macro_rules! contract {
(
$tool:literal,
ok: $ok_args:tt on $ok_argv:expr => $ok_reply:expr,
err: $err_args:tt on $err_argv:expr => $err_reply:expr, $code:literal
) => {
Contract {
tool: $tool,
success: (
json!($ok_args),
Arrangement::default().cli(&$ok_argv, $ok_reply),
),
failure: (
json!($err_args),
Arrangement::default().cli(&$err_argv, $err_reply),
$code,
),
}
};
}
macro_rules! api_contract {
(
$tool:literal,
ok: $ok_args:tt on $method:literal $path:literal => $answer:expr,
err: $err_args:tt
) => {
Contract {
tool: $tool,
success: (
json!($ok_args),
Arrangement::default().api($method, $path, $answer),
),
failure: (
json!($err_args),
Arrangement::default().api(
$method,
$path,
Response::status(404, json!({"message": "not found"})),
),
"not_found",
),
}
};
(
$tool:literal,
ok: $ok_args:tt on $method:literal $path:literal => $answer:expr,
err: $err_args:tt also $fallback:literal
) => {
Contract {
tool: $tool,
success: (
json!($ok_args),
Arrangement::default().api($method, $path, $answer),
),
failure: (
json!($err_args),
Arrangement::default()
.api(
$method,
$path,
Response::status(404, json!({"message": "not found"})),
)
.api(
$method,
$fallback,
Response::status(404, json!({"message": "not found"})),
),
"not_found",
),
}
};
}
macro_rules! printed {
($name:literal) => {
Reply::ok(harness::fixture($name))
};
}
vec![
contract!(
"tailscale_status",
ok: {} on ["status"] => printed!("status.json"),
err: {} on ["status"] => Reply::Unavailable, "backend_unavailable"
),
contract!(
"tailscale_ip",
ok: {} on ["ip"] => printed!("ip.txt"),
err: {"target": "missing"} on ["ip"] =>
Reply::failed(1, "no such host: missing"), "not_found"
),
contract!(
"tailscale_netcheck",
ok: {} on ["netcheck"] => printed!("netcheck.json"),
err: {} on ["netcheck"] =>
Reply::failed(1, "netcheck: the probe could not be run"), "cli_failed"
),
contract!(
"tailscale_ping",
ok: {"target": "laptop"} on ["ping"] => printed!("ping.txt"),
err: {"target": "missing"} on ["ping"] =>
Reply::failed(1, "ping \"missing\": unknown peer"), "not_found"
),
contract!(
"tailscale_whois",
ok: {"address": "100.64.0.2"} on ["whois"] => printed!("whois.json"),
err: {"address": "203.0.113.9"} on ["whois"] =>
Reply::failed(1, "whois: 203.0.113.9 is outside the tailnet"), "cli_failed"
),
contract!(
"tailscale_whoami",
ok: {} on ["whoami"] => printed!("whoami.json"),
err: {} on ["whoami"] =>
Reply::failed(1, "tailscale: unknown subcommand \"whoami\""), "unsupported_version"
),
contract!(
"tailscale_version",
ok: {} on ["version"] => printed!("tailscale-version.txt"),
err: {} on ["version"] =>
Reply::failed(1, "failed to connect to local tailscaled"), "cli_failed"
),
contract!(
"tailscale_licenses",
ok: {} on ["licenses"] => printed!("licenses.txt"),
err: {} on ["licenses"] =>
Reply::failed(1, "licenses: this build carries no licence index"), "cli_failed"
),
contract!(
"tailscale_bugreport",
ok: {"note": "slow handshake"} on ["bugreport"] => printed!("bugreport.txt"),
err: {} on ["bugreport"] =>
Reply::failed(1, "bugreport: the log service could not be reached"), "cli_failed"
),
contract!(
"tailscale_appc_routes",
ok: {"all": true} on ["appc-routes"] => printed!("appc-routes.txt"),
err: {} on ["appc-routes"] =>
Reply::failed(1, "appc-routes: the daemon refused the request"), "cli_failed"
),
contract!(
"tailscale_routecheck",
ok: {} on ["routecheck"] => printed!("routecheck.json"),
err: {} on ["routecheck"] =>
Reply::failed(1, "tailscale: unknown subcommand \"routecheck\""),
"unsupported_version"
),
contract!(
"tailscale_wait",
ok: {"timeout_seconds": 1} on ["wait"] => Reply::ok(""),
err: {"timeout_seconds": 1} on ["wait"] => Reply::Unavailable, "backend_unavailable"
),
contract!(
"tailscale_dns_status",
ok: {} on ["dns", "status"] => printed!("dns-status.json"),
err: {} on ["dns", "status"] =>
Reply::failed(1, "dns status: the resolver configuration could not be read"),
"cli_failed"
),
contract!(
"tailscale_dns_query",
ok: {"name": "laptop.example-tailnet.ts.net"} on ["dns", "query"] =>
printed!("dns-query.json"),
err: {"name": "absent.example-tailnet.ts.net"} on ["dns", "query"] =>
Reply::failed(1, "dns query: the resolver answered NXDOMAIN"), "cli_failed"
),
contract!(
"tailscale_exit_node_list",
ok: {} on ["exit-node", "list"] => printed!("exit-node-list.txt"),
err: {} on ["exit-node", "list"] =>
Reply::failed(1, "Access denied: this operation requires the operator"),
"needs_operator"
),
contract!(
"tailscale_exit_node_suggest",
ok: {} on ["exit-node", "suggest"] => printed!("exit-node-suggest.txt"),
err: {} on ["exit-node", "suggest"] => Reply::Unavailable, "backend_unavailable"
),
contract!(
"tailscale_metrics_print",
ok: {} on ["metrics", "print"] => printed!("metrics.txt"),
err: {} on ["metrics", "print"] =>
Reply::failed(1, "metrics: the daemon refused the request"), "cli_failed"
),
contract!(
"tailscale_service_list",
ok: {} on ["service", "list"] => printed!("service-list.json"),
err: {} on ["service", "list"] =>
Reply::failed(1, "tailscale service: unknown subcommand \"list\""),
"unsupported_version"
),
contract!(
"tailscale_syspolicy_list",
ok: {} on ["syspolicy", "list"] => printed!("syspolicy-list.json"),
err: {} on ["syspolicy", "list"] =>
Reply::failed(1, "syspolicy: the policy store could not be read"), "cli_failed"
),
contract!(
"tailscale_lock_status",
ok: {} on ["lock", "status"] => printed!("lock-status.json"),
err: {} on ["lock", "status"] =>
Reply::failed(1, "lock: the daemon refused the request"), "cli_failed"
),
contract!(
"tailscale_lock_log",
ok: {} on ["lock", "log"] => printed!("lock-log.json"),
err: {} on ["lock", "log"] =>
Reply::failed(1, "lock log: the key authority is unreachable"), "cli_failed"
),
contract!(
"tailscale_serve_status",
ok: {} on ["serve", "status"] => printed!("serve-status.json"),
err: {} on ["serve", "status"] =>
Reply::failed(1, "serve: the daemon refused the request"), "cli_failed"
),
contract!(
"tailscale_funnel_status",
ok: {} on ["funnel", "status"] => printed!("funnel-status.json"),
err: {} on ["funnel", "status"] =>
Reply::failed(1, "funnel: the daemon refused the request"), "cli_failed"
),
contract!(
"tailscale_configure_sysext_status",
ok: {} on ["configure", "sysext", "status"] => printed!("sysext-status.txt"),
err: {} on ["configure", "sysext", "status"] =>
Reply::failed(1, "configure sysext: the extension could not be queried"),
"cli_failed"
),
contract!(
"tailscale_switch_list",
ok: {} on ["switch"] => printed!("switch-list.json"),
err: {} on ["switch"] =>
Reply::failed(1, "switch: the profile store could not be read"), "cli_failed"
),
contract!(
"tailscale_prefs_get",
ok: {} on ["get"] => printed!("prefs.json"),
err: {} on ["get"] =>
Reply::failed(1, "tailscale: unknown subcommand \"get\""), "unsupported_version"
),
contract!(
"tailscale_prefs_set",
ok: {"hostname": "workstation"} on ["set"] => Reply::ok(""),
err: {"shields_up": true} on ["set"] =>
Reply::failed(1, "set: shields-up is managed by policy"), "cli_failed"
),
contract!(
"tailscale_up",
ok: {} on ["up"] => printed!("up-running.json"),
err: {} on ["up"] => Reply::Unavailable, "backend_unavailable"
),
contract!(
"tailscale_down",
ok: {} on ["down"] => Reply::ok(""),
err: {} on ["down"] =>
Reply::failed(1, "down: the daemon refused the request"), "cli_failed"
),
contract!(
"tailscale_login",
ok: {} on ["login"] => printed!("login.txt"),
err: {} on ["login"] =>
Reply::failed(1, "Access denied: this operation requires the operator"),
"needs_operator"
),
contract!(
"tailscale_logout",
ok: {} on ["logout"] => Reply::ok(""),
err: {} on ["logout"] =>
Reply::failed(1, "logout: the profile could not be cleared"), "cli_failed"
),
contract!(
"tailscale_switch_profile",
ok: {"account": "example-tailnet.ts.net"} on ["switch"] => Reply::ok(""),
err: {"account": "nobody"} on ["switch"] =>
Reply::failed(1, "switch: profile \"nobody\" not found"), "not_found"
),
contract!(
"tailscale_switch_remove",
ok: {"account": "example-tailnet.ts.net"} on ["switch", "remove"] => Reply::ok(""),
err: {"account": "nobody"} on ["switch", "remove"] =>
Reply::failed(1, "switch remove: the profile store is read-only"), "cli_failed"
),
contract!(
"tailscale_serve_set",
ok: {"target": "3000"} on ["serve"] => printed!("serve-set.txt"),
err: {"target": "3000", "http": 80, "https": 443} on ["serve"] =>
Reply::ok(""), "invalid_args"
),
contract!(
"tailscale_serve_off",
ok: {"https": 8443} on ["serve"] => Reply::ok(""),
err: {"https": 8443} on ["serve"] =>
Reply::failed(1, "error: failed to remove web serve: handler does not exist"),
"not_found"
),
contract!(
"tailscale_serve_reset",
ok: {} on ["serve", "reset"] => Reply::ok(""),
err: {} on ["serve", "reset"] =>
Reply::failed(1, "reset: the daemon refused the request"), "cli_failed"
),
contract!(
"tailscale_serve_drain",
ok: {"service": "svc:web"} on ["serve", "drain"] => Reply::ok(""),
err: {"service": "svc:web"} on ["serve", "drain"] =>
Reply::failed(1, "drain: no such service"), "not_found"
),
contract!(
"tailscale_serve_clear",
ok: {"service": "svc:web"} on ["serve", "clear"] => Reply::ok(""),
err: {"service": "svc:web"} on ["serve", "clear"] =>
Reply::failed(1, "clear: the configuration could not be written"), "cli_failed"
),
contract!(
"tailscale_serve_advertise",
ok: {"service": "svc:web"} on ["serve", "advertise"] => Reply::ok(""),
err: {"service": "svc:web"} on ["serve", "advertise"] =>
Reply::failed(1, "advertise: no such service"), "not_found"
),
contract!(
"tailscale_serve_get_config",
ok: {"all": true} on ["serve", "get-config"] => printed!("serve-config.json"),
err: {} on ["serve", "get-config"] => Reply::ok(""), "invalid_args"
),
contract!(
"tailscale_serve_set_config",
ok: {"all": true, "configuration": {"version": "0.0.1"}} on ["serve", "set-config"] =>
Reply::ok(""),
err: {"all": true, "service": "svc:web", "configuration": {}}
on ["serve", "set-config"] => Reply::ok(""), "invalid_args"
),
contract!(
"tailscale_funnel_set",
ok: {"target": "3000"} on ["funnel"] => printed!("serve-set.txt"),
err: {"target": "3000"} on ["funnel"] =>
Reply::hung_after("Funnel is not enabled on your tailnet."), "timeout"
),
contract!(
"tailscale_funnel_off",
ok: {"https": 8443} on ["funnel"] => Reply::ok(""),
err: {"https": 8443} on ["funnel"] =>
Reply::failed(1, "error: failed to remove funnel: handler does not exist"),
"not_found"
),
contract!(
"tailscale_file_cp",
ok: {"files": ["/tmp/notes.txt"], "target": "laptop"} on ["file", "cp"] =>
Reply::ok("notes.txt: 4.1 kB\n"),
err: {"files": ["/tmp/notes.txt"], "target": "missing"} on ["file", "cp"] =>
Reply::failed(1, "error looking up IP of \"missing\": lookup missing: no such host"),
"not_found"
),
contract!(
"tailscale_file_targets",
ok: {} on ["file", "cp"] => printed!("file-targets.txt"),
err: {} on ["file", "cp"] =>
Reply::failed(1, "file cp: not logged in"), "cli_failed"
),
contract!(
"tailscale_file_get",
ok: {"directory": "/tmp/inbox"} on ["file", "get"] => Reply::ok("notes.txt\n"),
err: {"directory": "/tmp/inbox"} on ["file", "get"] =>
Reply::failed(1, "\"/tmp/inbox\" is not a directory"), "cli_failed"
),
contract!(
"tailscale_cert",
ok: {
"domain": "workstation.example-tailnet.ts.net",
"cert_file": "/tmp/node.crt",
"key_file": "/tmp/node.key"
} on ["cert"] => Reply::ok(""),
err: {
"domain": "workstation.example-tailnet.ts.net",
"cert_file": "/tmp/node.crt",
"key_file": "/tmp/node.key"
} on ["cert"] =>
Reply::failed(1, "500 Internal Server Error: invalid domain"), "cli_failed"
),
contract!(
"tailscale_metrics_write",
ok: {"path": "/tmp/tailscaled.prom"} on ["metrics", "write"] => Reply::ok(""),
err: {"path": "/tmp/tailscaled.prom"} on ["metrics", "write"] =>
Reply::failed(1, "error writing metrics: read-only file system"), "cli_failed"
),
contract!(
"tailscale_configure_kubeconfig",
ok: {"hostname": "cluster"} on ["configure", "kubeconfig"] => Reply::ok(""),
err: {"hostname": "cluster"} on ["configure", "kubeconfig"] =>
Reply::failed(1, "no such host: cluster"), "not_found"
),
contract!(
"tailscale_syspolicy_reload",
ok: {} on ["syspolicy", "reload"] => printed!("syspolicy-reload.json"),
err: {} on ["syspolicy", "reload"] =>
Reply::failed(1, "syspolicy: the policy store could not be reloaded"),
"cli_failed"
),
contract!(
"tailscale_drive_list",
ok: {} on ["drive", "list"] => printed!("drive-list.txt"),
err: {} on ["drive", "list"] => Reply::failed(
1,
"Taildrive CLI commands are not supported when using the macOS GUI app."
), "unsupported_platform"
),
contract!(
"tailscale_drive_share",
ok: {"name": "docs", "path": "/srv/docs"} on ["drive", "share"] => Reply::ok(""),
err: {"name": "docs", "path": "/srv/docs"} on ["drive", "share"] =>
Reply::failed(1, "drive share: \"/srv/docs\" is not a directory"), "cli_failed"
),
contract!(
"tailscale_drive_rename",
ok: {"name": "docs", "new_name": "handbook"} on ["drive", "rename"] => Reply::ok(""),
err: {"name": "docs", "new_name": "handbook"} on ["drive", "rename"] =>
Reply::failed(1, "share \"docs\" does not exist"), "not_found"
),
contract!(
"tailscale_drive_unshare",
ok: {"name": "docs"} on ["drive", "unshare"] => Reply::ok(""),
err: {"name": "docs"} on ["drive", "unshare"] =>
Reply::failed(1, "share \"docs\" does not exist"), "not_found"
),
contract!(
"tailscale_lock_init",
ok: {"trusted_keys": [TLPUB]} on ["lock", "init"] =>
Reply::ok("disablement-secret:00112233445566778899aabbccddeeff\n"),
err: {"trusted_keys": [TLPUB]} on ["lock", "init"] => Reply::failed(
1,
"the tailnet lock key of the current node must be one of the trusted keys during initialization"
), "cli_failed"
),
contract!(
"tailscale_lock_add",
ok: {"keys": [TLPUB]} on ["lock", "add"] => Reply::ok(""),
err: {"keys": [TLPUB]} on ["lock", "add"] =>
Reply::failed(1, "tailnet lock is not enabled"), "cli_failed"
),
contract!(
"tailscale_lock_remove",
ok: {"keys": [TLPUB]} on ["lock", "remove"] => Reply::ok(""),
err: {"keys": [TLPUB]} on ["lock", "remove"] =>
Reply::failed(1, "tailnet lock is not enabled"), "cli_failed"
),
contract!(
"tailscale_lock_sign",
ok: {"key": NODEKEY} on ["lock", "sign"] => Reply::ok(""),
err: {"key": NODEKEY} on ["lock", "sign"] => Reply::failed(
1,
"error: 500 Internal Server Error: signing failed: tailnet-lock is not active"
), "cli_failed"
),
contract!(
"tailscale_lock_disable",
ok: {"secret": DISABLEMENT_SECRET} on ["lock", "disable"] => Reply::ok(""),
err: {"secret": DISABLEMENT_SECRET} on ["lock", "disable"] => Reply::failed(
1,
"error: 400 Bad Request: tailnet-lock disable failed: tailnet-lock is not active"
), "cli_failed"
),
contract!(
"tailscale_lock_disablement_kdf",
ok: {"secret": DISABLEMENT_HEX} on ["lock", "disablement-kdf"] =>
Reply::ok("disablement:756fe19f200fbfc9ad431e75c7942b82\n"),
err: {"secret": DISABLEMENT_HEX} on ["lock", "disablement-kdf"] =>
Reply::failed(1, "encoding/hex: invalid byte: U+007A 'z'"), "cli_failed"
),
contract!(
"tailscale_lock_local_disable",
ok: {} on ["lock", "local-disable"] => Reply::ok(""),
err: {} on ["lock", "local-disable"] => Reply::failed(
1,
"error: 400 Bad Request: tailnet-lock local disable failed: tailnet-lock is not active"
), "cli_failed"
),
contract!(
"tailscale_lock_revoke_keys",
ok: {"keys": [TLPUB]} on ["lock", "revoke-keys"] =>
Reply::ok("run this on the next signing node\n"),
err: {"keys": [TLPUB]} on ["lock", "revoke-keys"] => Reply::failed(
1,
"generation of recovery AUM failed: sending generate-recovery-aum: 500 Internal Server Error: tailnet-lock is not active"
), "cli_failed"
),
contract!(
"tailscale_debug_derp_map",
ok: {} on ["debug", "derp-map"] => Reply::ok(r#"{"Regions":{}}"#),
err: {} on ["debug", "derp-map"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_netmap",
ok: {} on ["debug", "netmap"] => Reply::ok(r#"{"Peers":[]}"#),
err: {} on ["debug", "netmap"] =>
Reply::failed(1, "netmap is not available: not logged in"), "cli_failed"
),
contract!(
"tailscale_debug_hostinfo",
ok: {} on ["debug", "hostinfo"] => Reply::ok(r#"{"OS":"macOS","Hostname":"example"}"#),
err: {} on ["debug", "hostinfo"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_control_knobs",
ok: {} on ["debug", "control-knobs"] => Reply::ok(r#"{"DisableUPnP":false}"#),
err: {} on ["debug", "control-knobs"] =>
Reply::failed(1, "tailscale: unknown subcommand \"control-knobs\""), "unsupported_version"
),
contract!(
"tailscale_debug_daemon_goroutines",
ok: {} on ["debug", "daemon-goroutines"] =>
Reply::ok("goroutine 1 [running]:\nmain.main()\n"),
err: {} on ["debug", "daemon-goroutines"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_daemon_bus_graph",
ok: {} on ["debug", "daemon-bus-graph", "--format=json"] =>
Reply::ok(r#"{"nodes":[],"edges":[]}"#),
err: {"format": "dot"} on ["debug", "daemon-bus-graph", "--format=dot"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_daemon_bus_queues",
ok: {} on ["debug", "daemon-bus-queues"] => Reply::ok(r#"{"queues":[]}"#),
err: {} on ["debug", "daemon-bus-queues"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_metrics",
ok: {} on ["debug", "metrics"] =>
Reply::ok("# TYPE tailscaled_inbound_packets_total counter\ntailscaled_inbound_packets_total 0\n"),
err: {} on ["debug", "metrics"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_statedir",
ok: {} on ["debug", "statedir"] => Reply::ok("/var/lib/tailscale\n"),
err: {} on ["debug", "statedir"] =>
Reply::failed(1, "no state directory is configured"), "cli_failed"
),
contract!(
"tailscale_debug_go_buildinfo",
ok: {} on ["debug", "go-buildinfo"] => Reply::ok(r#"{"GoVersion":"go1.24.0"}"#),
err: {} on ["debug", "go-buildinfo"] =>
Reply::failed(1, "tailscale: unknown subcommand \"go-buildinfo\""), "unsupported_version"
),
contract!(
"tailscale_debug_peer_relay_servers",
ok: {} on ["debug", "peer-relay-servers"] => Reply::ok("[]\n"),
err: {} on ["debug", "peer-relay-servers"] =>
Reply::failed(1, "tailscale: unknown subcommand \"peer-relay-servers\""), "unsupported_version"
),
contract!(
"tailscale_debug_peer_relay_sessions",
ok: {} on ["debug", "peer-relay-sessions"] =>
Reply::ok("Server port: not configured\nSessions count: 0\n"),
err: {} on ["debug", "peer-relay-sessions"] =>
Reply::failed(1, "tailscale: unknown subcommand \"peer-relay-sessions\""), "unsupported_version"
),
contract!(
"tailscale_debug_file_list",
ok: {} on ["debug", "--file=get"] => Reply::ok("null\n"),
err: {} on ["debug", "--file=get"] =>
Reply::failed(1, "Taildrop is not enabled on this node"), "cli_failed"
),
contract!(
"tailscale_debug_stat",
ok: {"paths": ["/etc/hosts"]} on ["debug", "stat", "/etc/hosts"] =>
Reply::ok("/etc/hosts: -rw-r--r--, 213\n"),
err: {"paths": ["/etc/nope"]} on ["debug", "stat", "/etc/nope"] =>
Reply::failed(1, "stat /etc/nope: no such file or directory"), "not_found"
),
contract!(
"tailscale_debug_via",
ok: {"site_id": 7, "prefix": "10.1.0.0/16"} on ["debug", "via", "7", "10.1.0.0/16"] =>
Reply::ok("fd7a:115c:a1e0:b1a:0:7:a01:0/112\n"),
err: {"site_id": 7, "prefix": "10.1.0.0/16", "route": "fd7a::/112"}
on ["debug", "via"] => Reply::ok(""), "invalid_args"
),
contract!(
"tailscale_debug_watch_ipn",
ok: {"count": 1} on ["debug", "watch-ipn", "--count=1"] =>
Reply::ok("{\"Version\":\"1.102.2\"}\n"),
err: {"count": 0} on ["debug", "watch-ipn"] => Reply::ok(""), "invalid_args"
),
contract!(
"tailscale_debug_peer_endpoint_changes",
ok: {"peer": "laptop"} on ["debug", "peer-endpoint-changes", "laptop"] =>
Reply::ok(r#"{"changes":[]}"#),
err: {"peer": "missing"} on ["debug", "peer-endpoint-changes", "missing"] =>
Reply::failed(1, "error looking up IP of \"missing\": no such host"), "not_found"
),
contract!(
"tailscale_debug_resolve",
ok: {"host": "example.com"} on ["debug", "resolve", "example.com"] =>
Reply::ok("203.0.113.10\n"),
err: {"host": "missing.invalid"} on ["debug", "resolve", "missing.invalid"] =>
Reply::failed(1, "lookup missing.invalid: no such host"), "not_found"
),
contract!(
"tailscale_debug_dial_types",
ok: {"host": "example.com", "port": 443} on ["debug", "dial-types", "example.com", "443"] =>
Reply::ok("tcp dial to example.com:443 succeeded\n"),
err: {"host": "example.com", "port": 443} on ["debug", "dial-types"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_derp",
ok: {} on ["debug", "derp"] => Reply::ok("derp region 1: ok\n"),
err: {} on ["debug", "derp"] =>
Reply::failed(1, "no DERP map is available"), "cli_failed"
),
contract!(
"tailscale_debug_ts2021",
ok: {} on ["debug", "ts2021"] => Reply::ok("did noise handshake\n"),
err: {} on ["debug", "ts2021"] =>
Reply::failed(1, "fetching keys: dial tcp: connection refused"), "cli_failed"
),
contract!(
"tailscale_debug_portmap",
ok: {} on ["debug", "portmap", "--duration=5s"] =>
Reply::ok("portmapper: no port mapping services were found\n"),
err: {"gateway_addr": "192.0.2.1"} on ["debug", "portmap"] =>
Reply::ok(""), "invalid_args"
),
contract!(
"tailscale_debug_component_logs",
ok: {"component": "magicsock"}
on ["debug", "component-logs", "--for=3600s", "magicsock"] => Reply::ok(""),
err: {"component": "nonsense"} on ["debug", "component-logs"] =>
Reply::failed(1, "unknown component \"nonsense\""), "cli_failed"
),
contract!(
"tailscale_debug_restun",
ok: {} on ["debug", "restun"] => Reply::ok(""),
err: {} on ["debug", "restun"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_rebind",
ok: {} on ["debug", "rebind"] => Reply::ok(""),
err: {} on ["debug", "rebind"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_rotate_disco_key",
ok: {} on ["debug", "rotate-disco-key"] => Reply::ok(""),
err: {} on ["debug", "rotate-disco-key"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_derp_unset_on_demand",
ok: {} on ["debug", "derp-unset-on-demand"] => Reply::ok(""),
err: {} on ["debug", "derp-unset-on-demand"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_pick_new_derp",
ok: {} on ["debug", "pick-new-derp"] => Reply::ok("now using derp region 2\n"),
err: {} on ["debug", "pick-new-derp"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_debug_force_prefer_derp",
ok: {"region_id": 0} on ["debug", "force-prefer-derp", "0"] => Reply::ok(""),
err: {"region_id": 99} on ["debug", "force-prefer-derp", "99"] =>
Reply::failed(1, "region 99 is not in the DERP map"), "cli_failed"
),
contract!(
"tailscale_debug_force_netmap_update",
ok: {} on ["debug", "force-netmap-update"] => Reply::ok(""),
err: {} on ["debug", "force-netmap-update"] =>
Reply::failed(1, "Access denied: cannot debug without operator access"), "needs_operator"
),
contract!(
"tailscale_run",
ok: {"args": ["version"]} on ["version"] => printed!("tailscale-version.txt"),
err: {"args": ["down"]} on ["down"] => Reply::ok(""), "not_permitted"
),
api_contract!(
"tailnet_device_list",
ok: {} on "GET" "/api/v2/tailnet/-/devices" =>
Response::json(json!({"devices": []})),
err: {}
),
api_contract!(
"tailnet_device_get",
ok: {"device_id": "n1111111CNTRL"} on "GET" "/api/v2/device/n1111111CNTRL" =>
Response::json(json!({"nodeId": "n1111111CNTRL", "name": "example-node"})),
err: {"device_id": "n1111111CNTRL"}
),
api_contract!(
"tailnet_device_delete",
ok: {"device_id": "n2222222CNTRL"} on "DELETE" "/api/v2/device/n2222222CNTRL" =>
Response::empty(),
err: {"device_id": "n2222222CNTRL"}
),
api_contract!(
"tailnet_device_expire",
ok: {"device_id": "n2222222CNTRL"} on "POST" "/api/v2/device/n2222222CNTRL/expire" =>
Response::empty(),
err: {"device_id": "n2222222CNTRL"}
),
api_contract!(
"tailnet_device_authorize",
ok: {"device_id": "n2222222CNTRL", "authorized": true}
on "POST" "/api/v2/device/n2222222CNTRL/authorized" => Response::empty(),
err: {"device_id": "n2222222CNTRL", "authorized": true}
),
api_contract!(
"tailnet_device_rename",
ok: {"device_id": "n2222222CNTRL", "name": "example-node"}
on "POST" "/api/v2/device/n2222222CNTRL/name" => Response::empty(),
err: {"device_id": "n2222222CNTRL", "name": "example-node"}
),
api_contract!(
"tailnet_device_tags_set",
ok: {"device_id": "n2222222CNTRL", "tags": ["tag:example"]}
on "POST" "/api/v2/device/n2222222CNTRL/tags" => Response::empty(),
err: {"device_id": "n2222222CNTRL", "tags": ["tag:example"]}
),
api_contract!(
"tailnet_device_key_expiry_set",
ok: {"device_id": "n1111111CNTRL", "key_expiry_disabled": true}
on "POST" "/api/v2/device/n1111111CNTRL/key" => Response::empty(),
err: {"device_id": "n1111111CNTRL", "key_expiry_disabled": true}
),
api_contract!(
"tailnet_device_ip_set",
ok: {"device_id": "n2222222CNTRL", "ipv4": "100.64.0.9"}
on "POST" "/api/v2/device/n2222222CNTRL/ip" => Response::empty(),
err: {"device_id": "n2222222CNTRL", "ipv4": "100.64.0.9"}
),
api_contract!(
"tailnet_device_routes_get",
ok: {"device_id": "n1111111CNTRL"} on "GET" "/api/v2/device/n1111111CNTRL/routes" =>
Response::json(json!({"advertisedRoutes": [], "enabledRoutes": []})),
err: {"device_id": "n1111111CNTRL"}
),
api_contract!(
"tailnet_device_routes_set",
ok: {"device_id": "n2222222CNTRL", "routes": ["10.0.0.0/24"]}
on "POST" "/api/v2/device/n2222222CNTRL/routes" =>
Response::json(json!({"advertisedRoutes": ["10.0.0.0/24"],
"enabledRoutes": ["10.0.0.0/24"]})),
err: {"device_id": "n2222222CNTRL", "routes": ["10.0.0.0/24"]}
),
api_contract!(
"tailnet_device_attributes_get",
ok: {"device_id": "n1111111CNTRL"} on "GET" "/api/v2/device/n1111111CNTRL/attributes" =>
Response::json(json!({"attributes": {}, "expiries": {}})),
err: {"device_id": "n1111111CNTRL"}
),
api_contract!(
"tailnet_device_attribute_set",
ok: {"device_id": "n1111111CNTRL", "attribute_key": "custom:example", "value": true}
on "POST" "/api/v2/device/n1111111CNTRL/attributes/custom:example" => Response::empty(),
err: {"device_id": "n1111111CNTRL", "attribute_key": "custom:example", "value": true}
),
api_contract!(
"tailnet_device_attribute_delete",
ok: {"device_id": "n1111111CNTRL", "attribute_key": "custom:example"}
on "DELETE" "/api/v2/device/n1111111CNTRL/attributes/custom:example" => Response::empty(),
err: {"device_id": "n1111111CNTRL", "attribute_key": "custom:example"}
),
api_contract!(
"tailnet_device_attributes_update",
ok: {"nodes": {"n1111111CNTRL": {"custom:example": true}}}
on "PATCH" "/api/v2/tailnet/-/device-attributes" => Response::empty(),
err: {"nodes": {"n1111111CNTRL": {"custom:example": true}}}
),
api_contract!(
"tailnet_posture_integration_list",
ok: {} on "GET" "/api/v2/tailnet/-/posture/integrations" =>
Response::json(json!({"integrations": []})),
err: {}
),
api_contract!(
"tailnet_posture_integration_get",
ok: {"integration_id": "pi-example"}
on "GET" "/api/v2/posture/integrations/pi-example" =>
Response::json(json!({"id": "pi-example", "provider": "falcon"})),
err: {"integration_id": "pi-example"}
),
api_contract!(
"tailnet_posture_integration_create",
ok: {"provider": "falcon", "client_secret": "example-secret"}
on "POST" "/api/v2/tailnet/-/posture/integrations" =>
Response::json(json!({"id": "pi-example", "provider": "falcon"})),
err: {"provider": "falcon", "client_secret": "example-secret"}
),
api_contract!(
"tailnet_posture_integration_update",
ok: {"integration_id": "pi-example", "client_id": "example-client"}
on "PATCH" "/api/v2/posture/integrations/pi-example" =>
Response::json(json!({"id": "pi-example", "provider": "falcon"})),
err: {"integration_id": "pi-example", "client_id": "example-client"}
),
api_contract!(
"tailnet_posture_integration_delete",
ok: {"integration_id": "pi-example"}
on "DELETE" "/api/v2/posture/integrations/pi-example" => Response::empty(),
err: {"integration_id": "pi-example"}
),
api_contract!(
"tailnet_dns_nameservers_get",
ok: {} on "GET" "/api/v2/tailnet/-/dns/nameservers" =>
Response::json(json!({"dns": ["8.8.8.8"]})),
err: {}
),
api_contract!(
"tailnet_dns_nameservers_replace",
ok: {"dns": ["8.8.8.8"]} on "POST" "/api/v2/tailnet/-/dns/nameservers" =>
Response::json(json!({"dns": ["8.8.8.8"], "magicDNS": true})),
err: {"dns": ["8.8.8.8"]}
),
api_contract!(
"tailnet_dns_preferences_get",
ok: {} on "GET" "/api/v2/tailnet/-/dns/preferences" =>
Response::json(json!({"magicDNS": true})),
err: {}
),
api_contract!(
"tailnet_dns_preferences_set",
ok: {"magic_dns": true} on "POST" "/api/v2/tailnet/-/dns/preferences" =>
Response::json(json!({"magicDNS": true})),
err: {"magic_dns": true}
),
api_contract!(
"tailnet_dns_search_paths_get",
ok: {} on "GET" "/api/v2/tailnet/-/dns/searchpaths" =>
Response::json(json!({"searchPaths": ["example.com"]})),
err: {}
),
api_contract!(
"tailnet_dns_search_paths_replace",
ok: {"search_paths": ["example.com"]}
on "POST" "/api/v2/tailnet/-/dns/searchpaths" =>
Response::json(json!({"searchPaths": ["example.com"]})),
err: {"search_paths": ["example.com"]}
),
api_contract!(
"tailnet_dns_split_get",
ok: {} on "GET" "/api/v2/tailnet/-/dns/split-dns" =>
Response::json(json!({"example.com": ["10.0.0.1"]})),
err: {}
),
api_contract!(
"tailnet_dns_split_update",
ok: {"domains": {"example.com": ["10.0.0.1"]}}
on "PATCH" "/api/v2/tailnet/-/dns/split-dns" =>
Response::json(json!({"example.com": ["10.0.0.1"]})),
err: {"domains": {"example.com": ["10.0.0.1"]}}
),
api_contract!(
"tailnet_dns_split_replace",
ok: {"domains": {"example.com": ["10.0.0.1"]}}
on "PUT" "/api/v2/tailnet/-/dns/split-dns" =>
Response::json(json!({"example.com": ["10.0.0.1"]})),
err: {"domains": {"example.com": ["10.0.0.1"]}}
),
api_contract!(
"tailnet_dns_configuration_get",
ok: {} on "GET" "/api/v2/tailnet/-/dns/configuration" =>
Response::json(json!({"nameservers": [], "searchPaths": []})),
err: {}
),
api_contract!(
"tailnet_dns_configuration_replace",
ok: {"configuration": {"nameservers": [], "searchPaths": []}}
on "POST" "/api/v2/tailnet/-/dns/configuration" =>
Response::json(json!({"nameservers": [], "searchPaths": []})),
err: {"configuration": {"nameservers": [], "searchPaths": []}}
),
api_contract!(
"tailnet_policy_get",
ok: {} on "GET" "/api/v2/tailnet/-/acl" =>
Response::text("application/hujson", "{\n // a policy\n \"acls\": [],\n}"),
err: {}
),
api_contract!(
"tailnet_policy_set",
ok: {"policy": "{\"acls\": []}", "etag": "\"e0b2816b418\""}
on "POST" "/api/v2/tailnet/-/acl" =>
Response::text("application/hujson", "{\"acls\": []}"),
err: {"policy": "{\"acls\": []}", "etag": "\"e0b2816b418\""}
),
api_contract!(
"tailnet_policy_preview",
ok: {"policy": {"acls": []}, "subject_type": "user",
"preview_for": "someone@example.com"}
on "POST" "/api/v2/tailnet/-/acl/preview" =>
Response::json(json!({"matches": [], "type": "user",
"previewFor": "someone@example.com"})),
err: {"policy": {"acls": []}, "subject_type": "user",
"preview_for": "someone@example.com"}
),
api_contract!(
"tailnet_policy_validate",
ok: {"tests": [{"src": "someone@example.com", "accept": ["10.0.0.1:80"]}]}
on "POST" "/api/v2/tailnet/-/acl/validate" => Response::empty(),
err: {"tests": [{"src": "someone@example.com", "accept": ["10.0.0.1:80"]}]}
),
api_contract!(
"tailnet_key_list",
ok: {} on "GET" "/api/v2/tailnet/-/keys" => Response::json(json!({"keys": []})),
err: {}
),
api_contract!(
"tailnet_key_get",
ok: {"key_id": "kexample1"} on "GET" "/api/v2/tailnet/-/keys/kexample1" =>
Response::json(json!({"id": "kexample1", "keyType": "auth"})),
err: {"key_id": "kexample1"}
),
api_contract!(
"tailnet_key_create",
ok: {"key_type": "auth", "description": "example"}
on "POST" "/api/v2/tailnet/-/keys" =>
Response::json(json!({"id": "kexample1", "keyType": "auth"})),
err: {"key_type": "auth", "description": "example"}
),
api_contract!(
"tailnet_key_update",
ok: {"key_id": "kexample1", "key_type": "client", "scopes": ["devices:core:read"]}
on "PUT" "/api/v2/tailnet/-/keys/kexample1" =>
Response::json(json!({"id": "kexample1", "keyType": "client"})),
err: {"key_id": "kexample1", "key_type": "client", "scopes": ["devices:core:read"]}
),
api_contract!(
"tailnet_key_delete",
ok: {"key_id": "kexample1"} on "DELETE" "/api/v2/tailnet/-/keys/kexample1" =>
Response::empty(),
err: {"key_id": "kexample1"}
),
api_contract!(
"tailnet_user_list",
ok: {} on "GET" "/api/v2/tailnet/-/users" => Response::json(json!({"users": []})),
err: {}
),
api_contract!(
"tailnet_user_get",
ok: {"user_id": "uexample1"} on "GET" "/api/v2/users/uexample1" =>
Response::json(json!({"id": "uexample1", "role": "member"})),
err: {"user_id": "uexample1"}
),
api_contract!(
"tailnet_user_role_set",
ok: {"user_id": "uexample1", "role": "admin"}
on "POST" "/api/v2/users/uexample1/role" => Response::empty(),
err: {"user_id": "uexample1", "role": "admin"}
),
api_contract!(
"tailnet_user_approve",
ok: {"user_id": "uexample1"} on "POST" "/api/v2/users/uexample1/approve" =>
Response::empty(),
err: {"user_id": "uexample1"}
),
api_contract!(
"tailnet_user_suspend",
ok: {"user_id": "uexample1"} on "POST" "/api/v2/users/uexample1/suspend" =>
Response::empty(),
err: {"user_id": "uexample1"}
),
api_contract!(
"tailnet_user_restore",
ok: {"user_id": "uexample1"} on "POST" "/api/v2/users/uexample1/restore" =>
Response::empty(),
err: {"user_id": "uexample1"}
),
api_contract!(
"tailnet_user_delete",
ok: {"user_id": "uexample1"} on "POST" "/api/v2/users/uexample1/delete" =>
Response::empty(),
err: {"user_id": "uexample1"}
),
api_contract!(
"tailnet_device_invite_list",
ok: {"device_id": "n1111111CNTRL"}
on "GET" "/api/v2/device/n1111111CNTRL/device-invites" =>
Response::json(json!([])),
err: {"device_id": "n1111111CNTRL"}
),
api_contract!(
"tailnet_device_invite_create",
ok: {"device_id": "n1111111CNTRL", "invites": [{"multiUse": true}]}
on "POST" "/api/v2/device/n1111111CNTRL/device-invites" =>
Response::json(json!([{"id": "di-example"}])),
err: {"device_id": "n1111111CNTRL", "invites": [{"multiUse": true}]}
),
api_contract!(
"tailnet_device_invite_get",
ok: {"invite_id": "di-example"} on "GET" "/api/v2/device-invites/di-example" =>
Response::json(json!({"id": "di-example"})),
err: {"invite_id": "di-example"}
),
api_contract!(
"tailnet_device_invite_delete",
ok: {"invite_id": "di-example"} on "DELETE" "/api/v2/device-invites/di-example" =>
Response::empty(),
err: {"invite_id": "di-example"}
),
api_contract!(
"tailnet_device_invite_resend",
ok: {"invite_id": "di-example"}
on "POST" "/api/v2/device-invites/di-example/resend" => Response::empty(),
err: {"invite_id": "di-example"}
),
api_contract!(
"tailnet_device_invite_accept",
ok: {"invite": "example-invite-code"}
on "POST" "/api/v2/device-invites/-/accept" =>
Response::json(json!({"device": {"id": "1"}})),
err: {"invite": "example-invite-code"}
),
api_contract!(
"tailnet_user_invite_list",
ok: {} on "GET" "/api/v2/tailnet/-/user-invites" => Response::json(json!([])),
err: {}
),
api_contract!(
"tailnet_user_invite_create",
ok: {"invites": [{"role": "member"}]}
on "POST" "/api/v2/tailnet/-/user-invites" =>
Response::json(json!([{"id": "ui-example"}])),
err: {"invites": [{"role": "member"}]}
),
api_contract!(
"tailnet_user_invite_get",
ok: {"invite_id": "ui-example"} on "GET" "/api/v2/user-invites/ui-example" =>
Response::json(json!({"id": "ui-example"})),
err: {"invite_id": "ui-example"}
),
api_contract!(
"tailnet_user_invite_delete",
ok: {"invite_id": "ui-example"} on "DELETE" "/api/v2/user-invites/ui-example" =>
Response::empty(),
err: {"invite_id": "ui-example"}
),
api_contract!(
"tailnet_user_invite_resend",
ok: {"invite_id": "ui-example"}
on "POST" "/api/v2/user-invites/ui-example/resend" => Response::empty(),
err: {"invite_id": "ui-example"}
),
api_contract!(
"tailnet_contacts_get",
ok: {} on "GET" "/api/v2/tailnet/-/contacts" =>
Response::json(json!({"account": {"email": "someone@example.com"}})),
err: {}
),
api_contract!(
"tailnet_contact_update",
ok: {"contact_type": "security", "email": "someone@example.com"}
on "PATCH" "/api/v2/tailnet/-/contacts/security" => Response::empty(),
err: {"contact_type": "security", "email": "someone@example.com"}
),
api_contract!(
"tailnet_contact_verification_resend",
ok: {"contact_type": "security"}
on "POST" "/api/v2/tailnet/-/contacts/security/resend-verification-email" =>
Response::empty(),
err: {"contact_type": "security"}
),
api_contract!(
"tailnet_settings_get",
ok: {} on "GET" "/api/v2/tailnet/-/settings" =>
Response::json(json!({"devicesApprovalOn": true})),
err: {}
),
api_contract!(
"tailnet_settings_update",
ok: {"settings": {"devicesApprovalOn": true}}
on "PATCH" "/api/v2/tailnet/-/settings" => Response::empty(),
err: {"settings": {"devicesApprovalOn": true}}
),
api_contract!(
"tailnet_webhook_list",
ok: {} on "GET" "/api/v2/tailnet/-/webhooks"
=> Response::json(json!({"webhooks": []})),
err: {}
),
api_contract!(
"tailnet_webhook_create",
ok: {"endpoint_url": "https://example.com/hook", "subscriptions": ["nodeCreated"]}
on "POST" "/api/v2/tailnet/-/webhooks"
=> Response::json(json!({"endpointId": "whk-example", "secret": "tskey-webhook-redacted"})),
err: {"endpoint_url": "https://example.com/hook", "subscriptions": ["nodeCreated"]}
),
api_contract!(
"tailnet_webhook_get",
ok: {"endpoint_id": "whk-example"} on "GET" "/api/v2/webhooks/whk-example"
=> Response::json(json!({"endpointId": "whk-example"})),
err: {"endpoint_id": "whk-example"}
),
api_contract!(
"tailnet_webhook_subscriptions_replace",
ok: {"endpoint_id": "whk-example", "subscriptions": ["nodeCreated", "nodeDeleted"]}
on "PATCH" "/api/v2/webhooks/whk-example"
=> Response::json(json!({"endpointId": "whk-example"})),
err: {"endpoint_id": "whk-example", "subscriptions": ["nodeCreated"]}
),
api_contract!(
"tailnet_webhook_delete",
ok: {"endpoint_id": "whk-example"} on "DELETE" "/api/v2/webhooks/whk-example"
=> Response::empty(),
err: {"endpoint_id": "whk-example"}
),
api_contract!(
"tailnet_webhook_test",
ok: {"endpoint_id": "whk-example"} on "POST" "/api/v2/webhooks/whk-example/test"
=> Response::empty(),
err: {"endpoint_id": "whk-example"}
),
api_contract!(
"tailnet_webhook_secret_rotate",
ok: {"endpoint_id": "whk-example"} on "POST" "/api/v2/webhooks/whk-example/rotate"
=> Response::json(json!({"endpointId": "whk-example", "secret": "tskey-webhook-redacted"})),
err: {"endpoint_id": "whk-example"}
),
api_contract!(
"tailnet_service_list",
ok: {} on "GET" "/api/v2/tailnet/-/services"
=> Response::json(json!({"vipServices": []})),
err: {} also "/api/v2/tailnet/-/vip-services"
),
api_contract!(
"tailnet_service_get",
ok: {"service_name": "svc:example"} on "GET" "/api/v2/tailnet/-/services/svc:example"
=> Response::json(json!({"name": "svc:example"})),
err: {"service_name": "svc:example"} also "/api/v2/tailnet/-/vip-services/svc:example"
),
api_contract!(
"tailnet_service_replace",
ok: {"service_name": "svc:example", "service": {"name": "svc:example"}}
on "PUT" "/api/v2/tailnet/-/services/svc:example"
=> Response::json(json!({"name": "svc:example"})),
err: {"service_name": "svc:example", "service": {"name": "svc:example"}} also "/api/v2/tailnet/-/vip-services/svc:example"
),
api_contract!(
"tailnet_service_delete",
ok: {"service_name": "svc:example"} on "DELETE" "/api/v2/tailnet/-/services/svc:example"
=> Response::empty(),
err: {"service_name": "svc:example"} also "/api/v2/tailnet/-/vip-services/svc:example"
),
api_contract!(
"tailnet_service_devices_list",
ok: {"service_name": "svc:example"}
on "GET" "/api/v2/tailnet/-/services/svc:example/devices"
=> Response::json(json!({"hosts": []})),
err: {"service_name": "svc:example"} also "/api/v2/tailnet/-/vip-services/svc:example/devices"
),
api_contract!(
"tailnet_service_approval_get",
ok: {"service_name": "svc:example", "device_id": "n1111111CNTRL"}
on "GET" "/api/v2/tailnet/-/services/svc:example/device/n1111111CNTRL/approved"
=> Response::json(json!({"approved": true})),
err: {"service_name": "svc:example", "device_id": "n1111111CNTRL"} also "/api/v2/tailnet/-/vip-services/svc:example/device/n1111111CNTRL/approved"
),
api_contract!(
"tailnet_service_approval_set",
ok: {"service_name": "svc:example", "device_id": "n1111111CNTRL", "approved": true}
on "POST" "/api/v2/tailnet/-/services/svc:example/device/n1111111CNTRL/approved"
=> Response::json(json!({"approved": true})),
err: {"service_name": "svc:example", "device_id": "n1111111CNTRL", "approved": true} also "/api/v2/tailnet/-/vip-services/svc:example/device/n1111111CNTRL/approved"
),
api_contract!(
"tailnet_oauth_app_list",
ok: {} on "GET" "/api/v2/tailnet/-/oauth-apps"
=> Response::json(json!({"oauthApps": []})),
err: {}
),
api_contract!(
"tailnet_oauth_app_create",
ok: {
"name": "my-oauth-app",
"redirect_uris": ["https://example.com/oauth/callback"],
"scopes": ["auth_keys:create"]
} on "POST" "/api/v2/tailnet/-/oauth-apps"
=> Response::json(json!({"id": "a111111CNTRL", "name": "my-oauth-app"})),
err: {
"name": "my-oauth-app",
"redirect_uris": ["https://example.com/oauth/callback"],
"scopes": ["auth_keys:create"]
}
),
api_contract!(
"tailnet_oauth_app_get",
ok: {"app_id": "a111111CNTRL"} on "GET" "/api/v2/tailnet/-/oauth-apps/a111111CNTRL"
=> Response::json(json!({"id": "a111111CNTRL"})),
err: {"app_id": "a111111CNTRL"}
),
api_contract!(
"tailnet_oauth_app_update",
ok: {
"app_id": "a111111CNTRL",
"name": "my-oauth-app",
"redirect_uris": ["https://example.com/oauth/callback"],
"scopes": ["auth_keys:create"]
} on "PUT" "/api/v2/tailnet/-/oauth-apps/a111111CNTRL"
=> Response::json(json!({"id": "a111111CNTRL"})),
err: {
"app_id": "a111111CNTRL",
"name": "my-oauth-app",
"redirect_uris": ["https://example.com/oauth/callback"],
"scopes": ["auth_keys:create"]
}
),
api_contract!(
"tailnet_oauth_app_delete",
ok: {"app_id": "a111111CNTRL"} on "DELETE" "/api/v2/tailnet/-/oauth-apps/a111111CNTRL"
=> Response::empty(),
err: {"app_id": "a111111CNTRL"}
),
api_contract!(
"tailnet_audit_log_list",
ok: {"start": "2023-12-19T16:39:57-08:00", "end": "2023-12-22T02:15:23-08:00"}
on "GET" "/api/v2/tailnet/-/logging/configuration"
=> Response::json(json!({"logs": []})),
err: {"start": "2023-12-19T16:39:57-08:00", "end": "2023-12-22T02:15:23-08:00"}
),
api_contract!(
"tailnet_network_log_list",
ok: {"start": "2023-12-19T16:39:57-08:00", "end": "2023-12-22T02:15:23-08:00"}
on "GET" "/api/v2/tailnet/-/logging/network"
=> Response::json(json!({"logs": []})),
err: {"start": "2023-12-19T16:39:57-08:00", "end": "2023-12-22T02:15:23-08:00"}
),
api_contract!(
"tailnet_log_stream_get",
ok: {"log_type": "configuration"}
on "GET" "/api/v2/tailnet/-/logging/configuration/stream"
=> Response::json(json!({"destinationType": "elastic"})),
err: {"log_type": "configuration"}
),
api_contract!(
"tailnet_log_stream_status_get",
ok: {"log_type": "network"}
on "GET" "/api/v2/tailnet/-/logging/network/stream/status"
=> Response::json(json!({"lastActivity": "2023-12-19T16:39:57-08:00"})),
err: {"log_type": "network"}
),
api_contract!(
"tailnet_log_stream_replace",
ok: {
"log_type": "configuration",
"configuration": {"destinationType": "elastic", "url": "https://example.com/logs"}
} on "PUT" "/api/v2/tailnet/-/logging/configuration/stream"
=> Response::json(json!({"destinationType": "elastic"})),
err: {
"log_type": "configuration",
"configuration": {"destinationType": "elastic", "url": "https://example.com/logs"}
}
),
api_contract!(
"tailnet_log_stream_delete",
ok: {"log_type": "network"}
on "DELETE" "/api/v2/tailnet/-/logging/network/stream" => Response::empty(),
err: {"log_type": "network"}
),
api_contract!(
"tailnet_aws_external_id_create",
ok: {"reusable": true} on "POST" "/api/v2/tailnet/-/aws-external-id"
=> Response::json(json!({
"externalId": "00000000-0000-0000-0000-000000000000",
"tailscaleAwsAccountId": "000000000000"
})),
err: {"reusable": true}
),
api_contract!(
"tailnet_aws_trust_policy_validate",
ok: {
"external_id": "00000000-0000-0000-0000-000000000000",
"role_arn": "arn:aws:iam::000000000000:role/tailscale-log-writer"
} on "POST"
"/api/v2/tailnet/-/aws-external-id/00000000-0000-0000-0000-000000000000/validate-aws-trust-policy"
=> Response::empty(),
err: {
"external_id": "00000000-0000-0000-0000-000000000000",
"role_arn": "arn:aws:iam::000000000000:role/tailscale-log-writer"
}
),
api_contract!(
"tailnet_organization_tailnet_list",
ok: {"organization": "example.com"}
on "GET" "/api/v2/organizations/example.com/tailnets"
=> Response::json(json!({"tailnets": [], "totalCount": 0})),
err: {"organization": "example.com"}
),
api_contract!(
"tailnet_organization_tailnet_create",
ok: {"organization": "example.com", "display_name": "Production staging"}
on "POST" "/api/v2/organizations/example.com/tailnets"
=> Response::json(json!({"id": "T111111CNTRL", "displayName": "Production staging"})),
err: {"organization": "example.com", "display_name": "Production staging"}
),
api_contract!(
"tailnet_organization_tailnet_delete",
ok: {"tailnet": "T111111CNTRL"} on "DELETE" "/api/v2/tailnet/T111111CNTRL"
=> Response::empty(),
err: {"tailnet": "T111111CNTRL"}
),
]
}
const TLPUB: &str = "tlpub:0000000000000000000000000000000000000000000000000000000000000000";
const NODEKEY: &str = "nodekey:0000000000000000000000000000000000000000000000000000000000000000";
const DISABLEMENT_SECRET: &str = "disablement-secret:00112233445566778899aabbccddeeff";
const DISABLEMENT_HEX: &str = "00112233445566778899aabbccddeeff";
async fn session(meta: &ToolMeta, arrangement: &Arrangement) -> harness::Harness {
let mut setup = Setup::new().toolsets(meta.toolset.as_str()).tier(meta.tier);
for (argv, reply) in &arrangement.cli {
setup = setup.cli_answers(argv, reply.clone());
}
for (method, path, response) in &arrangement.api {
setup = setup.api_answers(method, path, response.clone()).await;
}
setup.start().await
}
fn arguments(meta: &ToolMeta, args: &Value) -> Value {
let mut args = args.clone();
if meta.requires_confirmation
&& let Some(object) = args.as_object_mut()
{
object.insert("confirm".to_owned(), json!(true));
}
args
}
fn table() -> Vec<ToolMeta> {
tailscale_mcp::tools::entries()
.into_iter()
.map(|e| e.meta)
.collect()
}
fn contract_for(name: &str) -> Contract {
contracts()
.into_iter()
.find(|c| c.tool == name)
.unwrap_or_else(|| panic!("no contract for `{name}`"))
}
#[test]
fn every_tool_has_a_contract() {
let declared: BTreeSet<&str> = table().iter().map(|m| m.name).collect();
let covered: BTreeSet<&str> = contracts().iter().map(|c| c.tool).collect();
let uncovered: Vec<&&str> = declared.difference(&covered).collect();
assert!(
uncovered.is_empty(),
"these tools have no contract row, so nothing checks what they do: {uncovered:?}"
);
let invented: Vec<&&str> = covered.difference(&declared).collect();
assert!(
invented.is_empty(),
"these contract rows name tools that do not exist: {invented:?}"
);
}
#[tokio::test]
async fn every_tool_is_named_for_the_surface_it_acts_on() {
for meta in table() {
assert!(
meta.name.starts_with(meta.surface().prefix()),
"`{}` belongs to the {} surface but is not named for it",
meta.name,
meta.surface().as_str()
);
}
}
#[tokio::test]
async fn every_tool_describes_itself_the_way_its_tier_says() {
for meta in table() {
let harness = session(&meta, &Arrangement::default()).await;
let tool = harness
.tool(meta.name)
.await
.unwrap_or_else(|| panic!("`{}` is not offered by its own toolset", meta.name));
assert!(
tool.description.as_deref().is_some_and(|d| !d.is_empty()),
"`{}` has no description, so a model cannot choose it",
meta.name
);
let annotations = tool
.annotations
.unwrap_or_else(|| panic!("`{}` is not annotated", meta.name));
let (read_only, destructive) = if meta.varying_tier {
(false, true)
} else {
(meta.tier == Tier::Read, meta.tier == Tier::Destructive)
};
assert_eq!(
annotations.read_only_hint,
Some(read_only),
"`{}` is at the {} tier",
meta.name,
meta.tier
);
assert_eq!(
annotations.destructive_hint,
Some(destructive),
"`{}` is at the {} tier",
meta.name,
meta.tier
);
assert_eq!(
annotations.idempotent_hint,
Some(meta.idempotent),
"`{}` declares idempotent: {}",
meta.name,
meta.idempotent
);
assert_eq!(annotations.open_world_hint, Some(true), "{}", meta.name);
harness.shutdown().await;
}
}
#[tokio::test]
async fn every_tool_answers_its_success_case() {
for meta in table() {
let contract = contract_for(meta.name);
let (args, arrangement) = contract.success;
let harness = session(&meta, &arrangement).await;
if !meta.runs_here() {
let error = harness.call_err(meta.name, arguments(&meta, &args)).await;
assert_eq!(
error["code"],
"unsupported_platform",
"`{}` does not exist on {} and should say so: {error:#}",
meta.name,
std::env::consts::OS
);
harness.shutdown().await;
continue;
}
let answer = harness.call_ok(meta.name, arguments(&meta, &args)).await;
assert!(
answer.is_object(),
"`{}` answered with something a client cannot destructure: {answer}",
meta.name
);
harness.shutdown().await;
}
}
#[tokio::test]
async fn every_tool_answers_its_failure_case_with_the_code_it_promised() {
for meta in table() {
let contract = contract_for(meta.name);
let (args, arrangement, code) = contract.failure;
let code = if meta.runs_here() {
code
} else {
"unsupported_platform"
};
let harness = session(&meta, &arrangement).await;
let error = harness.call_err(meta.name, arguments(&meta, &args)).await;
assert_eq!(
error["code"], code,
"`{}` failed with the wrong code: {error:#}",
meta.name
);
assert!(
error["message"].as_str().is_some_and(|m| !m.is_empty()),
"`{}` failed without a message: {error:#}",
meta.name
);
harness.shutdown().await;
}
}
#[tokio::test]
async fn every_tool_that_needs_confirming_refuses_without_it() {
for meta in table().into_iter().filter(|m| m.requires_confirmation) {
let contract = contract_for(meta.name);
let (args, arrangement) = contract.success;
let harness = session(&meta, &arrangement).await;
let error = harness.call_err(meta.name, args).await;
assert_eq!(
error["code"], "confirmation_required",
"`{}` ran without being confirmed",
meta.name
);
harness.shutdown().await;
}
}
#[tokio::test]
async fn each_preset_offers_more_than_the_one_below_it() {
for tier in [Tier::Read, Tier::Write, Tier::Destructive] {
let mut previous: Option<(&str, BTreeSet<String>)> = None;
for preset in Preset::ALL {
let harness = Setup::new()
.preset(preset.as_str())
.tier(tier)
.start()
.await;
let offered: BTreeSet<String> = harness.tool_names().await.into_iter().collect();
if let Some((smaller, below)) = &previous {
let lost: Vec<&String> = below.difference(&offered).collect();
assert!(
lost.is_empty(),
"moving from {smaller} to {} at the {tier} tier loses {lost:?}",
preset.as_str()
);
}
previous = Some((preset.as_str(), offered));
harness.shutdown().await;
}
}
}
#[tokio::test]
async fn no_tool_is_reachable_from_a_session_that_did_not_ask_for_its_toolset() {
let occupied: BTreeSet<&str> = table().iter().map(|m| m.toolset.as_str()).collect();
for meta in table() {
let elsewhere: Vec<&str> = occupied
.iter()
.copied()
.filter(|t| *t != meta.toolset.as_str())
.collect();
if elsewhere.is_empty() {
continue;
}
let harness = Setup::new()
.toolsets(&elsewhere.join(","))
.tier(Tier::Destructive)
.start()
.await;
assert!(
harness.tool(meta.name).await.is_none(),
"`{}` is offered by a session that did not select {}",
meta.name,
meta.toolset.as_str()
);
let error = harness.call_err(meta.name, json!({})).await;
assert_eq!(error["code"], "not_permitted", "{}", meta.name);
harness.shutdown().await;
}
}
#[tokio::test]
async fn no_preset_offers_a_debug_tool_at_any_tier() {
let debug: BTreeSet<&str> = table()
.iter()
.filter(|m| m.toolset == tailscale_mcp::meta::Toolset::LocalDebug)
.map(|m| m.name)
.collect();
assert_eq!(debug.len(), 30, "the debug toolset is thirty tools");
for preset in [Preset::Minimal, Preset::Core, Preset::Full] {
for tier in [Tier::Read, Tier::Write, Tier::Destructive] {
let harness = Setup::new()
.preset(preset.as_str())
.tier(tier)
.start()
.await;
let offered: BTreeSet<String> = harness.tool_names().await.into_iter().collect();
let leaked: Vec<&&str> = debug
.iter()
.filter(|name| offered.contains(**name))
.collect();
assert!(
leaked.is_empty(),
"the {} preset at the {tier} tier offers {leaked:?}",
preset.as_str()
);
harness.shutdown().await;
}
}
}
#[tokio::test]
async fn the_knobs_need_the_write_tier_as_well_as_the_toolset() {
let readers: BTreeSet<&str> = table()
.iter()
.filter(|m| m.toolset == tailscale_mcp::meta::Toolset::LocalDebug && m.tier == Tier::Read)
.map(|m| m.name)
.collect();
let knobs: BTreeSet<&str> = table()
.iter()
.filter(|m| m.toolset == tailscale_mcp::meta::Toolset::LocalDebug && m.tier == Tier::Write)
.map(|m| m.name)
.collect();
assert_eq!((readers.len(), knobs.len()), (22, 8));
let harness = Setup::new()
.toolsets("local-debug")
.tier(Tier::Read)
.start()
.await;
let offered: BTreeSet<String> = harness.tool_names().await.into_iter().collect();
for reader in &readers {
assert!(offered.contains(*reader), "`{reader}` is a read-tier tool");
}
for knob in &knobs {
assert!(!offered.contains(*knob), "`{knob}` needs the write tier");
let error = harness.call_err(knob, json!({})).await;
assert_eq!(error["code"], "not_permitted", "{knob}");
}
harness.shutdown().await;
let harness = Setup::new()
.toolsets("local-debug")
.tier(Tier::Write)
.start()
.await;
let offered: BTreeSet<String> = harness.tool_names().await.into_iter().collect();
for knob in &knobs {
assert!(
offered.contains(*knob),
"`{knob}` is on offer at the write tier"
);
}
harness.shutdown().await;
}
fn minimal_arguments(tool: &rmcp::model::Tool) -> Value {
let schema = &tool.input_schema;
let properties = schema.get("properties").and_then(Value::as_object);
let required = schema
.get("required")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut args = serde_json::Map::new();
for name in required.iter().filter_map(Value::as_str) {
let property = properties.and_then(|p| p.get(name));
args.insert(name.to_owned(), emptiest_value(property));
}
Value::Object(args)
}
fn emptiest_value(property: Option<&Value>) -> Value {
let kind = property
.and_then(|p| p.get("type"))
.map(|t| match t {
Value::Array(types) => types
.iter()
.filter_map(Value::as_str)
.find(|t| *t != "null")
.unwrap_or("string")
.to_owned(),
other => other.as_str().unwrap_or("string").to_owned(),
})
.unwrap_or_else(|| "string".to_owned());
match kind.as_str() {
"integer" | "number" => json!(1),
"boolean" => json!(false),
"array" => json!([emptiest_value(
property
.and_then(|p| p.get("items"))
.filter(|i| i.is_object())
)]),
_ => json!("x"),
}
}
#[tokio::test]
async fn no_debug_tool_runs_an_excluded_subcommand() {
let harness = Setup::new()
.toolsets("local-debug")
.tier(Tier::Destructive)
.start()
.await;
let route = json!({"route": "fd7a:115c:a1e0:b1a:0:7:a01:0/112"});
let mut called = 0;
for tool in harness.tools().await {
let name = tool.name.to_string();
assert!(
name.starts_with("tailscale_debug_"),
"only debug tools should be on offer here, and `{name}` is not one"
);
let arguments = if name == "tailscale_debug_via" {
route.clone()
} else {
minimal_arguments(&tool)
};
let before = harness.cli_calls().len();
let _ = harness.call(&name, arguments.clone()).await;
let calls = harness.cli_calls();
assert!(
calls.len() > before,
"`{name}` answered without reaching the client, so nothing was \
proved about what it runs; it was called with {arguments}"
);
for argv in &calls[before..] {
for excluded in tailscale_mcp::tools::local_debug::EXCLUDED {
let words: Vec<String> = excluded.path.split(' ').map(str::to_owned).collect();
assert!(
!argv.starts_with(&words),
"`{name}` ran `{}`, which is the excluded `{}`",
argv.join(" "),
excluded.path
);
}
}
called += 1;
}
assert_eq!(called, 30, "every debug tool has to be exercised, not most");
harness.shutdown().await;
}
#[tokio::test]
async fn no_excluded_debug_subcommand_is_reachable_as_a_tool() {
let harness = Setup::new()
.toolsets("local-debug")
.tier(Tier::Destructive)
.start()
.await;
let offered: BTreeSet<String> = harness.tool_names().await.into_iter().collect();
for excluded in tailscale_mcp::tools::local_debug::EXCLUDED {
let name = format!("tailscale_{}", excluded.path.replace([' ', '-'], "_"));
assert!(
!offered.contains(&name),
"`{}` is excluded but `{name}` is on offer",
excluded.path
);
let error = harness.call_err(&name, json!({})).await;
assert_eq!(error["code"], "not_found", "{name}");
}
harness.shutdown().await;
}
async fn passthrough(tier: Tier, answers: &[(&[&str], Reply)]) -> harness::Harness {
let mut setup = Setup::new().toolsets("local-passthrough").tier(tier);
for (argv, reply) in answers {
setup = setup.cli_answers(argv, reply.clone());
}
setup.start().await
}
#[tokio::test]
async fn the_passthrough_is_reached_by_naming_it_and_no_other_way() {
let broad = Setup::new()
.preset("full")
.tier(Tier::Destructive)
.start()
.await;
assert!(
!broad
.tool_names()
.await
.contains(&"tailscale_run".to_owned()),
"`full` offered the passthrough"
);
broad.shutdown().await;
let named = Setup::new()
.preset("full")
.toolsets("+local-passthrough")
.tier(Tier::Destructive)
.start()
.await;
assert!(
named
.tool_names()
.await
.contains(&"tailscale_run".to_owned()),
"adding the toolset did not offer the passthrough"
);
named.shutdown().await;
}
#[tokio::test]
async fn at_the_read_tier_the_command_decides_what_may_run() {
let harness = passthrough(
Tier::Read,
&[(&["status"], Reply::ok(harness::fixture("status.json")))],
)
.await;
let answer = harness
.call_ok("tailscale_run", json!({"args": ["status", "--json"]}))
.await;
assert_eq!(answer["tier"], "read");
assert_eq!(answer["covered"], true);
let error = harness
.call_err("tailscale_run", json!({"args": ["down"]}))
.await;
assert_eq!(error["code"], "not_permitted", "{error:#}");
assert!(
error["hint"]
.as_str()
.is_some_and(|h| h.contains("--allow-destructive")),
"the refusal should name the switch that would allow it: {error:#}"
);
assert!(
harness
.cli_calls()
.iter()
.all(|argv| argv.first().map(String::as_str) != Some("down")),
"`down` was refused and should not have run"
);
harness.shutdown().await;
}
#[tokio::test]
async fn an_unknown_subcommand_is_judged_at_the_top() {
for tier in [Tier::Read, Tier::Write] {
let harness = passthrough(tier, &[]).await;
let error = harness
.call_err("tailscale_run", json!({"args": ["nonesuch"]}))
.await;
assert_eq!(
error["code"], "not_permitted",
"at the {tier} tier: {error:#}"
);
assert!(harness.cli_calls().iter().all(|argv| argv != &["nonesuch"]));
harness.shutdown().await;
}
let harness = passthrough(Tier::Destructive, &[(&["nonesuch"], Reply::ok("it ran\n"))]).await;
let answer = harness
.call_ok("tailscale_run", json!({"args": ["nonesuch"]}))
.await;
assert_eq!(answer["tier"], "destructive");
assert_eq!(
answer["covered"], false,
"an unknown command must say the tier was a refusal to guess: {answer:#}"
);
harness.shutdown().await;
}
#[tokio::test]
async fn every_excluded_command_is_refused() {
let harness = passthrough(Tier::Destructive, &[]).await;
let mut checked = 0;
for excluded in tailscale_mcp::tools::passthrough::excluded() {
let words: Vec<&str> = excluded.path.split(' ').collect();
let before = harness.cli_calls().len();
let error = harness
.call_err("tailscale_run", json!({"args": words}))
.await;
assert_eq!(
error["code"], "not_permitted",
"`{}` was not refused: {error:#}",
excluded.path
);
assert!(
error["message"]
.as_str()
.is_some_and(|m| m.contains(excluded.reason)),
"`{}` was refused without its reason: {error:#}",
excluded.path
);
assert!(
error["hint"].is_null(),
"`{}` is refused by no switch, so nothing should suggest one: {error:#}",
excluded.path
);
assert_eq!(
harness.cli_calls().len(),
before,
"`{}` reached the client",
excluded.path
);
checked += 1;
}
assert_eq!(checked, 23, "the whole exclusion list has to be walked");
let runnable = harness
.call_err("tailscale_run", json!({"args": ["debug", "reload-config"]}))
.await;
assert_ne!(
runnable["code"], "not_permitted",
"`debug reload-config` is deliberately runnable (DECISIONS Q44): {runnable:#}"
);
harness.shutdown().await;
}
#[tokio::test]
async fn a_command_cannot_be_disguised_past_its_own_judgement() {
let harness = passthrough(Tier::Destructive, &[]).await;
let before = harness.cli_calls().len();
let error = harness
.call_err("tailscale_run", json!({"args": ["DEBUG", "PREFS"]}))
.await;
assert_eq!(error["code"], "not_permitted", "{error:#}");
assert_eq!(
harness.cli_calls().len(),
before,
"a shouted `debug prefs` reached the client"
);
harness.shutdown().await;
let harness = passthrough(Tier::Write, &[]).await;
let before = harness.cli_calls().len();
let error = harness
.call_err("tailscale_run", json!({"args": ["serve", "--bg", "reset"]}))
.await;
assert_eq!(error["code"], "not_permitted", "{error:#}");
assert_eq!(
harness.cli_calls().len(),
before,
"a write-tier session reset the serve configuration"
);
harness.shutdown().await;
let harness = passthrough(Tier::Destructive, &[]).await;
let before = harness.cli_calls().len();
let error = harness
.call_err("tailscale_run", json!({"args": ["serve", "--bg", "reset"]}))
.await;
assert_eq!(error["code"], "confirmation_required", "{error:#}");
assert_eq!(
harness.cli_calls().len(),
before,
"the serve configuration was reset without a confirmation"
);
harness.shutdown().await;
}
#[tokio::test]
async fn nothing_a_caller_writes_is_parsed_by_a_shell() {
let awkward = "; rm -rf / & $(whoami) `id` | tee /tmp/x #'\"";
let harness = passthrough(Tier::Read, &[(&["ping"], Reply::ok("pong\n"))]).await;
harness
.call_ok("tailscale_run", json!({"args": ["ping", awkward]}))
.await;
let ran = harness
.cli_calls()
.into_iter()
.find(|argv| argv.first().map(String::as_str) == Some("ping"))
.expect("`ping` ran");
assert_eq!(
ran,
vec!["ping".to_owned(), awkward.to_owned()],
"the argument was split, quoted or expanded on its way to the client"
);
harness.shutdown().await;
}
fn hand_written_arguments(name: &str) -> Option<Value> {
Some(match name {
"tailscale_debug_via" => json!({"route": "fd7a:115c:a1e0:b1a:0:7:a01:0/112"}),
"tailscale_lock_add" => json!({"keys": [TLPUB]}),
"tailscale_lock_remove" => json!({"keys": [TLPUB]}),
"tailscale_lock_init" => json!({"trusted_keys": [TLPUB], "confirm": true}),
"tailscale_lock_disable" => json!({"secret": DISABLEMENT_SECRET, "confirm": true}),
"tailscale_lock_disablement_kdf" => json!({"secret": DISABLEMENT_HEX}),
"tailscale_lock_revoke_keys" => json!({"keys": [TLPUB], "confirm": true}),
"tailscale_lock_sign" => json!({"key": NODEKEY}),
"tailscale_prefs_set" => json!({"nickname": "workstation"}),
"tailscale_serve_get_config" => json!({"all": true}),
"tailscale_serve_set_config" => json!({"all": true, "configuration": {}}),
_ => return None,
})
}
#[tokio::test]
async fn the_covered_table_follows_the_tools_it_claims_to_follow() {
use std::collections::BTreeMap;
use tailscale_mcp::tools::passthrough::{COVERED, Known, classify};
let harness = Setup::new()
.preset("full")
.toolsets("+local-debug")
.tier(Tier::Destructive)
.start()
.await;
let mut derived: BTreeMap<String, (Tier, bool)> = BTreeMap::new();
let mut elsewhere: std::collections::BTreeSet<String> = Default::default();
for meta in table() {
if meta.name == "tailscale_run" {
continue;
}
if meta.surface() == tailscale_mcp::meta::Surface::Tailnet {
continue;
}
if !meta.runs_here() {
let contract = contract_for(meta.name);
let argv: Vec<String> = contract.success.1.cli[0]
.0
.iter()
.map(|word| (*word).to_owned())
.collect();
let (path, _) = classify(&argv).unwrap_or_else(|e| {
panic!(
"`{}` is contracted to run `{}`, which the passthrough refuses to read: {e:?}",
meta.name,
argv.join(" ")
)
});
elsewhere.insert(path);
continue;
}
let tool = harness
.tool(meta.name)
.await
.unwrap_or_else(|| panic!("`{}` was not offered", meta.name));
let mut arguments =
hand_written_arguments(meta.name).unwrap_or_else(|| minimal_arguments(&tool));
if meta.requires_confirmation
&& let Some(object) = arguments.as_object_mut()
{
object.insert("confirm".to_owned(), json!(true));
}
let before = harness.cli_calls().len();
let _ = harness.call(meta.name, arguments.clone()).await;
let calls = harness.cli_calls();
let ran = &calls[before..];
assert_eq!(
ran.len(),
1,
"`{}` ran {} commands, and this test reads one; it was called with \
{arguments}",
meta.name,
ran.len()
);
let argv = &ran[0];
if meta.name == "tailscale_debug_file_list" {
assert_eq!(argv, &["debug", "--file=get"]);
let error = classify(argv).expect_err("a bare `debug` cannot be judged");
assert_eq!(error.code, tailscale_mcp::error::ErrorCode::InvalidArgs);
continue;
}
let (path, known) = classify(argv).unwrap_or_else(|e| {
panic!(
"`{}` ran `{}`, which the passthrough refuses to read: {e:?}",
meta.name,
argv.join(" ")
)
});
let Known::Covered(row) = known else {
panic!(
"`{}` runs `tailscale {path}`, which is in no row of COVERED",
meta.name
);
};
assert!(
row.tier >= meta.tier,
"`tailscale {path}` is {} in COVERED but `{}` runs it at {}",
row.tier,
meta.name,
meta.tier
);
assert!(
row.confirm >= meta.requires_confirmation,
"`{}` confirms and `tailscale {path}` does not",
meta.name
);
let entry = derived.entry(path).or_insert((Tier::Read, false));
entry.0 = entry.0.max(meta.tier);
entry.1 |= meta.requires_confirmation;
}
for row in COVERED {
let Some(&(tier, confirm)) = derived.get(row.path) else {
assert!(
elsewhere.contains(row.path),
"no tool runs `tailscale {}`, so its row states nobody's terms",
row.path
);
continue;
};
assert_eq!(
(row.tier, row.confirm),
(tier, confirm),
"`tailscale {}` is stricter in COVERED than every tool that runs it",
row.path
);
}
harness.shutdown().await;
}