Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <client> --server
<name>` or `--tool <name>`, and `--off` to end it.
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 19 additions & 13 deletions PlugApp/PlugApp/Clients/ClientsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -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."
}
}
7 changes: 2 additions & 5 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
78 changes: 77 additions & 1 deletion plug/src/client_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ pub(crate) fn current() -> Option<ClientHost> {
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;
}
Expand Down Expand Up @@ -79,6 +86,50 @@ fn command_line(pid: u32) -> Option<String> {
.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<String> {
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;
Expand Down Expand Up @@ -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();
Expand Down
Loading