From 7d0fdc3e68892621630e09d371217b197c47046b Mon Sep 17 00:00:00 2001 From: cyberpapi Date: Tue, 6 Oct 2026 16:25:22 -0400 Subject: [PATCH] fix: the new-servers switch sits under Settings, and two scripts started alike from different folders are two clients Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H3eUf4x7TRB1Q2rTJuNSvB --- CHANGELOG.md | 7 +- PlugApp/PlugApp/Clients/ClientsView.swift | 32 ++++++---- docs/STATUS.md | 7 +- plug/src/client_host.rs | 78 ++++++++++++++++++++++- 4 files changed, 103 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32dcd02f..adbdeea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ### Added -- A client can be given only the servers you pick. Turn off New Servers on - a client's page and a server you add later stays off for that client +- A client can be given only the servers you pick. Switch off "Turn On New + Servers" under Settings on a client's page and a server you add later stays off for that client until you turn it on; what the client gets today does not change. The same from the command line with `plug clients only --server ` or `--tool `, and `--off` to end it. @@ -82,6 +82,9 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ### Fixed +- Two scripts started the same way from different folders, such as + `python3 main.py`, are two clients. They were one, sharing a name and + what they were kept from. - A script that connects on this Mac is called by its own name, or by its folder when the file is a `main.py`. It showed as `python3` or `node`, the same as every other script. diff --git a/PlugApp/PlugApp/Clients/ClientsView.swift b/PlugApp/PlugApp/Clients/ClientsView.swift index cd39b73d..fa23b277 100644 --- a/PlugApp/PlugApp/Clients/ClientsView.swift +++ b/PlugApp/PlugApp/Clients/ClientsView.swift @@ -1175,17 +1175,6 @@ private struct ClientDetail: View { ForEach(access.servers) { server in serverRow(server, access: access) } - Toggle( - "New Servers", - isOn: Binding( - get: { !access.onlyAllowed }, - set: { run(.setClientAllowList(key: access.key, on: !$0)) } - ) - ) - .toggleStyle(.switch) - .controlSize(.mini) - .disabled(!canMutate) - .help("Whether a server you add later is on for this client") } } header: { Text("Servers") @@ -1203,6 +1192,23 @@ private struct ClientDetail: View { } } + if let access = entry.access, !access.servers.isEmpty { + Section { + Toggle( + "Turn On New Servers", + isOn: Binding( + get: { !access.onlyAllowed }, + set: { run(.setClientAllowList(key: access.key, on: !$0)) } + ) + ) + .disabled(!canMutate) + } header: { + Text("Settings") + } footer: { + Text("When this is off, a server you add later stays off for this client until you turn it on above.") + } + } + if entry.renameKey != nil || entry.placeKey != nil || entry.website != nil || entry.shortID != nil { Section { if let key = entry.renameKey { @@ -1364,7 +1370,7 @@ private struct ClientDetail: View { static func note(for access: ClientAccess) -> String { access.isRemote - ? "Turn a server off to keep this client from using its tools. Open a server to turn off single tools. With New Servers off, a server you add later stays off for this client until you turn it on." - : "Turn a server off to hide its tools from this client, or open it to hide single tools. With New Servers off, a server you add later stays off for this client until you turn it on. This tidies the list; it is not a security lock." + ? "Turn a server off to keep this client from using its tools. Open a server to turn off single tools." + : "Turn a server off to hide its tools from this client, or open it to hide single tools. This tidies the list; it is not a security lock." } } diff --git a/docs/STATUS.md b/docs/STATUS.md index 899a2f9d..93484de0 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -15,10 +15,7 @@ line here when it lands. Anything bigger than a line belongs in a GitHub issue. Agreed with the owner on 2026-10-05. No release until the first four are in. Remove a line when it lands. -1. Paper cuts. One open: a script's client key holds the script's path as - it was typed, so two scripts started as `python3 main.py` from different - folders are one client to Plug and share a name, blocks, and allow list. - Add others here as the owner reports them. +1. Paper cuts. None open. Add them here as the owner reports them. 2. Client audit. Tell GrokBot from Cursor: the owner has GrokBot make a few tool calls, and its id is read off them. Windsurf was looked for on 2026-10-06 and shows nowhere; it is Devin throughout. @@ -27,7 +24,7 @@ Remove a line when it lands. are still config-only. Issue #301. 4. Widening: the shared bearer token split into separate clients, and relaying events a server emits itself (#255). The allow list per client - is in (2026-10-06): New Servers on a client's page, `plug clients only`. + is in (2026-10-06): Settings on a client's page, `plug clients only`. 5. Single servers served on their own, beside the one Plug endpoint, so a client can mix its own connectors with only the servers it lacks. Comes out of the allow list. Issue #304. diff --git a/plug/src/client_host.rs b/plug/src/client_host.rs index 37aaa12a..8bf23279 100644 --- a/plug/src/client_host.rs +++ b/plug/src/client_host.rs @@ -35,7 +35,14 @@ pub(crate) fn current() -> Option { let (pid, executable) = find_host(std::process::id(), &table)?; let mut host = describe(executable); if is_interpreter(executable) { - host.script = command_line(pid).as_deref().and_then(script_of); + host.script = command_line(pid).as_deref().and_then(|line| { + let script = script_of(line)?; + Some(if runs_a_module(line) { + script + } else { + anchored(&script, working_folder(pid).as_deref()) + }) + }); if let Some(name) = host.script.as_deref().and_then(script_name) { host.name = name; } @@ -79,6 +86,50 @@ fn command_line(pid: u32) -> Option { .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) } +/// The folder a process was started in, which a script named without one +/// is found from. +#[cfg(not(test))] +fn working_folder(pid: u32) -> Option { + let output = std::process::Command::new("/usr/sbin/lsof") + .args(["-a", "-p", &pid.to_string(), "-d", "cwd", "-Fn"]) + .output() + .ok()?; + String::from_utf8_lossy(&output.stdout) + .lines() + .find_map(|line| line.strip_prefix('n')) + .filter(|folder| folder.starts_with('/')) + .map(str::to_string) +} + +/// Whether the command line names a module with `-m`, not a file. +fn runs_a_module(command_line: &str) -> bool { + command_line + .split_whitespace() + .skip(1) + .find(|word| !word.starts_with('-') || *word == "-m") + == Some("-m") +} + +/// A script's whole path. Typed as `main.py` it is whichever `main.py` the +/// folder holds, so two started that way from two folders are two clients. +/// Left as typed when the folder cannot be read. +fn anchored(script: &str, folder: Option<&str>) -> String { + let Some(folder) = folder.filter(|_| !script.starts_with('/')) else { + return script.to_string(); + }; + let mut parts: Vec<&str> = folder.split('/').filter(|part| !part.is_empty()).collect(); + for part in script.split('/') { + match part { + "" | "." => {} + ".." => { + parts.pop(); + } + part => parts.push(part), + } + } + format!("/{}", parts.join("/")) +} + /// What an interpreter's command line says it runs: the script path, or the /// module after `-m`. `None` for inline code and for a bare interpreter. /// `ps` does not quote, so a path with a space in it is cut at the space; @@ -251,6 +302,31 @@ mod tests { assert_eq!(script_of("python3"), None); } + #[test] + fn a_script_typed_without_its_folder_is_found_from_where_it_was_started() { + assert_eq!( + anchored("main.py", Some("/work/radar")), + "/work/radar/main.py" + ); + assert_eq!( + anchored("./tools/run.py", Some("/work/radar")), + "/work/radar/tools/run.py" + ); + assert_eq!( + anchored("../other/main.py", Some("/work/radar")), + "/work/other/main.py" + ); + assert_eq!( + anchored("/opt/agent.py", Some("/work/radar")), + "/opt/agent.py" + ); + assert_eq!(anchored("main.py", None), "main.py"); + // A module is not a file and stays as it is named. + assert!(runs_a_module("/usr/bin/python3 -u -m hermes.agent")); + assert!(!runs_a_module("python3 -u main.py -m fast")); + assert!(!runs_a_module("python3")); + } + #[test] fn a_script_is_called_by_its_own_name_not_its_interpreters() { let name = |script| script_name(script).unwrap();