diff --git a/docs/isolation.md b/docs/isolation.md index 600c203..1291ec5 100644 --- a/docs/isolation.md +++ b/docs/isolation.md @@ -11,10 +11,14 @@ A pane starts with your access: its program, an agent included, can read and wri | Network | What the pane's programs get | |---|---| | Open | Your network, as you. | -| Only these providers | Only the hosts and ports that the agents you choose name in their manifests, through a proxy nerd runs for that pane alone. The agent must honour `HTTPS_PROXY`; one that does not gets no network. Agents whose manifests name different ports cannot share a pane. | +| Only these providers | Only the hosts and ports that the agents you choose name in their manifests, through a proxy nerd runs for that pane alone. For pi, **Choose providers…** narrows that to some of its providers. The agent must honour `HTTPS_PROXY`; one that does not gets no network. Agents whose manifests name different ports cannot share a pane. | | None | No internet and no name lookups. Services on this Mac (`localhost`) stay reachable. | -The hosts in the agents' `plugin.json` are a starting point: check them against what your agent contacts. pi's names every provider pi ships with, except four you add yourself if you use them: Bedrock outside us-east-1 and eu-central-1, Vertex and Azure use addresses of your own account; Cloudflare (`api.cloudflare.com`, `gateway.ai.cloudflare.com`) can fetch any URL for an account token, so it would make the pane's network open. A pane's `~/.pi/agent/*.lock` is writable from every This-folder-only pane: one session can hold another pi session's lock, which only delays that session until the lock is deleted. +The hosts in the agents' `plugin.json` are a starting point: check them against what your agent contacts. pi's names every provider pi ships with, except four you add yourself if you use them: Bedrock outside us-east-1 and eu-central-1, Vertex and Azure use addresses of your own account; Cloudflare (`api.cloudflare.com`, `gateway.ai.cloudflare.com`) can fetch any URL for an account token, so it would make the pane's network open. In a pane set to Only these providers, Bedrock works with keys, not with SSO or profile login. + +**Choose providers…** appears under the chips once pi is chosen and lists pi's providers, each with the hosts it opens: `deepseek` opens `api.deepseek.com`, `openai` opens `api.openai.com` and `auth.openai.com`, `github-copilot` opens GitHub's Copilot hosts and `github.com`, which pi also downloads its tools from. **all** is every provider, those added in a later version of pi included; a list of providers stays that list, so a provider added later stays closed. The pane is saved as `pi/deepseek`, one entry per provider; a pane saved before this choice existed is saved as `pi` and keeps every provider. A provider that a later nerd renames or removes stops the pane rather than opening more: it does not start, and the notice names the pane and the provider; the **Isolation** window shows it as no longer in pi's manifest and drops it on save. Only built-in agents name providers; a plugin's or your own agent manifest names its hosts as one list. + +A pane's `~/.pi/agent/*.lock` is writable from every This-folder-only pane: one session can hold another pi session's lock, which only delays that session until the lock is deleted. **This folder only** also keeps one session from planting code or instructions for another. Inside the places it may write, these stay read-only: shell, git and editor startup files, `~/.local/bin` and other folders ahead on `PATH`, agents' settings and memory, Claude Code's task list, Codex's shared databases, Qwen Code's workflow scripts, what agents source or run later from their own folders, and the caches package managers run code from (pnpm, uv, deno, Go, Playwright, Poetry, Yarn, node-gyp, pip, Cypress, SwiftPM, Copilot). When the pane's folder is your home folder, its dot files, `~/Library` and `~/bin` stay read-only too, except `~/Library/Caches` and the agents' session folders; choose a project folder for a real limit. The **Isolation** window lists what stops working. diff --git a/docs/menus.md b/docs/menus.md index 687308d..8bb1038 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -67,6 +67,11 @@ Every window except a context menu is drawn through `chrome::dialog`, and `chrom exact delivery" when the pane's agent delivers through a hook that is not this build's. Then **Isolation…** as the pane's own tool. **Rename pane…** refuses a name another pane holds and asks keep or drop for a name with routes or waiting messages from before. +- In the Isolation window, once an agent with providers (pi) is chosen under Only these providers, a line under the + chips says which (`pi: all`, `pi: deepseek, openai`) beside **[Choose providers…]**. It opens the view **Choose + providers** inside the window: a row **all** with its host count, then a row per provider with its hosts. A click + ticks a row (all clears the providers; a provider clears all); **[Enter] done** writes the choice back, **[Esc] back** + returns unchanged; done with nothing ticked stays and says so. - Tab menu and space menu: **Isolation for new panes…**, with "as the space" or "as nerd's default". - Settings → Messages: Messaging, Programs in panes may stop or update nerd, Set up a topology…, Accounts…. The server stores them in policy.json, not in config.json. diff --git a/docs/privacy-and-security.md b/docs/privacy-and-security.md index 6513a37..1a76d6a 100644 --- a/docs/privacy-and-security.md +++ b/docs/privacy-and-security.md @@ -23,7 +23,7 @@ Each way out starts from something you do or approved, and writes a line to `dae | `git` | You add a plugin, update it or check for updates, or refresh the catalog (**Plugins…**, `nerd --plugin-add`, `--plugin-update`, `--plugin-catalog`). Never in the background. | The public GitHub address shown in the window. | git's own requests for that repository, from a `git` run with none of your git config, no home folder, no credentials and no proxy settings. | `git clone …` | | `ssh` | You connect to another computer (**Connect to another computer…**, `nerd --remote`). | The host you named in `remotes`, reached through your own `~/.ssh/config`, keys and agent, so a `ProxyJump` there decides the path. | nerd's protocol to the nerd on that machine, including what you type and what its panes print. | `remote: ssh …` | | A plugin with network | A plugin you installed with a network permission runs; approving it at install is what lets it reach the network later. | `"localhost"`: services on this Mac only, and whatever they pass on. Hosts and ports: only those hosts on those ports, through a proxy nerd runs for that plugin alone in a sandbox of its own; the plugin itself reaches only that proxy's port on this Mac's addresses and looks up no name. A name is reached at whatever public address its owner gives it, never this Mac or a network it is on; an IP address in the manifest is reached as written, and one on your local network or this Mac is named so when you are asked. The plugin must honour `HTTPS_PROXY`; plain `http://` does not work. | Anything the plugin can read, the command line and environment of your other programs included (macOS lets any of your processes read those). | `plugin … started: network …`; each connection and refusal as `plugin … proxy: …` | -| A pane set to Only these providers | You choose **Isolation…** → Network: Only these providers for a pane, and its program starts. | Only the hosts and ports the chosen agents' manifests name, through a proxy nerd runs for that pane alone in a sandbox of its own; the pane's programs reach only that proxy's port on this Mac and look up no names. The agent must honour `HTTPS_PROXY`; one that does not gets no network. | Anything the pane's programs send. | `pane N proxy: …` | +| A pane set to Only these providers | You choose **Isolation…** → Network: Only these providers for a pane, and its program starts. | Only the chosen agents' or chosen providers' hosts, on the ports their manifests name, through a proxy nerd runs for that pane alone in a sandbox of its own; the pane's programs reach only that proxy's port on this Mac and look up no names. The agent must honour `HTTPS_PROXY`; one that does not gets no network. | Anything the pane's programs send. | `pane N proxy: …` | | A link or a file | You open a link in a pane, or a file with **Open in its app**. | `http(s)` in your browser, `mailto:` in your mail app, a file in its app. Files that hold an address of their own (`.webloc`, `.inetloc`, `.url`, `.fileloc`, `.ftploc`, `.afploc`, `.vncloc`, `.mailloc`) are not opened; a page opened in the browser may still load addresses of its own, and a file opened in its app runs as that app does (a `.command` or an app runs code as you). | The address. | `link opened …`, with the host for `http(s)` and only the kind otherwise | | The clipboard | You copy. | The macOS clipboard, and through OSC 52 the terminal you run nerd in, which may be on another machine over `ssh`. | The text you copied. | `copied N characters` | | Claude Code's inbox | A message from another pane, or from you, is delivered to a Claude Code pane whose session reported its inbox socket. | That session's own socket on this Mac, used only after nerd has checked that a process of that pane listens on it. | The message, its first line naming the sender: `[message from via nerd, id ; …]`. | `message … via inbox: delivered` | diff --git a/plugins/pi/plugin.json b/plugins/pi/plugin.json index 3232834..6e5acbb 100644 --- a/plugins/pi/plugin.json +++ b/plugins/pi/plugin.json @@ -12,6 +12,46 @@ "flag": ["--continue", "-c", "--resume", "-r", "--no-session"] }, "data": ["~/.pi/agent/sessions", "~/.pi/agent/*.lock"], - "network": { "hosts": ["ai-gateway.vercel.sh", "api.ant-ling.com", "api.anthropic.com", "api.business.githubcopilot.com", "api.cerebras.ai", "api.deepseek.com", "api.enterprise.githubcopilot.com", "api.fireworks.ai", "api.github.com", "api.githubcopilot.com", "api.groq.com", "api.individual.githubcopilot.com", "api.kimi.com", "api.meta.ai", "api.minimax.io", "api.minimaxi.com", "api.mistral.ai", "api.moonshot.ai", "api.moonshot.cn", "api.openai.com", "api.together.ai", "api.typesafe.ai", "api.x.ai", "api.xiaomimimo.com", "api.z.ai", "auth.kimi.com", "auth.meta.com", "auth.openai.com", "auth.x.ai", "bedrock-runtime.eu-central-1.amazonaws.com", "bedrock-runtime.us-east-1.amazonaws.com", "chatgpt.com", "claude.ai", "console.anthropic.com", "generativelanguage.googleapis.com", "github.com", "inference.baseten.co", "integrate.api.nvidia.com", "open.bigmodel.cn", "opencode.ai", "openrouter.ai", "platform.claude.com", "radius.pi.dev", "router.huggingface.co", "token-plan-ams.xiaomimimo.com", "token-plan-cn.xiaomimimo.com", "token-plan-sgp.xiaomimimo.com", "token-plan.ap-southeast-1.maas.aliyuncs.com", "token-plan.cn-beijing.maas.aliyuncs.com", "us.openrouter.ai"], "ports": [443] } + "network": { + "providers": { + "amazon-bedrock": ["bedrock-runtime.eu-central-1.amazonaws.com", "bedrock-runtime.us-east-1.amazonaws.com"], + "ant-ling": ["api.ant-ling.com"], + "anthropic": ["api.anthropic.com", "claude.ai", "platform.claude.com"], + "baseten": ["inference.baseten.co"], + "cerebras": ["api.cerebras.ai"], + "deepseek": ["api.deepseek.com"], + "fireworks": ["api.fireworks.ai"], + "github-copilot": ["api.business.githubcopilot.com", "api.enterprise.githubcopilot.com", "api.github.com", "api.individual.githubcopilot.com", "github.com"], + "google": ["generativelanguage.googleapis.com"], + "groq": ["api.groq.com"], + "huggingface": ["router.huggingface.co"], + "kimi-coding": ["api.kimi.com", "auth.kimi.com"], + "meta": ["api.meta.ai", "auth.meta.com"], + "minimax": ["api.minimax.io"], + "minimax-cn": ["api.minimaxi.com"], + "mistral": ["api.mistral.ai"], + "moonshotai": ["api.moonshot.ai"], + "moonshotai-cn": ["api.moonshot.cn"], + "nvidia": ["integrate.api.nvidia.com"], + "openai": ["api.openai.com", "auth.openai.com"], + "openai-codex": ["auth.openai.com", "chatgpt.com"], + "opencode": ["opencode.ai"], + "openrouter": ["openrouter.ai", "us.openrouter.ai"], + "qwen-token-plan": ["token-plan.ap-southeast-1.maas.aliyuncs.com"], + "qwen-token-plan-cn": ["token-plan.cn-beijing.maas.aliyuncs.com"], + "radius": ["radius.pi.dev"], + "together": ["api.together.ai"], + "typesafe": ["api.typesafe.ai"], + "vercel-ai-gateway": ["ai-gateway.vercel.sh"], + "xai": ["api.x.ai", "auth.x.ai"], + "xiaomi": ["api.xiaomimimo.com"], + "xiaomi-token-plan-ams": ["token-plan-ams.xiaomimimo.com"], + "xiaomi-token-plan-cn": ["token-plan-cn.xiaomimimo.com"], + "xiaomi-token-plan-sgp": ["token-plan-sgp.xiaomimimo.com"], + "zai": ["api.z.ai"], + "zai-coding-cn": ["open.bigmodel.cn"] + }, + "ports": [443] + } } } diff --git a/src/agents.rs b/src/agents.rs index e2ae87c..41b7e9c 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -126,6 +126,53 @@ fn host(h: &str) -> Option { (name && labels.last()?.starts_with(|c: char| c.is_ascii_lowercase())).then(|| h.to_string()) } +/// An agent's hosts: one list, or (built-ins only) named provider groups a pane may choose from, `net` being their union. +/// ceiling: ports are the agent's, so a provider on other ports cannot be a group; a port list per group would lift it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AgentNetwork { + pub net: Network, + /// Sorted by name; empty when the manifest names `hosts`. + pub providers: Vec<(String, Vec)>, +} + +const GROUPS: usize = 64; +const AGENT_NETWORK_FORMS: &str = r#"agent.network is {"hosts": [...], "ports": [...]} (built-in agents may group their hosts as {"providers": {"name": [hosts], ...}, "ports": [...]})"#; + +impl<'de> Deserialize<'de> for AgentNetwork { + fn deserialize>(d: D) -> Result { + agent_network(serde_json::Value::deserialize(d)?).map_err(serde::de::Error::custom) + } +} + +fn agent_network(mut v: serde_json::Value) -> Result { + use serde_json::Value; + let Some(groups) = v.as_object_mut().and_then(|o| o.remove("providers")) else { + return Ok(AgentNetwork { net: network(&v)?, providers: Vec::new() }); + }; + let Value::Object(groups) = groups else { return Err(format!("{AGENT_NETWORK_FORMS}; providers must be an object")) }; + if v.get("hosts").is_some() { + return Err(format!("{AGENT_NETWORK_FORMS}; hosts or providers, not both")); + } + if groups.is_empty() || groups.len() > GROUPS { + return Err(format!("{AGENT_NETWORK_FORMS}; 1 to {GROUPS} providers")); + } + let mut providers = Vec::new(); + for (name, hosts) in groups { + if !token(&name) { + return Err(format!("provider {name:?} must be lowercase letters, digits and dashes")); + } + let list = hosts.as_array().filter(|a| !a.is_empty()).ok_or_else(|| format!("provider {name:?} must list its hosts"))?; + let mut hosts = list.iter().map(|h| h.as_str().and_then(host).ok_or_else(|| format!("provider {name:?}: host {h} must be a lowercase domain name or an IP address"))).collect::, _>>()?; + hosts.sort(); + hosts.dedup(); + providers.push((name, hosts)); + } + providers.sort(); + let union: Vec<&String> = providers.iter().flat_map(|(_, h)| h).collect(); + v["hosts"] = serde_json::json!(union); + Ok(AgentNetwork { net: network(&v)?, providers }) +} + impl Network { /// Whether everything this reaches, `wider` reaches too. pub fn within(&self, wider: &Network) -> bool { @@ -236,7 +283,7 @@ pub struct Agent { pub data: Vec, /// Its provider's hosts and ports, for a pane set to "only these providers". #[serde(default)] - pub network: Network, + pub network: AgentNetwork, /// One sentence: how to turn the agent's own sandbox off. #[serde(default)] pub own_sandbox: Option, @@ -559,7 +606,7 @@ fn folder_rules(root: &Path, dir: &Path, problems: &mut Vec) { } } -fn token(s: &str) -> bool { +pub fn token(s: &str) -> bool { !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') } @@ -583,9 +630,13 @@ pub fn validate(m: &Manifest, others: &[Manifest]) -> Result<(), String> { return Err(format!("{} is a built-in's name", m.name)); } let many = |n: &Network| matches!(n, Network::Internet { hosts, .. } if hosts.len() > HOSTS); - if !m.builtin && (many(&m.permissions.network) || many(&a.network)) { + if !m.builtin && (many(&m.permissions.network) || many(&a.network.net)) { return Err(format!("{NETWORK_FORMS}; at most {HOSTS} hosts and 8 ports")); } + // a plugin that regrouped its hosts would change what a saved pane reaches without asking + if !m.builtin && !a.network.providers.is_empty() { + return Err("agent.network.providers is for built-in agents only: name the hosts".into()); + } if let Some(o) = others.iter().find(|o| o.name == m.name) { return Err(format!("{} is already taken by {}", m.name, if o.builtin { "a built-in" } else { "another plugin" })); } @@ -700,7 +751,7 @@ fn validate_mail(m: &Manifest) -> Result<(), String> { return Err(format!("data {d:?} {why}: an isolated pane could change what the agent runs")); } } - if a.network == Network::Localhost { + if a.network.net == Network::Localhost { return Err("an agent's network names its hosts and ports".into()); } Ok(()) @@ -1343,6 +1394,93 @@ mod tests { }); } + #[test] + fn an_agent_network_is_hosts_or_a_built_ins_provider_groups() { + use serde_json::json; + let n = |v: serde_json::Value| agent_network(v); + let hosts = n(json!({ "hosts": ["b.dev", "a.dev"], "ports": [443] })).unwrap(); + assert_eq!(hosts, AgentNetwork { net: Network::Internet { hosts: vec!["a.dev".into(), "b.dev".into()], ports: vec![443] }, providers: Vec::new() }); + let groups = n(json!({ "providers": { "zed": ["z.dev", "a.dev"], "acme": ["a.dev"] }, "ports": [443] })).unwrap(); + assert_eq!(groups.providers, vec![("acme".to_string(), vec!["a.dev".to_string()]), ("zed".into(), vec!["a.dev".into(), "z.dev".into()])]); + assert_eq!(groups.net, Network::Internet { hosts: vec!["a.dev".into(), "z.dev".into()], ports: vec![443] }, "the union"); + assert_eq!(n(serde_json::Value::Null), Ok(AgentNetwork::default())); + let many = |k: usize, per: usize| json!({ "providers": (0..k).map(|i| (format!("g{i}"), json!((0..per).map(|j| format!("h{i}-{j}.dev")).collect::>()))).collect::>(), "ports": [443] }); + for (bad, says) in [ + (json!({ "hosts": ["a.dev"], "providers": { "a": ["a.dev"] }, "ports": [443] }), "not both"), + (json!({ "providers": { "a": [] }, "ports": [443] }), "must list its hosts"), + (json!({ "providers": {}, "ports": [443] }), "1 to 64 providers"), + (json!({ "providers": ["a.dev"], "ports": [443] }), "must be an object"), + (json!({ "providers": { "A": ["a.dev"] }, "ports": [443] }), "lowercase letters"), + (json!({ "providers": { "a/b": ["a.dev"] }, "ports": [443] }), "lowercase letters"), + (json!({ "providers": { "a": ["A.dev"] }, "ports": [443] }), "lowercase domain name"), + (json!({ "providers": { "a": ["a.dev"] } }), "ports must be a list"), + (json!({ "providers": { "a": ["a.dev"] }, "ports": [443], "other": 1 }), "\"other\" is not one of its keys"), + (many(65, 1), "1 to 64 providers"), + (many(13, 5), "at most 16 hosts"), + ] { + let e = n(bad.clone()).unwrap_err(); + assert!(e.contains(says), "{bad}: {e}"); + } + assert!(n(many(64, 1)).is_ok(), "64 groups"); + let mut builtin = fake(json!({ "network": many(16, 4) })); + builtin.builtin = true; + assert!(validate(&builtin, &builtins()).is_ok(), "a built-in's union may reach 64 hosts"); + assert!(refused(json!({ "network": many(2, 1) })).contains("for built-in agents only"), "a user's or a plugin's manifest names hosts"); + assert!(refused(json!({ "network": many(17, 1) })).contains("at most 16 hosts"), "a user's union counts against 16"); + } + + #[test] + fn pis_provider_groups_are_pinned() { + const WHY: &str = "renaming or removing a group strands panes saved with it: see docs/isolation.md"; + let pi = builtins().into_iter().find(|m| m.name == "pi").unwrap().agent.network; + let want: &[(&str, &[&str])] = &[ + ("amazon-bedrock", &["bedrock-runtime.eu-central-1.amazonaws.com", "bedrock-runtime.us-east-1.amazonaws.com"]), + ("ant-ling", &["api.ant-ling.com"]), + ("anthropic", &["api.anthropic.com", "claude.ai", "platform.claude.com"]), + ("baseten", &["inference.baseten.co"]), + ("cerebras", &["api.cerebras.ai"]), + ("deepseek", &["api.deepseek.com"]), + ("fireworks", &["api.fireworks.ai"]), + ("github-copilot", &["api.business.githubcopilot.com", "api.enterprise.githubcopilot.com", "api.github.com", "api.individual.githubcopilot.com", "github.com"]), + ("google", &["generativelanguage.googleapis.com"]), + ("groq", &["api.groq.com"]), + ("huggingface", &["router.huggingface.co"]), + ("kimi-coding", &["api.kimi.com", "auth.kimi.com"]), + ("meta", &["api.meta.ai", "auth.meta.com"]), + ("minimax", &["api.minimax.io"]), + ("minimax-cn", &["api.minimaxi.com"]), + ("mistral", &["api.mistral.ai"]), + ("moonshotai", &["api.moonshot.ai"]), + ("moonshotai-cn", &["api.moonshot.cn"]), + ("nvidia", &["integrate.api.nvidia.com"]), + ("openai", &["api.openai.com", "auth.openai.com"]), + ("openai-codex", &["auth.openai.com", "chatgpt.com"]), + ("opencode", &["opencode.ai"]), + ("openrouter", &["openrouter.ai", "us.openrouter.ai"]), + ("qwen-token-plan", &["token-plan.ap-southeast-1.maas.aliyuncs.com"]), + ("qwen-token-plan-cn", &["token-plan.cn-beijing.maas.aliyuncs.com"]), + ("radius", &["radius.pi.dev"]), + ("together", &["api.together.ai"]), + ("typesafe", &["api.typesafe.ai"]), + ("vercel-ai-gateway", &["ai-gateway.vercel.sh"]), + ("xai", &["api.x.ai", "auth.x.ai"]), + ("xiaomi", &["api.xiaomimimo.com"]), + ("xiaomi-token-plan-ams", &["token-plan-ams.xiaomimimo.com"]), + ("xiaomi-token-plan-cn", &["token-plan-cn.xiaomimimo.com"]), + ("xiaomi-token-plan-sgp", &["token-plan-sgp.xiaomimimo.com"]), + ("zai", &["api.z.ai"]), + ("zai-coding-cn", &["open.bigmodel.cn"]), + ]; + let names: Vec<&str> = pi.providers.iter().map(|(g, _)| g.as_str()).collect(); + assert_eq!(names, want.iter().map(|(g, _)| *g).collect::>(), "{WHY}"); + for ((g, hosts), (wg, wh)) in pi.providers.iter().zip(want) { + assert_eq!((g.as_str(), hosts.iter().map(String::as_str).collect::>()), (*wg, wh.to_vec()), "{WHY}"); + } + let Network::Internet { hosts, ports } = &pi.net else { panic!("pi names hosts") }; + assert_eq!((hosts.len(), ports.as_slice()), (48, &[443][..])); + assert!(!hosts.iter().any(|h| h == "console.anthropic.com" || h == "api.githubcopilot.com"), "pi-ai 1.3.0 names neither: {hosts:?}"); + } + #[test] fn a_plugins_network_is_none_localhost_or_named_ports_and_true_is_refused() { let n = |v: serde_json::Value| network(&v); diff --git a/src/app/commands.rs b/src/app/commands.rs index 560b382..0b09792 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1553,7 +1553,7 @@ impl App { /// Isolation… on a pane, or Isolation for new panes… on a tab or a space, as they are now. pub(super) fn isolation_overlay(&self, target: IsolationTarget) -> Overlay { let agents = crate::agents::all(); - let providers: Vec = agents.iter().filter(|m| matches!(m.agent.network, crate::agents::Network::Internet { .. })).map(|m| m.name.clone()).collect(); + let providers: Vec = agents.iter().filter(|m| matches!(m.agent.network.net, crate::agents::Network::Internet { .. })).map(|m| m.name.clone()).collect(); let notes = agents.iter().filter_map(|m| Some(format!("{}: {}", m.title, m.agent.own_sandbox.as_ref()?))).collect(); let accounts = crate::policy::current().accounts.iter().map(|a| a.name.clone()).collect(); let (about, mut pick, inherit, agent, folder) = match target { @@ -1572,15 +1572,16 @@ impl App { }; let canon = |p: &std::path::Path| std::fs::canonicalize(p).ok(); let home = crate::paths::home().is_some_and(|h| canon(&h).is_some() && canon(&h) == canon(&folder)); - let chosen = match pick.as_ref().map(|i| &i.network) { - Some(crate::policy::Network::Providers(p)) if p.iter().any(|a| providers.contains(a)) => p.iter().filter(|a| providers.contains(a)).cloned().collect(), - _ => agent.filter(|a| providers.contains(a)).or_else(|| providers.first().cloned()).into_iter().collect(), + let groups: Vec<_> = agents.iter().filter(|m| !m.agent.network.providers.is_empty()).map(|m| (m.name.clone(), m.agent.network.clone())).collect(); + let (chosen, stale) = match pick.as_ref().map(|i| &i.network) { + Some(crate::policy::Network::Providers(p)) => crate::isolation_ui::known(p, &providers, &groups), + _ => (agent.filter(|a| providers.contains(a)).or_else(|| providers.first().cloned()).into_iter().map(|a| (a, None)).collect(), Vec::new()), }; - // Save keeps what the chips show, never a name that has no chip. + // Save keeps what the window shows: never a name or a group that is gone, never wider than saved. if let Some(crate::policy::Isolation { network: crate::policy::Network::Providers(p), .. }) = pick.as_mut() { - p.clone_from(&chosen); + *p = crate::policy::entries(&chosen); } - Overlay::Isolation(crate::isolation_ui::IsolationWin { target, about, pick, inherit, accounts, providers, chosen, notes, home }) + Overlay::Isolation(crate::isolation_ui::IsolationWin { target, about, pick, inherit, accounts, providers, groups, chosen, stale, view: None, notes, home }) } pub(super) fn isolation_outcome(&mut self, w: crate::isolation_ui::IsolationWin, out: crate::isolation_ui::Out) -> Overlay { diff --git a/src/app/lifecycle.rs b/src/app/lifecycle.rs index 718ba88..fc07d81 100644 --- a/src/app/lifecycle.rs +++ b/src/app/lifecycle.rs @@ -51,7 +51,7 @@ impl App { crate::session::clear_screens(); return false; } - let mut restored = 0usize; + let (mut restored, mut failed) = (0usize, Vec::new()); self.rebuild_tabs(&snap, |app, ps| { let launch = restore_launch(ps); // Whatever the pane was running, its own process is the shell again. @@ -59,7 +59,12 @@ impl App { // Stored size, so replay and the pty agree until the first resize; 0 would make a 0-row grid. // Clamped to 1000: a hand-edited snapshot must not allocate a giant grid. let (cols, rows) = if ps.cols == 0 || ps.rows == 0 { (80, 24) } else { (ps.cols.min(1000), ps.rows.min(1000)) }; - let mut pty = Pty::spawn(ps.id, &argv, &ps.cwd, cols, rows, app.bell.clone(), &ps.isolation).map_err(|e| crate::log::error(&format!("pane {}: not restored: {e}", ps.id))).ok()?; + let mut pty = Pty::spawn(ps.id, &argv, &ps.cwd, cols, rows, app.bell.clone(), &ps.isolation) + .map_err(|e| { + crate::log::error(&format!("pane {}: not restored: {e}", ps.id)); + failed.push(format!("{}: {e}", ps.label.clone().unwrap_or_else(|| format!("pane {}", ps.id)))); + }) + .ok()?; if let Some(cmd) = &launch { // Typed in as the human would type it, so leaving the agent lands on the prompt. match restore_line(cmd) { @@ -77,6 +82,9 @@ impl App { let _ = std::fs::remove_file(crate::session::screen_path(ps.id)); Some(Pane { command, label: ps.label.clone(), agent_session: ps.session(), isolation: ps.isolation.clone(), ..Pane::new(ps.id, term, pty, ps.cwd.clone(), argv) }) }); + if !failed.is_empty() { + self.notice = Some(format!("{} not restored: {}", if failed.len() == 1 { "1 pane".into() } else { format!("{} panes", failed.len()) }, failed.join("; "))); + } let stale = crate::session::clear_screens(); if restored > 0 || stale > 0 { crate::log::info(&format!("screens: {restored} restored, {stale} removed")); diff --git a/src/app/mod.rs b/src/app/mod.rs index 6f4e296..71c1a02 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1966,6 +1966,38 @@ mod tests { }); } + #[test] + fn isolation_keeps_a_saved_provider_choice_and_never_turns_what_is_gone_into_the_agent() { + use crate::isolation_ui::Out; + use crate::policy::{Files, Isolation, Network}; + let only = |e: &[&str]| Isolation { files: Files::Everything, network: Network::Providers(e.iter().map(|s| s.to_string()).collect()) }; + let open = |app: &App, t| match app.isolation_overlay(t) { + Overlay::Isolation(w) => w, + _ => panic!("no window"), + }; + let enter = |w: &mut crate::isolation_ui::IsolationWin| w.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + crate::config::tests::with_temp_home(|| { + let mut app = named(); + app.pane_mut(1).unwrap().isolation = only(&["pi/deepseek"]); + assert_eq!(enter(&mut open(&app, IsolationTarget::Pane(1))), Out::Save(Some(only(&["pi/deepseek"])), false), "a pane's subset, opened and saved"); + app.spaces[0].tabs[0].isolation_new = Some(only(&["pi/openai", "pi/deepseek"])); + assert_eq!(enter(&mut open(&app, IsolationTarget::Tab(0, 0))), Out::Save(Some(only(&["pi/deepseek", "pi/openai"])), false), "a tab's subset"); + app.pane_mut(1).unwrap().isolation = only(&["pi/xyz", "pi/deepseek"]); + let mut w = open(&app, IsolationTarget::Pane(1)); + let text: String = w.lines(&crate::render::Theme::dark()).iter().flatten().map(|(t, _, _)| t.as_str()).collect(); + assert!(text.contains("xyz: no longer in pi's manifest") && text.contains("pi: deepseek"), "{text}"); + assert_eq!(enter(&mut w), Out::Save(Some(only(&["pi/deepseek"])), false), "a gone provider is dropped on save"); + for gone in [&["pi/xyz"][..], &["foo"]] { + app.pane_mut(1).unwrap().isolation = only(gone); + let mut w = open(&app, IsolationTarget::Pane(1)); + let out = enter(&mut w); + assert_eq!(out, Out::Save(Some(only(&[])), false), "{gone:?}: never pi"); + app.isolation_outcome(w, out); + assert_eq!((app.notice.as_deref(), &app.pane(1).unwrap().isolation), (Some("no provider is chosen"), &only(gone)), "{gone:?}: refused, the pane unchanged"); + } + }); + } + #[test] fn rename_reads_the_label_of_a_pane_in_another_tab() { let mut app = named(); diff --git a/src/app/model.rs b/src/app/model.rs index ae98f4c..20c2f73 100644 --- a/src/app/model.rs +++ b/src/app/model.rs @@ -299,6 +299,7 @@ impl App { Ok(pty) => pty, Err(e) => { crate::log::error(&format!("pane {id}: the shell would not start again: {e}")); + self.notice = Some(format!("{}: the shell would not start again: {e}", ps.label.clone().unwrap_or_else(|| format!("pane {id}")))); return false; } }; diff --git a/src/isolation.rs b/src/isolation.rs index b7723ce..238a49c 100644 --- a/src/isolation.rs +++ b/src/isolation.rs @@ -88,9 +88,36 @@ pub fn wrap(id: PaneId, argv: &[String], cwd: &Path, iso: &Isolation, env: &mut static SHARED: std::sync::Mutex> = std::sync::Mutex::new(None); -/// What a pane set to "only these providers" reaches: the chosen agents' hosts on the ports they all name. -pub fn providers_net(agents: &[String]) -> Result { - merge(agents.iter().map(|a| (a.as_str(), crate::agents::get(a).map(|m| m.agent.network).unwrap_or_default()))) +/// What a pane set to "only these providers" reaches: the chosen agents' hosts, or their chosen providers' hosts, on the ports they all name. +pub fn providers_net(entries: &[String]) -> Result { + resolve(entries, |a| crate::agents::get(a).map(|m| m.agent.network).unwrap_or_default()) +} + +/// A group that is gone or unknown is refused, never read as the whole agent. +fn resolve(entries: &[String], network_of: impl Fn(&str) -> crate::agents::AgentNetwork) -> Result { + use crate::agents::Network::Internet; + let (chosen, bad) = crate::policy::chosen(entries); + if let Some(e) = bad.first() { + return Err(format!("{e:?} is not an agent or an agent/provider")); + } + let mut nets = Vec::new(); + for (agent, groups) in &chosen { + let n = network_of(agent); + let net = match (groups, &n.net) { + (None, _) | (Some(_), crate::agents::Network::None | crate::agents::Network::Localhost) => n.net, + (Some(_), Internet { .. }) if n.providers.is_empty() => return Err(format!("{agent} has no providers to choose from")), + (Some(groups), Internet { ports, .. }) => { + let mut hosts = Vec::new(); + for g in groups { + let (_, h) = n.providers.iter().find(|(name, _)| name == g).ok_or_else(|| format!("{agent} has no provider {g} any more"))?; + hosts.extend(h.iter().cloned()); + } + Internet { hosts, ports: ports.clone() } + } + }; + nets.push((agent.as_str(), net)); + } + merge(nets.into_iter()) } /// The proxy keeps one port list for all its hosts, so agents naming different ports cannot share a pane. @@ -654,9 +681,69 @@ mod tests { assert!(!app.restart_pane(1), "its shell is refused, never started unwrapped"); let log = std::fs::read_to_string(crate::session::state_dir().join("daemon.log")).unwrap(); assert!(log.contains("the shell would not start again: no-such-agent names no hosts"), "{log}"); + assert_eq!(app.notice.as_deref(), Some("pane 1: the shell would not start again: no-such-agent names no hosts")); + }); + } + + #[test] + fn panes_whose_saved_provider_is_gone_are_not_restored_and_the_notice_names_them() { + use crate::app::{App, Pane, Space, Tab}; + crate::session::tests::with_temp_state_dir("restore-gone", || { + let mut app = App::new(crate::config::Config::default()); + let pane = |id, iso: &[&str], label: Option<&str>| { + let (pty, _) = crate::pty::Pty::fake(id, std::fs::File::create("/dev/null").unwrap()); + let mut p = Pane::new(id, crate::term::Term::new(80, 24, 100), pty, "/".into(), vec!["/bin/zsh".into()]); + p.isolation = Isolation { files: Files::Folder, network: Network::Providers(iso.iter().map(|s| s.to_string()).collect()) }; + p.label = label.map(Into::into); + p + }; + let mut layout = crate::layout::Layout::new(1); + layout.split(crate::layout::Dir::Horizontal, 2); + let tab = Tab { name: None, layout, panes: [(1, pane(1, &["pi/xyz"], Some("reader"))), (2, pane(2, &["pi/deepseek", "nobody"], None))].into(), isolation_new: None }; + app.spaces.push(Space { name: None, cwd: "/".into(), tabs: vec![tab], active_tab: 0, isolation_new: None }); + crate::session::save(&app.snapshot()).unwrap(); + let mut again = App::new(crate::config::Config::default()); + again.restore(); + let notice = again.notice.unwrap_or_default(); + assert!(notice.starts_with("2 panes not restored: ") && notice.contains("reader: pi has no provider xyz any more") && notice.contains("pane 2: nobody names no hosts"), "{notice}"); + assert_eq!(again.spaces.iter().flat_map(|s| &s.tabs).map(|t| t.panes.len()).sum::(), 0, "nothing started in their place"); }); } + #[test] + fn a_chosen_provider_opens_only_its_hosts_and_a_gone_one_nothing() { + use crate::agents::{AgentNetwork, Network::Internet}; + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + let fixture = |a: &str| match a { + "fake" => AgentNetwork { net: Internet { hosts: s(&["a.dev", "b.dev", "c.dev"]), ports: vec![443] }, providers: vec![("alpha".into(), s(&["a.dev"])), ("beta".into(), s(&["b.dev", "c.dev"]))] }, + "plain" => AgentNetwork { net: Internet { hosts: s(&["p.dev"]), ports: vec![443] }, providers: Vec::new() }, + _ => AgentNetwork::default(), + }; + let r = |e: &[&str]| resolve(&s(e), fixture).map(|n| n.hosts); + assert_eq!(r(&["fake/alpha"]), Ok(s(&["a.dev"]))); + assert_eq!(r(&["fake/beta", "fake/alpha"]), Ok(s(&["a.dev", "b.dev", "c.dev"]))); + assert_eq!(r(&["fake"]), Ok(s(&["a.dev", "b.dev", "c.dev"])), "the agent alone: all its providers"); + assert_eq!(r(&["fake/alpha", "fake"]), Ok(s(&["a.dev", "b.dev", "c.dev"])), "the agent beside a group: all"); + assert_eq!(r(&["fake/alpha", "fake/alpha"]), Ok(s(&["a.dev"])), "duplicates are harmless"); + assert_eq!(r(&["plain", "fake/alpha"]), Ok(s(&["a.dev", "p.dev"]))); + assert_eq!(r(&["fake/gone"]), Err("fake has no provider gone any more".into()), "never the whole agent"); + assert_eq!(r(&["fake/alpha", "fake/gone"]), Err("fake has no provider gone any more".into())); + assert_eq!(r(&["plain/x"]), Err("plain has no providers to choose from".into())); + assert_eq!(r(&["nobody/x"]), Err("nobody names no hosts".into())); + assert_eq!(r(&["fake/"]), Err("\"fake/\" is not an agent or an agent/provider".into())); + assert_eq!(r(&["fake/a/b"]), Err("\"fake/a/b\" is not an agent or an agent/provider".into())); + assert_eq!(r(&[]), Err("no provider is chosen".into())); + } + + #[test] + fn pis_saved_choices_resolve_through_its_manifest() { + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::>(); + assert_eq!(providers_net(&s(&["pi"])).unwrap().hosts.len(), 48, "an old saved pi is every provider"); + assert_eq!(providers_net(&s(&["pi/deepseek"])).unwrap().hosts, ["api.deepseek.com"]); + assert_eq!(providers_net(&s(&["pi/openai-codex"])).unwrap().hosts, ["auth.openai.com", "chatgpt.com"]); + assert_eq!(providers_net(&s(&["pi/xyz"])).unwrap_err(), "pi has no provider xyz any more"); + } + #[test] fn providers_naming_different_ports_cannot_share_a_pane() { use crate::agents::Network::Internet; @@ -698,6 +785,8 @@ mod tests { let net = crate::proxy::Net::parse(&hosts).unwrap(); assert!(["api.anthropic.com", "accounts.google.com", "api.openai.com"].iter().all(|h| net.hosts.iter().filter(|n| n == h).count() == 1), "every chosen agent's hosts, each once: {hosts}"); assert_eq!(net.ports, [443], "{hosts}"); + let got = wrap(3, &argv, Path::new("/w"), &Isolation { files: Files::Everything, network: Network::Providers(vec!["pi/deepseek".into()]) }, &mut env).unwrap(); + assert_eq!(got.windows(2).find(|w| w[0] == "--hosts").map(|w| w[1].as_str()), Some("api.deepseek.com;443"), "{got:?}"); let e = wrap(3, &argv, Path::new("/w"), &Isolation { files: Files::Account("nobody-here".into()), network: Network::Open }, &mut env).unwrap_err(); assert_eq!(e.to_string(), "nobody-here is not an admitted account"); } diff --git a/src/isolation_ui.rs b/src/isolation_ui.rs index b73c771..3cd8e56 100644 --- a/src/isolation_ui.rs +++ b/src/isolation_ui.rs @@ -6,7 +6,8 @@ use ratatui::style::{Modifier, Style}; use crate::app::IsolationTarget; use crate::messages::{Drawn, Line, chip}; -use crate::policy::{Account, Files, Isolation, Network}; +use crate::agents::AgentNetwork; +use crate::policy::{Account, Chosen, Files, Isolation, Network, entries}; use crate::render::Theme; #[derive(Clone, Debug, PartialEq)] @@ -17,6 +18,12 @@ pub enum Click { Network(Network), /// A provider's chip: on or off among the chosen. Provider(String), + /// Choose providers… for this agent. + Choose(String), + /// In Choose providers: every provider, one provider on or off, done. + All, + Group(String), + Done, /// true: and restart the program. Save(bool), Admit, @@ -48,15 +55,35 @@ pub struct IsolationWin { pub accounts: Vec, /// Agents whose manifest names their provider's hosts. pub providers: Vec, - /// The ones "only these providers" means until others are chosen. - pub chosen: Vec, + /// The networks of the agents among them that name provider groups. + pub groups: Vec<(String, AgentNetwork)>, + /// What "only these providers" means until others are chosen: per agent, all or some of its providers. + pub chosen: Chosen, + /// Saved entries no chip or manifest stands for now: shown, and never saved again. + pub stale: Vec, + /// Choose providers, open over the window. + pub view: Option, /// `: <how to turn its own sandbox off>`, one per agent that says. pub notes: Vec<String>, /// The pane's folder (a tab's or space's: the space's) is the home folder itself. pub home: bool, } +/// Choose providers for one agent: what is ticked (None: all), the first line shown, and whether done found nothing ticked. +#[derive(Clone, Debug, PartialEq)] +pub struct View { + pub agent: String, + pub draft: Option<Vec<String>>, + pub scroll: usize, + pub empty: bool, + /// The last line that can be first, from the last draw: keys stop where the wheel does. + pub last: std::cell::Cell<usize>, +} + const CANCEL: &str = " [Esc] cancel "; +const BACK: &str = " [Esc] back "; +const DONE: &str = " [Enter] done "; +const NOTHING_TICKED: &str = "choose at least one provider, or all"; /// Seatbelt refuses a setuid program under a profile. const HOME_FOLDER: &str = "This pane's folder is your home folder, so it can change your files there, though not dot files, ~/Library or ~/bin; choose a project folder for a real limit."; /// What a This-folder-only pane costs, each line in what stops working. @@ -120,12 +147,19 @@ impl IsolationWin { if self.providers.is_empty() { out.push(radio(false, "Only these providers: no agent names its hosts".into(), None)); } else { - let mut l = radio(provider, "Only these providers: ".into(), Some(Click::Network(Network::Providers(self.chosen.clone())))); + let mut l = radio(provider, "Only these providers: ".into(), Some(Click::Network(Network::Providers(entries(&self.chosen))))); for p in &self.providers { - let lit = (provider && self.chosen.contains(p)).then(|| theme.cursor()); + let lit = (provider && self.chosen.iter().any(|(a, _)| a == p)).then(|| theme.cursor()); chip(&mut l, p, lit, Some(Click::Provider(p.clone())), theme); } out.push(l); + for (a, gs) in self.chosen.iter().filter(|(a, _)| provider && self.groups.iter().any(|(g, _)| g == a)) { + let said = gs.as_ref().map_or_else(|| "all".to_string(), |gs| gs.join(", ")); + out.push(vec![(format!(" {a}: {said} "), g.fg(theme.text), None), ("[Choose providers…]".into(), g.fg(theme.accent), Some(Click::Choose(a.clone())))]); + } + if provider { + out.extend(self.stale.iter().map(|s| vec![(format!(" {s}"), g.fg(theme.warn), None)])); + } } out.push(radio(network == Some(&Network::None), "None".into(), Some(Click::Network(Network::None)))); let folder = files == Some(&Files::Folder); @@ -146,7 +180,41 @@ impl IsolationWin { out } + /// Choose providers: all, then each provider with its hosts, cut to `width`. + fn view_lines(&self, v: &View, width: usize, theme: &Theme) -> Vec<Line<Click>> { + let g = Style::default().bg(theme.ground); + let n = self.groups.iter().find(|(a, _)| *a == v.agent).map(|(_, n)| n.clone()).unwrap_or_default(); + let all = match &n.net { + crate::agents::Network::Internet { hosts, .. } => hosts.len(), + _ => 0, + }; + let mark = |on: bool| if on { "●" } else { "○" }; + let mut out: Vec<Line<Click>> = vec![vec![(format!("{}: providers of {}", self.about, v.agent), g.fg(theme.accent).add_modifier(Modifier::BOLD), None)], Vec::new()]; + out.push(vec![(format!(" {} all ({all} hosts)", mark(v.draft.is_none())), g.fg(theme.text), Some(Click::All))]); + let w = n.providers.iter().map(|(p, _)| p.chars().count()).max().unwrap_or(0); + for (p, hosts) in &n.providers { + let on = v.draft.as_ref().is_some_and(|d| d.contains(p)); + let head = format!(" {} {p:<w$} ", mark(on)); + let room = width.saturating_sub(head.chars().count()); + let hit = Some(Click::Group(p.clone())); + out.push(vec![(head, g.fg(theme.text), hit.clone()), (crate::messages::cut(&hosts.join(", "), room), g.fg(theme.muted), hit)]); + } + if v.empty { + out.push(Vec::new()); + out.push(vec![(NOTHING_TICKED.into(), g.fg(theme.warn), None)]); + } + out + } + pub fn draw(&self, buf: &mut Buffer, screen: Rect, theme: &Theme) -> Drawn<Click> { + if let Some(v) = &self.view { + let width = crate::chrome::limits(screen).0.saturating_sub(2 * crate::chrome::DIALOG_FRAME.horizontal) as usize; + let lines = self.view_lines(v, width, theme); + let r = crate::messages::fit(screen, &lines, DONE.len() + 1 + BACK.len()); + let d = crate::messages::draw(buf, r, "Choose providers", theme, &lines, v.scroll, &[(DONE.to_string(), Click::Done)], BACK); + v.last.set(d.rows.saturating_sub(d.room)); + return d; + } let lines = self.lines(theme); let buttons = self.buttons(); let bottom = buttons.iter().map(|(b, _)| b.chars().count() + 1).sum::<usize>() + CANCEL.len(); @@ -161,22 +229,64 @@ impl IsolationWin { Click::Network(n) => self.pick.get_or_insert_default().network = n, Click::Provider(p) => { let on = matches!(self.pick.as_ref().map(|i| &i.network), Some(Network::Providers(_))); - match self.chosen.iter().position(|c| *c == p) { - _ if !on => self.chosen = vec![p], + match self.chosen.iter().position(|(a, _)| *a == p) { + _ if !on => self.chosen = vec![(p, None)], Some(i) if self.chosen.len() > 1 => _ = self.chosen.remove(i), Some(_) => {} - None => self.chosen.push(p), + None => self.chosen.push((p, None)), } - self.pick.get_or_insert_default().network = Network::Providers(self.chosen.clone()); + self.pick.get_or_insert_default().network = Network::Providers(entries(&self.chosen)); + } + Click::Choose(agent) => { + let draft = self.chosen.iter().find(|(a, _)| *a == agent).and_then(|(_, gs)| gs.clone()); + self.view = Some(View { agent, draft, scroll: 0, empty: false, last: Default::default() }); } + Click::All | Click::Group(_) | Click::Done => self.view_click(c), Click::Save(restart) => return Out::Save(self.pick.clone(), restart), _ => {} } Out::None } + fn view_click(&mut self, c: Click) { + let Some(v) = self.view.as_mut() else { return }; + v.empty = false; + match c { + Click::All => v.draft = None, + Click::Group(p) => match v.draft.as_mut() { + None => v.draft = Some(vec![p]), + Some(d) if d.contains(&p) => d.retain(|x| *x != p), + Some(d) => d.push(p), + }, + Click::Done if v.draft.as_ref().is_some_and(Vec::is_empty) => v.empty = true, + Click::Done => { + let Some(View { agent, mut draft, .. }) = self.view.take() else { return }; + if let Some(d) = draft.as_mut() { + d.sort(); + } + if let Some((_, gs)) = self.chosen.iter_mut().find(|(a, _)| *a == agent) { + *gs = draft; + } + if let Some(i) = self.pick.as_mut().filter(|i| matches!(i.network, Network::Providers(_))) { + i.network = Network::Providers(entries(&self.chosen)); + } + } + _ => {} + } + } + pub fn key(&mut self, k: crossterm::event::KeyEvent) -> Out { use crossterm::event::KeyCode::*; + if let Some(v) = self.view.as_mut() { + match crate::keys::normalize(k).code { + Enter => self.view_click(Click::Done), + Esc => self.view = None, + Up => v.scroll = v.scroll.saturating_sub(1), + Down => v.scroll = (v.scroll + 1).min(v.last.get()), + _ => {} + } + return Out::None; + } match crate::keys::normalize(k).code { Enter => self.click(Click::Save(false)), Char('r') if self.inherit.is_none() => self.click(Click::Save(true)), @@ -186,6 +296,17 @@ impl IsolationWin { } pub fn mouse(&mut self, ev: crossterm::event::MouseEvent, d: &Drawn<Click>) -> Out { + use crossterm::event::MouseEventKind::{ScrollDown, ScrollUp}; + if let Some(v) = self.view.as_mut() { + match (ev.kind, pressed(ev, d)) { + (ScrollUp, _) => v.scroll = v.scroll.saturating_sub(3), + (ScrollDown, _) => v.scroll = (v.scroll + 3).min(v.last.get()), + (_, Err(_)) => self.view = None, + (_, Ok(Some(c))) => _ = self.click(c), + (_, Ok(None)) => {} + } + return Out::None; + } match pressed(ev, d) { Err(out) => out, Ok(Some(c)) => self.click(c), @@ -194,6 +315,31 @@ impl IsolationWin { } } +/// The saved providers entries a chip and a manifest's group still stand for, and words for the rest. +pub fn known(saved: &[String], providers: &[String], groups: &[(String, AgentNetwork)]) -> (Chosen, Vec<String>) { + let (chosen, bad) = crate::policy::chosen(saved); + let mut stale: Vec<String> = bad.iter().map(|e| format!("{e}: not an agent or an agent/provider")).collect(); + let mut out = Vec::new(); + for (a, gs) in chosen { + if !providers.contains(&a) { + let why = if crate::agents::get(&a).is_some() { "names no hosts to choose from" } else { "no such agent is installed" }; + stale.push(format!("{a}: {why}")); + continue; + } + let Some(gs) = gs else { + out.push((a, None)); + continue; + }; + let have = groups.iter().find(|(n, _)| *n == a).map(|(_, n)| &n.providers); + let (keep, gone): (Vec<String>, Vec<String>) = gs.into_iter().partition(|g| have.is_some_and(|h| h.iter().any(|(n, _)| n == g))); + stale.extend(gone.iter().map(|g| format!("{g}: no longer in {a}'s manifest"))); + if !keep.is_empty() { + out.push((a, Some(keep))); + } + } + (out, stale) +} + /// Accounts…: the admitted accounts, and the commands that make one. #[derive(Clone, Debug)] pub struct Accounts { @@ -338,12 +484,173 @@ mod tests { inherit: None, accounts: vec!["scout".into()], providers: vec!["claude".into(), "codex".into()], - chosen: vec!["codex".into()], + groups: Vec::new(), + chosen: vec![("codex".into(), None)], + stale: Vec::new(), + view: None, notes: vec!["Codex: run it with --sandbox danger-full-access".into()], home: false, } } + /// `fake` has the providers alpha (a.dev) and beta (b.dev, c.dev); the pane is set to only claude. + fn grouped() -> IsolationWin { + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>(); + let net = AgentNetwork { net: crate::agents::Network::Internet { hosts: s(&["a.dev", "b.dev", "c.dev"]), ports: vec![443] }, providers: vec![("alpha".into(), s(&["a.dev"])), ("beta".into(), s(&["b.dev", "c.dev"]))] }; + IsolationWin { + providers: s(&["claude", "fake"]), + groups: vec![("fake".into(), net)], + chosen: vec![("claude".into(), None)], + pick: Some(Isolation { files: Files::Everything, network: Network::Providers(s(&["claude"])) }), + ..pane() + } + } + + fn saved(w: &IsolationWin) -> Vec<String> { + match w.pick.as_ref().map(|i| &i.network) { + Some(Network::Providers(p)) => p.clone(), + n => panic!("{n:?}"), + } + } + + fn shown(w: &IsolationWin) -> String { + let screen = Rect::new(0, 0, 100, 40); + let mut buf = Buffer::empty(screen); + w.draw(&mut buf, screen, &Theme::dark()); + crate::app::snapshots::dump(&buf, screen) + } + + #[test] + fn choose_providers_ticks_by_mouse_and_done_writes_the_choice_back() { + let mut w = grouped(); + click_on(&mut w, " fake "); + assert_eq!(saved(&w), ["claude", "fake"]); + assert!(shown(&w).contains("fake: all [Choose providers…]"), "{}", shown(&w)); + click_on(&mut w, "[Choose providers…]"); + let view = shown(&w); + assert!(view.contains("Choose providers") && view.contains("● all (3 hosts)") && view.contains("○ alpha a.dev") && view.contains("○ beta b.dev, c.dev") && view.contains("[Enter] done") && view.contains("[Esc] back"), "{view}"); + click_on(&mut w, "alpha"); + assert!(shown(&w).contains("● alpha") && shown(&w).contains("○ all"), "a provider clears all"); + assert_eq!(saved(&w), ["claude", "fake"], "nothing changes until done"); + click_on(&mut w, "[Enter] done"); + assert_eq!((w.view.as_ref(), saved(&w)), (None, vec!["claude".to_string(), "fake/alpha".into()])); + assert!(shown(&w).contains("fake: alpha [Choose providers…]"), "{}", shown(&w)); + click_on(&mut w, "[Choose providers…]"); + click_on(&mut w, "beta"); + assert_eq!(w.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), Out::None, "Esc goes back, not out of the window"); + assert_eq!((w.view.as_ref(), saved(&w)), (None, vec!["claude".to_string(), "fake/alpha".into()]), "back leaves the choice"); + click_on(&mut w, "[Choose providers…]"); + click_on(&mut w, "alpha"); + click_on(&mut w, "[Enter] done"); + assert!(w.view.as_ref().is_some_and(|v| v.empty) && shown(&w).contains(NOTHING_TICKED), "nothing ticked stays: {}", shown(&w)); + click_on(&mut w, "all (3 hosts)"); + assert!(!shown(&w).contains(NOTHING_TICKED)); + assert_eq!(w.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), Out::None, "Enter is done in the view"); + assert_eq!(saved(&w), ["claude", "fake"]); + click_on(&mut w, "[Choose providers…]"); + click_on(&mut w, "[Esc] back"); + assert!(w.view.is_none(), "the back button"); + assert_eq!(click_on(&mut w, "[Enter] save"), Out::Save(Some(Isolation { files: Files::Everything, network: Network::Providers(vec!["claude".into(), "fake".into()]) }), false)); + } + + #[test] + fn the_only_agent_s_chip_keeps_its_providers() { + let mut w = IsolationWin { chosen: vec![("fake".into(), Some(vec!["alpha".into()]))], ..grouped() }; + w.pick = Some(Isolation { files: Files::Everything, network: Network::Providers(vec!["fake/alpha".into()]) }); + click_on(&mut w, " fake "); + assert_eq!(saved(&w), ["fake/alpha"], "the last chip stays, and never as all of the agent"); + } + + #[test] + fn leaving_the_view_by_mouse_keeps_the_choice() { + let mut w = grouped(); + w.click(Click::Provider("fake".into())); + w.click(Click::Choose("fake".into())); + w.click(Click::Group("alpha".into())); + w.click(Click::Done); + assert_eq!(saved(&w), ["claude", "fake/alpha"]); + click_on(&mut w, "[Choose providers…]"); + click_on(&mut w, "beta"); + click_on(&mut w, "[Esc] back"); + assert_eq!((w.view.as_ref(), saved(&w)), (None, vec!["claude".to_string(), "fake/alpha".into()]), "the back button drops the ticks"); + click_on(&mut w, "[Choose providers…]"); + click_on(&mut w, "all (3 hosts)"); + let screen = Rect::new(0, 0, 100, 40); + let d = w.draw(&mut Buffer::empty(screen), screen, &Theme::dark()); + assert!(!d.frame.contains(ratatui::layout::Position::new(0, 0))); + w.mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: 0, row: 0, modifiers: KeyModifiers::NONE }, &d); + assert_eq!((w.view.as_ref(), saved(&w)), (None, vec!["claude".to_string(), "fake/alpha".into()]), "a click outside drops the ticks"); + } + + #[test] + fn keys_scroll_the_view_no_further_than_the_wheel() { + let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>(); + let many: Vec<(String, Vec<String>)> = (0..60).map(|i| (format!("p{i:02}"), s(&[&format!("h{i}.dev")]))).collect(); + let hosts = many.iter().flat_map(|(_, h)| h.clone()).collect(); + let net = AgentNetwork { net: crate::agents::Network::Internet { hosts, ports: vec![443] }, providers: many }; + let mut w = IsolationWin { groups: vec![("fake".into(), net)], ..grouped() }; + w.click(Click::Provider("fake".into())); + w.click(Click::Choose("fake".into())); + shown(&w); + let last = w.view.as_ref().unwrap().last.get(); + assert!(last > 0, "60 providers do not fit 40 rows"); + let key = |w: &mut IsolationWin, c| _ = w.key(KeyEvent::new(c, KeyModifiers::NONE)); + for _ in 0..200 { + key(&mut w, KeyCode::Down); + } + assert_eq!(w.view.as_ref().unwrap().scroll, last); + key(&mut w, KeyCode::Up); + assert_eq!(w.view.as_ref().unwrap().scroll, last - 1, "one Up moves the view at once"); + } + + #[test] + fn every_provider_ticked_one_by_one_stays_a_list() { + let mut w = grouped(); + w.click(Click::Provider("fake".into())); + w.click(Click::Choose("fake".into())); + w.click(Click::Group("beta".into())); + w.click(Click::Group("alpha".into())); + w.click(Click::Done); + assert_eq!(saved(&w), ["claude", "fake/alpha", "fake/beta"], "a provider added to the manifest later stays closed"); + } + + #[test] + fn a_chip_turned_off_drops_every_entry_of_its_agent() { + let mut w = IsolationWin { chosen: vec![("fake".into(), Some(vec!["alpha".into()])), ("claude".into(), None)], ..grouped() }; + w.pick = Some(Isolation { files: Files::Everything, network: Network::Providers(entries(&w.chosen)) }); + click_on(&mut w, " fake "); + assert_eq!(saved(&w), ["claude"]); + click_on(&mut w, " fake "); + assert_eq!(saved(&w), ["claude", "fake"], "on again: all of it, as a chip says"); + } + + #[test] + fn no_click_sequence_puts_an_agent_beside_its_own_providers() { + let steps: [&[Click]; 6] = [ + &[Click::Provider("fake".into())], + &[Click::Provider("claude".into())], + &[Click::Choose("fake".into()), Click::All, Click::Done], + &[Click::Choose("fake".into()), Click::Group("alpha".into()), Click::Done], + &[Click::Choose("fake".into()), Click::Group("beta".into()), Click::Group("alpha".into()), Click::Done], + &[Click::Network(Network::Open)], + ]; + for n in 0..6usize.pow(4) { + let mut w = grouped(); + let order: Vec<usize> = (0..4).map(|i| n / 6usize.pow(i) % 6).collect(); + for &i in &order { + for c in steps[i] { + w.click(c.clone()); + } + w.view = None; + } + w.click(Click::Network(Network::Providers(entries(&w.chosen)))); + let e = saved(&w); + let (c, bad) = crate::policy::chosen(&e); + assert!(bad.is_empty() && entries(&c) == e, "{order:?}: normal form {e:?}"); + assert!(!(e.contains(&"fake".to_string()) && e.iter().any(|x| x.starts_with("fake/"))), "{order:?}: {e:?}"); + } + } + fn click_on(w: &mut IsolationWin, label: &str) -> Out { let screen = Rect::new(0, 0, 100, 40); let mut buf = Buffer::empty(screen); diff --git a/src/messages.rs b/src/messages.rs index 52847ad..f04461c 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -40,6 +40,14 @@ fn cells(s: &str) -> usize { crate::slots::cells(s) } +/// `s` in at most `max` characters, the last one `…` when cut. +pub fn cut(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + s.chars().take(max.saturating_sub(1)).chain(['…']).collect() +} + pub fn width<H>(l: &Line<H>) -> usize { l.iter().map(|(t, _, _)| cells(t)).sum() } @@ -193,6 +201,8 @@ const W: u8 = 8; const CELL: usize = 9; /// A card's height: the border, the name, the agent, the isolation, the border. const CH: usize = 5; +/// The widest a card's isolation line grows, so one pane's long list does not widen every card. +const CARD_WORDS: usize = 44; /// Between columns: the exit cell, six lanes, and the next column's exit cell. const GX: usize = 8; /// Between card rows: two lanes and the arrow row. @@ -972,7 +982,7 @@ fn card_text(c: &Card) -> ((String, String), String, String) { let (l2, l3) = match (c.name.as_str(), c.pane) { ("you", _) => ("the owner".into(), String::new()), (_, None) => ("no pane holds".into(), "this name now".into()), - _ => (c.agent.clone().unwrap_or_else(|| "shell".into()), c.isolation.words()), + _ => (c.agent.clone().unwrap_or_else(|| "shell".into()), cut(&c.isolation.words(), CARD_WORDS)), }; ((c.name.clone(), tag), l2, l3) } @@ -1428,6 +1438,19 @@ mod tests { assert!(drawn(&m, &v, 100, 20).1.contains("columns 1–3 of 7")); } + #[test] + fn a_cards_isolation_line_is_cut_at_its_width() { + let iso = |e: &[&str]| Isolation { files: Files::Folder, network: Network::Providers(e.iter().map(|s| s.to_string()).collect()) }; + let short = card("reader", Some(3), "main", Some("pi"), iso(&["pi/deepseek"])); + assert_eq!(card_text(&short).2, "this folder only, only pi (deepseek)"); + let long = card("reader", Some(3), "main", Some("pi"), iso(&["claude", "pi/deepseek", "pi/openai", "pi/xai", "pi/zai"])); + let l3 = card_text(&long).2; + assert_eq!((l3.chars().count(), l3.as_str()), (CARD_WORDS, "this folder only, only claude, pi (deepseek…")); + let two = card("reader", Some(3), "main", Some("gemini"), iso(&["claude", "gemini"])); + assert_eq!(card_text(&two).2, "this folder only, only claude, gemini", "a list that fit before still fits"); + assert_eq!(card_width(&long), 4 + CARD_WORDS); + } + #[test] fn the_map_keeps_its_lines_out_of_the_cards() { let theme = Theme::dark(); diff --git a/src/plugins.rs b/src/plugins.rs index 9a700e7..c415c55 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -470,7 +470,7 @@ pub fn abilities(m: &Manifest) -> Result<std::collections::BTreeSet<Ability>, St out.insert(Ability::Network(m.permissions.network.clone())); } if !m.agent.names.is_empty() { - out.insert(Ability::Agent { names: m.agent.names.clone(), config: m.agent.config.as_ref().map(|c| c.path.clone()), resume: m.agent.resume.clone(), network: m.agent.network.clone() }); + out.insert(Ability::Agent { names: m.agent.names.clone(), config: m.agent.config.as_ref().map(|c| c.path.clone()), resume: m.agent.resume.clone(), network: m.agent.network.net.clone() }); } Ok(out) } diff --git a/src/policy.rs b/src/policy.rs index 9b1cebf..7bd02d0 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -344,11 +344,64 @@ pub enum Files { pub enum Network { #[default] Open, - /// The agents whose manifest hosts are allowed. + /// Entries read by `entry`: `"pi"` is every provider of pi, `"pi/deepseek"` only that one. Providers(Vec<String>), None, } +/// A providers entry: the agent, and the one provider group it names, if any; None when malformed. +pub fn entry(s: &str) -> Option<(&str, Option<&str>)> { + let tok = crate::agents::token; + match s.split_once('/') { + None => tok(s).then_some((s, None)), + Some((a, g)) => (tok(a) && tok(g)).then_some((a, Some(g))), + } +} + +/// Per agent: every provider (None) or the groups named, sorted. +pub type Chosen = Vec<(String, Option<Vec<String>>)>; + +/// Providers entries by agent in first-seen order, and the malformed ones; the agent alone beside some of its groups is the whole agent. +pub fn chosen(entries: &[String]) -> (Chosen, Vec<String>) { + let (mut out, mut bad): (Chosen, Vec<String>) = (Vec::new(), Vec::new()); + for e in entries { + let Some((a, g)) = entry(e) else { + bad.push(e.clone()); + continue; + }; + match (out.iter_mut().find(|(x, _)| x == a), g) { + (Some((_, all)), None) => *all = None, + (Some((_, Some(gs))), Some(g)) => gs.push(g.into()), + (Some((_, None)), Some(_)) => {} + (None, g) => out.push((a.into(), g.map(|g| vec![g.into()]))), + } + } + for (_, gs) in &mut out { + if let Some(gs) = gs { + gs.sort(); + gs.dedup(); + } + } + (out, bad) +} + +/// What is saved: `pi`, or `pi/<group>` per group, sorted, never both. +pub fn entries(chosen: &[(String, Option<Vec<String>>)]) -> Vec<String> { + let mut out = Vec::new(); + for (a, gs) in chosen { + match gs { + None => out.push(a.clone()), + Some(gs) => { + let mut gs = gs.clone(); + gs.sort(); + gs.dedup(); + out.extend(gs.iter().map(|g| format!("{a}/{g}"))); + } + } + } + out +} + impl Isolation { pub fn is_open(&self) -> bool { self.files == Files::Everything && self.network == Network::Open @@ -363,7 +416,11 @@ impl Isolation { }; let network = match &self.network { Network::Open => None, - Network::Providers(a) => Some(format!("only {}", a.join(", "))), + Network::Providers(a) => { + let (c, bad) = chosen(a); + let said = c.iter().map(|(a, gs)| gs.as_ref().map_or_else(|| a.clone(), |gs| format!("{a} ({})", gs.join(", ")))); + Some(format!("only {}", said.chain(bad).collect::<Vec<_>>().join(", "))) + } Network::None => Some("no network".to_string()), }; files.into_iter().chain(network).collect::<Vec<_>>().join(", ") @@ -601,5 +658,25 @@ mod tests { let i = Isolation { files: Files::Account("scout".into()), network: Network::None }; assert_eq!(i.words(), "as scout, no network"); assert_eq!(Isolation { files: Files::Folder, network: Network::Providers(vec!["claude".into(), "gemini".into()]) }.words(), "this folder only, only claude, gemini"); + let only = |e: &[&str]| Isolation { files: Files::Everything, network: Network::Providers(e.iter().map(|s| s.to_string()).collect()) }.words(); + assert_eq!(only(&["pi/deepseek"]), "only pi (deepseek)"); + assert_eq!(only(&["pi"]), "only pi"); + assert_eq!(only(&["claude", "pi/openai", "pi/deepseek"]), "only claude, pi (deepseek, openai)"); + assert_eq!(only(&["pi/deepseek", "pi"]), "only pi", "the agent beside its groups is the whole agent"); + } + + #[test] + fn a_providers_entry_is_an_agent_or_an_agent_and_one_group() { + for (s, want) in [("pi", Some(("pi", None))), ("pi/deepseek", Some(("pi", Some("deepseek")))), ("open-code2/x-1", Some(("open-code2", Some("x-1"))))] { + assert_eq!(entry(s), want, "{s}"); + } + for bad in ["", "pi/", "/x", "pi/x/y", "Pi", "pi/DeepSeek", "pi deepseek", "pi//x", &"x".repeat(65)] { + assert_eq!(entry(bad), None, "{bad:?}"); + } + let e = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>(); + let (c, bad) = chosen(&e(&["pi/openai", "claude", "pi/deepseek", "pi/openai", "Bad", "codex/x", "codex"])); + assert_eq!(c, vec![("pi".to_string(), Some(vec!["deepseek".to_string(), "openai".into()])), ("claude".into(), None), ("codex".into(), None)]); + assert_eq!(bad, ["Bad"]); + assert_eq!(entries(&c), ["pi/deepseek", "pi/openai", "claude", "codex"], "normal form: sorted groups, no duplicates, never the agent beside them"); } }