From b024214d4208f4d9bb6686a5393cff2f63eff72f Mon Sep 17 00:00:00 2001 From: Evgeniy Kovrov <7254010+nyxene@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:13:43 +0100 Subject: [PATCH] fix: the server's sockets, files, ssh and typed messages hold their limits when a pane pushes on them - A connection whose process or ancestors cannot be read at connect is refused like one from a pane: a program that writes stop and exits at once no longer passes as outside. - Plugins do not start when the server's own plugin socket did not bind. - A line over the protocol limit ends the connection instead of the rest being read as the next message; the client drops a paste that large, as the server did. - ssh to a machine forwards no agent and no display; what it prints is kept to 1 MiB per pipe. - Private files never follow a link planted at their temp name; hook settings keep their mode. - config.json keeps keys this build does not know. - A remote session does not make this Mac's terminal reload. - Checkout paths with a newline are read whole; a non-UTF-8 path never enters a sandbox profile. - A fetched catalog whose links leave its folder on disk is refused. - State words have a budget of their own on the message socket. - Nothing is typed into a pane, and no Enter sent, while the agent's numbered question is on screen. - A This-folder-only pane cannot reach the server's sockets when NERD_SOCKET puts them outside the state root. - The docs say what reaches the remote nerd and the terminal, that the catalog is unsigned, that temp folders are shared, what the proxy sees, and that an agent route is a label. Part of GHSA-jr8q-vrww-r6hm fix: a menu worktree runs no hook of the repository, an old git shows no status, and a typed message is never typed twice - New worktree and Delete worktree checkout run git with the read-only git's limits (no fsmonitor, lazy fetch or index refresh), the repository's own filters off and core.hooksPath=/dev/null; config hooks (hook..command) of the repository are disabled too, since core.hooksPath does not reach them. Removing reads the checkout's own config. Both are refused when the repository's own config includes other files (include.path, includeIf), which could name a filter only in the new checkout's context; a refused delete keeps its space open. A new checkout's notice names the filters git did not run in it. docs/isolation.md names what stays: the user's own git in that folder still runs a hook a pane planted, and a pane swapping .git/config between nerd's read and git's (a ceiling: at filters_off). - A git before 2.31 ignores GIT_CONFIG_COUNT, so the filters could not be kept off: filters_off now refuses it, and status, diff, review and menu worktrees are skipped, with one log line; a version that cannot be read is said so. install.rs shares the version parse. - A typed message whose Enter meets a question is held for as long as the question shows, never queued again; once it goes, Enter only if the agent is idle and in front and nobody typed into the pane since, otherwise it stays delivered as "typed, not submitted". A restart no longer requeues a message that was already pasted. Text nerd types for the owner (Open in editor, Insert path, cd) counts as the owner typing. - docs/privacy-and-security.md: --install-hook, --uninstall-hook and --preset are not refused from a pane, and what limits them there. Part of GHSA-jr8q-vrww-r6hm Co-Authored-By: Claude --- SECURITY.md | 2 +- docs/isolation.md | 6 +- docs/messages-between-agents.md | 4 +- docs/plugins.md | 3 +- docs/privacy-and-security.md | 6 +- src/agents.rs | 2 +- src/app/commands.rs | 9 +- src/app/deliver.rs | 143 +++++++++++++++- src/app/input.rs | 2 + src/app/mod.rs | 24 +++ src/catalog.rs | 23 +++ src/client.rs | 60 +++++-- src/config.rs | 9 + src/daemon.rs | 5 +- src/git.rs | 282 +++++++++++++++++++++++++++----- src/hook.rs | 28 +++- src/install.rs | 13 +- src/isolation.rs | 35 +++- src/msock.rs | 43 +++-- src/paths.rs | 30 +++- src/plugins.rs | 44 ++++- src/proto.rs | 23 ++- src/term.rs | 23 ++- src/transport.rs | 114 +++++++++---- tests/daemon.rs | 51 +++++- tests/egress.rs | 3 +- 26 files changed, 841 insertions(+), 146 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index eedc2dd..85885c5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -37,4 +37,4 @@ Out of scope: - **`open` is outside it.** Opening a file or link "the way the system would", and opening a new terminal window, go through LaunchServices, which starts those programs outside the sandbox. - **A sandbox that fails to start does not stop the server.** The failure is written to `daemon.log`, shown by `nerd --list`, and shown in the window of every client that attaches. - **A release is signed, and what that covers depends on the way in.** Its `.sha256` is signed with nerd's release key, kept off GitHub; install.sh carries the public halves of that key and its backup, and installs nothing whose signature does not check. An update from nerd's menu uses the install.sh built into the running nerd, so someone who can replace a release on GitHub cannot make it install theirs. `curl … | sh` downloads install.sh itself from nerdterm.dev, so it is only as safe as nerdterm/nerd's release tags and the site; it refuses a release older than the nerd already in its install folder (by default ~/.local/bin), but a first install takes whichever signed release GitHub calls the latest, older ones included. Homebrew pins the archive's sha256, written into the formula only after the release script checked the signature, so it is as safe as nerdterm/homebrew-tap. It cannot stop someone holding the key, a release held back or with its signature removed (nerd then reports the release has no signature yet and stays where it is), or an older nerd that never updates; the changelog shown before installing is not signed. Releases before 0.15.0 were not signed, and a nerd before 0.15.0 installs the next release without checking its signature; every update after that is checked. The installer refuses an unsigned archive given by hand unless `NERD_INSTALL_UNVERIFIED=1` is set, and a signed archive whose nerd does not report the version in the archive's name. The release key signs only the release commit on master: the release script refuses to build and sign any other commit, or a binary reporting another version. Building and testing run every dependency's build code as the maintainer, so the script reads its signing script, formula script and install.sh from the release commit in git before it builds and runs that text afterwards, and after the build runs only the system's own commands and the nerd it built until the key has been used: a build that rewrites those files or puts commands of its own on the maintainer's PATH cannot run them in their place. It cannot stop build code that keeps running as the maintainer from reading the terminal while the key's passphrase is typed, using the maintainer's GitHub login, changing the binary being built, or waiting for a later release; only building releases as a separate macOS user would. -- **Both ends check the other's user on every connection.** The server talks only to processes of its own user, and a client refuses a server socket another user created first, for example at a `NERD_SOCKET` in a shared directory, before it sends anything. Such a socket still keeps nerd from starting there: keep `NERD_SOCKET` in a directory only you can write. +- **Both ends check the other's user on every connection.** The server talks only to processes of its own user, and a client refuses a server socket another user created first, for example at a `NERD_SOCKET` in a shared directory, before it sends anything. Such a socket still keeps nerd from starting there: keep `NERD_SOCKET` in a directory only you can write. That check is the client's, at nerd's own socket; when the plugin socket beside it cannot be nerd's, the server starts without plugins and logs why. diff --git a/docs/isolation.md b/docs/isolation.md index f1f59d6..4a4be3a 100644 --- a/docs/isolation.md +++ b/docs/isolation.md @@ -15,13 +15,15 @@ A pane starts with your access: its program, an agent included, can read and wri | 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. In a pane set to Only these providers, Bedrock works with keys, not with SSO or profile login. +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. The proxy checks the name a program asks it to connect to, not the name inside the encrypted connection, so a service behind the same address as an allowed host (a shared CDN) is reached too. **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 known caches whose contents run later (pnpm, uv, deno, Go, Playwright, Poetry, Yarn, node-gyp, pip, Cypress, SwiftPM, Copilot, the system Python's bytecode, Homebrew, ccache, sccache, esbuild, Bazelisk, Coursier); another cache under `~/Library/Caches` stays writable. Seatbelt sees a folder moved in whole as one being made, so a missing `~/./projects` folder, or a project folder in it an agent has not made yet, can still be put in place with memory or Qwen Code's workflow scripts inside. 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. +**This folder only** also keeps one session from planting code or instructions for another where agents and tools look for them. The temp folders (`/private/tmp`, `/private/var/folders`) stay writable and are shared by every session and tool: what one session leaves there, another can read. 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 known caches whose contents run later (pnpm, uv, deno, Go, Playwright, Poetry, Yarn, node-gyp, pip, Cypress, SwiftPM, Copilot, the system Python's bytecode, Homebrew, ccache, sccache, esbuild, Bazelisk, Coursier); another cache under `~/Library/Caches` stays writable. Seatbelt sees a folder moved in whole as one being made, so a missing `~/./projects` folder, or a project folder in it an agent has not made yet, can still be put in place with memory or Qwen Code's workflow scripts inside. 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. + +A pane that can write its folder can write the repository's `.git` too. nerd's own git there (the file tree's marks, diffs and review, **New worktree…** and **Delete worktree checkout…**) runs none of the repository's own hooks, filters, fsmonitor or lazy fetch; your global git config, such as Git LFS, still applies. A worktree made from the menu is therefore checked out without the repository's post-checkout hook or its local filters, and the notice names the filters left out (Git LFS installed with `--local`, for example). nerd refuses to make or delete a worktree from the menu when the repository's own config includes other files (`include.path`, `includeIf`), since what they make git run in another checkout cannot be checked first. Your own `git` in that folder runs all of it as usual: a hook a pane planted runs at your next `git checkout` or `git commit` there. nerd reads the repository's config just before its git does, so a pane that swaps the repository's config files in between can still, at the right moment, have a filter of its own run. A git older than 2.31 cannot be told to leave the filters off: with it, nerd shows no marks, diffs or review, refuses **New worktree…** and **Delete worktree checkout…**, and `daemon.log` says why. Local sockets: with Network **Open**, a This-folder-only pane reaches no ssh agent and no Docker, colima, OrbStack, Rancher Desktop, lima or podman socket, so `git` over ssh and `docker` do not work there. With **Only these providers** or **None**, a pane reaches no Unix socket but nerd's message socket. diff --git a/docs/messages-between-agents.md b/docs/messages-between-agents.md index 0b643fa..a03b499 100644 --- a/docs/messages-between-agents.md +++ b/docs/messages-between-agents.md @@ -12,7 +12,7 @@ Agents in different panes can write to each other, and to you, through nerd. Eve - **A name is the opt-in.** A pane named with **Rename pane…** (in a space of one pane, also on the space's row) can receive. A name is 1 to 32 letters, digits, `.`, `_` or `-`, does not start with `-`, is not all digits, and is not `you` or `all`. An unnamed pane neither sends nor receives. - **Sending.** From a pane: `nerd send ` (`-` reads the text from standard input), with `--reply ` to answer, `--ask-route ` to ask you for a route, and `--wait ` to wait for the answer. `nerd agents` names the calling pane, then lists the panes it may write to (`you` is the person at nerd's window), `nerd inbox [--all] [--show ]` reads its mail, and `nerd wait [--thread] [--count N] [--timeout S]` waits for replies. Exit codes: 0 sent, 3 held for your answer, 1 refused, 2 timed out. A send refused because no pane has that name or a route forbids it ends with whom the pane may write to. When that list changes after the pane last saw it (with `nerd agents` or such a refusal: a name, a space or tab, a route, an account), its next `nerd send`, `nerd inbox` or `nerd wait` says so once on standard error; nerd never types or injects this into the agent's session. - **The frame.** Every delivered message starts with `[message from via nerd, id ; reply: nerd send --reply "…"]`. The sender is read from the kernel, never from the text; a line of the text that starts the same way gets `> ` in front. -- **Routes.** Mode **Open** lets panes of your account write to each other and asks you about other accounts; **Strict** allows only what a route allows. A route says Allow, Ask or No from one side to another: a pane, a space, a tab, an account, an agent, `you` or everyone. The most specific route wins, and a tie goes to the stricter. Writing to `you` is allowed unless a route to `you` says Ask or No. A reply to a message passes unless a route says No; a refused or held message opens no way back. Answering a held message can allow that pair for an hour. +- **Routes.** Mode **Open** lets panes of your account write to each other and asks you about other accounts; **Strict** allows only what a route allows. A route says Allow, Ask or No from one side to another: a pane, a space, a tab, an account, an agent, `you` or everyone. An agent is the one nerd detects from the program in front, by its name and arguments: a route for an agent is a label, which any program in a pane can wear. The most specific route wins, and a tie goes to the stricter. Writing to `you` is allowed unless a route to `you` says Ask or No. A reply to a message passes unless a route says No; a refused or held message opens no way back. Answering a held message can allow that pair for an hour. - **Where.** **Messages…** in the main menu shows the mode, a matrix of which space may write to which (a click on a cell opens the panes of those two spaces, where a cell cycles that pair's route), what waits for you, and today's threads. **Set up a topology…** writes routes from a preset. **Routes map…** shows every named pane and its routes on one screen. **Settings… → Messages → Accounts…** admits other accounts. - **A held message.** A click on it in the sidebar's needs-you list, on a `held N` cell, or on its row under what waits for you opens it: who wrote to whom, why nerd holds it in a plain sentence, how long it has waited, its text, and its buttons (deliver, deliver and allow 1 hour, drop, reply; for a route request allow, ask, no). Once it is delivered, dropped, decided or, for a message to you, answered, the next held message between the same two panes follows; when none is left the view goes back to where it was opened from. @@ -46,7 +46,7 @@ Above 20 KiB only the first 2 KiB is delivered, with `nerd inbox --show ` fo | Cursor | Its hook: `followup_message` at stop. The CLI may not fire it; then typing when idle | | pi, OpenCode | nerd's extension | -An agent with no better path gets the message typed in, only while it is idle and is the program in front of its terminal. Nothing is ever typed into a pane waiting for your answer. `via` in the Messages window says which path was used. +An agent with no better path gets the message typed in, only while it is idle and is the program in front of its terminal. Nothing is ever typed into a pane waiting for your answer. A question that comes up after the message was typed holds its Enter for as long as it shows; once it is gone, Enter goes in only if the agent is idle and in front and nobody typed into the pane since. Otherwise, or when nerd was updated in place in between, the message stays typed in the agent's input, not sent: it counts as delivered, with the note "typed, not submitted" in `messages.jsonl` and a line in `daemon.log`, and is never typed a second time. After nerd stopped or crashed in between, the pane and what was typed into it are gone, and the message still counts as delivered. `via` in the Messages window says which path was used. **When an agent's own sandbox blocks `nerd send`.** `nerd send` talks to the socket named by `NERD_MSOCK`. Claude Code with `/sandbox`: add `"sandbox": {"network": {"allowUnixSockets": [""]}}` to its settings (`echo $NERD_MSOCK` in the pane prints it; each server has its own). Codex with `workspace-write`: `[sandbox_workspace_write] network_access = true` and a `unix_sockets` allow for the socket path. Gemini's permissive profile needs nothing. pi and OpenCode have no OS sandbox. For any other agent, test it. nerd never edits these settings. nerd knows a sender by its processes, not by the pane's variables, so an agent whose tools run outside the pane cannot send: Codex with `--remote` (it still receives, by typing), or a Claude Code session behind `claude attach`, whose session runs in Claude Code's background host. When such an agent carries a pane's variables, the refusal names that pane; start the agent in the pane itself. diff --git a/docs/plugins.md b/docs/plugins.md index 49cf9dd..217e237 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -80,7 +80,8 @@ it, and takes nerd's hooks out of its agent's config; `--plugin-on` brings both window's to do there. **The catalog.** `nerd --plugin-catalog`, or **Refresh catalog**, fetches the catalog from -`https://github.com/nerdterm/nerd-plugins` into the state root's `catalog/`; nothing else ever fetches it. Each plugin +`https://github.com/nerdterm/nerd-plugins` into the state root's `catalog/`; nothing else ever fetches it. The catalog +is not signed: it is as trustworthy as that repository and HTTPS to GitHub. Each plugin is marked **reviewed** (the maintainer approved that release), **checked** (risk level 0, approved by the catalog's checks alone), **not in catalog** (another address or another commit than the catalog's), or **by hand**. A catalog install takes exactly the commit the catalog reviewed, refuses when its tag no longer points there, and recomputes the diff --git a/docs/privacy-and-security.md b/docs/privacy-and-security.md index 6c7241d..a019db4 100644 --- a/docs/privacy-and-security.md +++ b/docs/privacy-and-security.md @@ -1,7 +1,7 @@ # Privacy and security - **The core.** nerd sends no telemetry, and checks for updates only if you allowed it, and logs every check. The server locks itself into a macOS Seatbelt sandbox that denies it the network: `nerd --sandbox-selftest` shows the profile refusing it, and `nerd --list` shows whether each server has it. nerd's only network code is the proxy it runs for a plugin with network, or for a pane set to Only these providers: outside the server's sandbox, in a sandbox of its own, it connects only to the hosts and ports that the plugin's manifest or the chosen agents' manifests name. Everything that can leave nerd is in [What leaves nerd](#what-leaves-nerd); [SECURITY.md](../SECURITY.md) says what each protection covers and what it does not. -- **Terminal output is contained.** Escape sequences printed by programs are interpreted inside nerd and never reach your terminal. +- **Terminal output is contained.** Escape sequences printed by programs are interpreted inside nerd and never reach your terminal, but for one: the pointer shape a pane asks for (OSC 22), a name of up to 32 lowercase letters and hyphens, is passed on. - **What is stored.** Everything is under `~/Library/Application Support/nerd/`, private to your user: - the layout, working directories, commands, and each agent's session id with the agent it belongs to; - the resume command of each agent you confirmed when its hook was installed; @@ -25,7 +25,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 …` | | `curl` to GitHub | You choose **Check for a new release…** or run `nerd --check-update`; or once a day, only after you answered yes to **Check for new releases** (asked once on each Mac, changed in **Settings…**). A release is installed only on your click in nerd's own window on that Mac, or your `y` in a terminal outside nerd's panes. A window inside a pane cannot install one, though a program running as you could ask for the install itself: it would get only the newest release, checked against nerd's release key: nothing it could not already do as you. | `api.github.com` for nerdterm/nerd's latest release; to install, its archive, `.sha256` and `.sha256.sig` from `github.com/nerdterm/nerd/releases/download/…`, which redirects to `release-assets.githubusercontent.com`. `/usr/bin/curl` with no `.curlrc`, https only, redirects included, and no proxy. | The request: your IP address and curl's version; nothing about you or your panes. The `.sha256` must be signed with nerd's release key, whose public half is built into the running nerd, and the archive must match it: someone who can replace the release on GitHub cannot make nerd install theirs. What the signature does not cover is in [SECURITY.md](../SECURITY.md). | `release check: …`; `release … installed …` | -| `ssh` | You open a machine in **Servers…**, which lists its servers, or open one of them there, or run `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 …` | +| `ssh` | You open a machine in **Servers…**, which lists its servers, or open one of them there, or run `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, this Mac's name, and the path of your home folder here (where a server there with no spaces would start). | `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 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, a help page from **Search**, 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 | @@ -41,7 +41,7 @@ Links are found in the text a pane prints; OSC 8 hyperlinks are not read. Anythi ## What a program inside a pane can do -An agent in a pane runs shell commands as you, so it could type `nerd --stop` or `nerd --update`. The server refuses a connection from a process that descends from its own panes when it asks to stop, update, disconnect everyone or take the screen; the command prints `refused: … from inside a pane`, exits non-zero, and the server logs it. `nerd --stop`, `--update` and `--disconnect` without a name, run inside any nerd pane, refuse too: name the server, or set `NERD_SOCKET` or `NERD_STATE_DIR` to target a test server. `nerd --remove`, with a name or `--stopped`, refuses inside any nerd pane, and when nerd cannot tell whether it runs in one, unless `NERD_SOCKET` or `NERD_STATE_DIR` is set to target a test server. **Servers…** in a session attached from inside a pane only lists this Mac's servers: Open on one of them, New server…, Rename…, Update…, Stop… and Remove… are refused with a notice. **Programs in panes may stop or update nerd**, in **Settings… → Messages**, turns these refusals off. Plugins follow the same rule: from a pane, `nerd --plugin-add` only fetches, and the question comes up in nerd's own window, where only a click installs; `--yes`, `--plugin-on` and `--plugin-remove` are refused. A window attached from inside a pane is stricter than the command line: it refuses turning a plugin on, removing, installing and updating one, and answering the install question, in **Plugins…** and in **Search** alike, while `--plugin-add` and `--plugin-update` still fetch; it also refuses installing and removing agent hooks. +An agent in a pane runs shell commands as you, so it could type `nerd --stop` or `nerd --update`. The server refuses a connection from a process that descends from its own panes when it asks to stop, update, disconnect everyone or take the screen; the command prints `refused: … from inside a pane`, exits non-zero, and the server logs it. A connection whose process or ancestors can no longer be read when it connects, such as a program that writes and exits at once, is refused the same way, even with programs in panes allowed. `nerd --stop`, `--update` and `--disconnect` without a name, run inside any nerd pane, refuse too: name the server, or set `NERD_SOCKET` or `NERD_STATE_DIR` to target a test server. `nerd --remove`, with a name or `--stopped`, refuses inside any nerd pane, and when nerd cannot tell whether it runs in one, unless `NERD_SOCKET` or `NERD_STATE_DIR` is set to target a test server. **Servers…** in a session attached from inside a pane only lists this Mac's servers: Open on one of them, New server…, Rename…, Update…, Stop… and Remove… are refused with a notice. **Programs in panes may stop or update nerd**, in **Settings… → Messages**, turns these refusals off. Plugins follow the same rule: from a pane, `nerd --plugin-add` only fetches, and the question comes up in nerd's own window, where only a click installs; `--yes`, `--plugin-on` and `--plugin-remove` are refused. A window attached from inside a pane is stricter than the command line: it refuses turning a plugin on, removing, installing and updating one, and answering the install question, in **Plugins…** and in **Search** alike, while `--plugin-add` and `--plugin-update` still fetch; it also refuses installing and removing agent hooks. `nerd --install-hook`, `--uninstall-hook` and `--preset` are not refused from a pane: under This folder only, the agents' settings and terminal config files they write are read-only, so those writes fail there, but the margins and default profile that iTerm2's preset sets through `defaults` are not stopped; in a pane at Everything, an agent could write all of these itself anyway. The way in is the message socket. Panes of admitted accounts reach it through `/tmp/nerd-/`, a folder only nerd's owner can write, checked before use, and reach nothing else of nerd. No path in nerd lets an agent change names, routes, isolation or that switch while nerd runs; from an isolated pane, no file can either. diff --git a/src/agents.rs b/src/agents.rs index 41b7e9c..8a05e0a 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -584,7 +584,7 @@ fn check(dir: &Path, catalog: bool, walk: bool) -> Check { } /// No link out of the folder, no file over `FILE_MAX`; `.git` is git's, not the plugin's. -fn folder_rules(root: &Path, dir: &Path, problems: &mut Vec) { +pub(crate) fn folder_rules(root: &Path, dir: &Path, problems: &mut Vec) { let Ok(rd) = std::fs::read_dir(dir) else { return }; let mut entries: Vec<_> = rd.flatten().collect(); entries.sort_by_key(|e| e.file_name()); diff --git a/src/app/commands.rs b/src/app/commands.rs index 2639824..9598549 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -702,6 +702,10 @@ impl App { let main = crate::git::group_key(&cwd) .and_then(|g| g.parent().map(std::path::Path::to_path_buf)) .unwrap_or_else(|| cwd.clone()); + if let Err(e) = crate::git::removable(&main, &cwd) { + self.notice = Some(e); + return; + } crate::log::info(&format!("worktree checkout deleted by the user; its space closed with {}", plural_panes(self.space_panes(i).unwrap_or(0)))); // Close panes first: their shells sit in this folder. self.close_space(i); @@ -1624,7 +1628,10 @@ impl App { } if let Some(cwd) = self.spaces.get(*i).map(|s| s.cwd.clone()) { match crate::git::add_worktree(&cwd, &value) { - Ok(path) => self.open_space(path), + Ok((path, skipped)) => { + self.open_space(path); + self.notice = self.notice.take().or(skipped); + } // We show git's own message: it's more precise than any // paraphrase of ours. Err(e) => self.notice = Some(e), diff --git a/src/app/deliver.rs b/src/app/deliver.rs index 39dc81a..da89c98 100644 --- a/src/app/deliver.rs +++ b/src/app/deliver.rs @@ -24,6 +24,9 @@ const OWNER_QUIET: Duration = Duration::from_secs(10); /// A pane whose hand-over failed is not tried again on every tick. const RETRY: Duration = Duration::from_secs(5); const CURRENT_FOR: Duration = Duration::from_secs(2); +/// The step note of a message about to be pasted: a restart finds it pasted and not submitted. +const TYPING: &str = "typing hand-over"; +const NOT_SUBMITTED: &str = "typed, not submitted"; /// `socket_fdinfo` (sys/proc_info.h): `psi.soi_kind`, and `psi.soi_proto.pri_un.unsi_addr.ua_sun.sun_path` /// (`struct un_sockinfo`: two u64 handles, then a `sockaddr_un`, whose path follows its length and family bytes). @@ -47,6 +50,9 @@ struct Hand { at: Instant, idle: Option, claimed: Option, + /// Typed: a question showed after the paste, and whether the owner typed into the pane since. + asked: bool, + typed_over: bool, } #[derive(Default)] @@ -132,10 +138,21 @@ impl App { } /// A hand-over the previous run did not finish is made again, once: its frame's id makes a repeat recognisable. + /// A message already pasted stays in the agent's input, not submitted. fn after_restart(&mut self) -> bool { - let unfinished: Vec = self.mail.msgs.iter().filter(|m| m.state == State::Delivered && m.via.is_none()).map(|m| m.id.clone()).collect(); - for id in &unfinished { - self.mail.set(id, State::Queued, None, Some("after a restart".into())); + let unfinished: Vec<(String, bool)> = self + .mail + .msgs + .iter() + .filter(|m| m.state == State::Delivered && m.via.is_none()) + .map(|m| (m.id.clone(), m.events.last().is_some_and(|s| s.note.as_deref() == Some(TYPING)))) + .collect(); + for (id, pasted) in &unfinished { + if *pasted { + self.mail.set(id, State::Delivered, Some(Via::Typed), Some(NOT_SUBMITTED.into())); + } else { + self.mail.set(id, State::Queued, None, Some("after a restart".into())); + } } for e in std::fs::read_dir(crate::hook::state_dir()).into_iter().flatten().flatten() { if e.file_name().to_string_lossy().ends_with(".mail") { @@ -202,6 +219,22 @@ impl App { if h.at.elapsed() < CR_AFTER { return Some(h); } + h.typed_over |= self.mail.owner_typed.is_some_and(|(p, t)| p == pane && t > h.at); + // A question that came up after the paste would take this carriage return as its answer: held while it shows. + if self.pane(pane).is_some_and(|p| p.term.shows_question()) { + if !h.asked { + crate::log::warn(&format!("pane {pane}: a question came up before a typed message was submitted")); + } + h.asked = true; + return Some(h); + } + // Once it is gone, Enter only into the input as nerd left it; never typed again, or it would be there twice. + let front = || self.pane(pane).is_some_and(|p| p.agent.as_deref().is_some_and(|a| p.pty.agent_in_front(a))); + if h.asked && (h.typed_over || !matches!(state, AgentState::Idle | AgentState::Done) || !front()) { + crate::log::warn(&format!("pane {pane}: a typed message was not submitted")); + self.handed(&h.id, Via::Typed, Some(NOT_SUBMITTED.into())); + return None; + } self.submit_typed(pane); self.handed(&h.id, Via::Typed, None); Some(Hand { way: Way::Typed(true), at: Instant::now(), ..h }) @@ -279,10 +312,11 @@ impl App { return true; } let owner = self.mail.owner_typed.is_some_and(|(p, t)| p == pane && t.elapsed() < OWNER_QUIET); - if !matches!(state, AgentState::Idle | AgentState::Done) || owner || !self.pane(pane).is_some_and(|p| p.pty.agent_in_front(agent)) { + let asked = self.pane(pane).is_some_and(|p| p.term.shows_question()); + if !matches!(state, AgentState::Idle | AgentState::Done) || owner || asked || !self.pane(pane).is_some_and(|p| p.pty.agent_in_front(agent)) { return false; } - self.mail.set(&m.id, State::Delivered, None, Some("typing hand-over".into())); + self.mail.set(&m.id, State::Delivered, None, Some(TYPING.into())); let one_line = if self.pane(pane).is_some_and(|p| p.term.screen().bracketed_paste()) { framed } else { one_line(&framed) }; if !self.paste_into(pane, &one_line) { self.requeue(&m.id, "the pane closed"); @@ -294,7 +328,7 @@ impl App { } fn start(&mut self, pane: PaneId, id: &str, way: Way) { - self.mail.delivery.hands.insert(pane, Hand { id: id.to_string(), way, at: Instant::now(), idle: None, claimed: None }); + self.mail.delivery.hands.insert(pane, Hand { id: id.to_string(), way, at: Instant::now(), idle: None, claimed: None, asked: false, typed_over: false }); } fn hook_current(&mut self, agent: &str) -> bool { @@ -535,6 +569,97 @@ mod tests { }); } + /// A hook that says idle while the agent's numbered question is on screen: Claude's prompt, as it draws it. + fn ask(app: &mut App, text: &str) { + let t = &mut app.pane_mut(2).unwrap().term; + t.set_blocked_text(crate::agents::blocked_text(Some("claude"))); + t.feed(text.as_bytes()); + } + + #[test] + fn nothing_is_typed_while_a_numbered_question_is_on_screen_whatever_the_hook_says() { + with_app("question", "codex", AgentState::Idle, |app, _, typed| { + let id = queue(app); + ask(app, "Do you want to proceed?\r\n\u{276f} 1. Yes\r\n 2. No"); + assert!(!app.tick_deliver()); + std::thread::sleep(Duration::from_millis(60)); + assert_eq!((read(typed), rec(app, &id).state), (String::new(), State::Queued)); + }); + with_app("question-prose", "codex", AgentState::Idle, |app, _, typed| { + let id = queue(app); + ask(app, "Do you want me to continue?"); + assert!(app.tick_deliver(), "prose without a numbered choice is not a question"); + assert!(read_until(typed, |s| s.contains(&id)).contains(&id)); + }); + } + + #[test] + fn a_question_after_the_paste_holds_its_carriage_return_and_a_pasted_message_is_never_queued_again() { + with_app("question-after", "codex", AgentState::Idle, |app, _, typed| { + let id = queue(app); + assert!(app.tick_deliver()); + let pasted = read_until(typed, |s| s.contains(&id)); + ask(app, "\r\nDo you want to proceed?\r\n\u{276f} 1. Yes"); + std::thread::sleep(CR_AFTER + Duration::from_millis(20)); + app.tick_deliver(); + std::thread::sleep(Duration::from_millis(60)); + assert_eq!(read(typed), pasted, "no carriage return into the question"); + assert_eq!(rec(app, &id).via, None, "not handed before its carriage return"); + // The question is answered and goes: the carriage return follows. + app.pane_mut(2).unwrap().term.feed(b"\x1b[2J\x1b[H"); + app.tick_deliver(); + assert!(read_until(typed, |s| s.ends_with('\r')).ends_with('\r')); + assert_eq!(rec(app, &id).via, Some(Via::Typed)); + }); + with_app("question-stays", "codex", AgentState::Idle, |app, _, typed| { + let id = queue(app); + assert!(app.tick_deliver()); + let pasted = read_until(typed, |s| s.contains(&id)); + ask(app, "\r\nDo you want to proceed?\r\n\u{276f} 1. Yes"); + app.mail.delivery.hands.get_mut(&2).unwrap().at = Instant::now().checked_sub(TAKE_BACK + Duration::from_millis(1)).unwrap(); + app.tick_deliver(); + std::thread::sleep(Duration::from_millis(60)); + assert_eq!(read(typed), pasted, "never a carriage return into the question"); + let m = rec(app, &id); + assert_eq!((m.state.clone(), m.via), (State::Delivered, None), "held while the question shows, never queued again"); + assert!(log().contains("pane 2: a question came up before a typed message was submitted"), "{}", log()); + assert!(app.mail.delivery.hands.contains_key(&2), "still held"); + // The owner answers it in the pane; the question goes. + app.mail.owner_typed = Some((2, Instant::now())); + app.tick_deliver(); + app.pane_mut(2).unwrap().term.feed(b"\x1b[2J\x1b[H"); + app.tick_deliver(); + std::thread::sleep(Duration::from_millis(60)); + assert_eq!(read(typed), pasted, "no carriage return after the owner typed"); + let m = rec(app, &id); + assert_eq!((m.state.clone(), m.via), (State::Delivered, Some(Via::Typed))); + assert_eq!(notes(&m).last().and_then(|n| n.1.clone()).as_deref(), Some("typed, not submitted")); + assert!(log().contains("pane 2: a typed message was not submitted"), "{}", log()); + assert!(!app.mail.delivery.hands.contains_key(&2), "the hand is over"); + }); + for case in ["not idle", "the shell in front"] { + with_app(&format!("question-{}", case.replace(' ', "-")), "codex", AgentState::Idle, |app, _, typed| { + let id = queue(app); + assert!(app.tick_deliver()); + let pasted = read_until(typed, |s| s.contains(&id)); + ask(app, "\r\nDo you want to proceed?\r\n\u{276f} 1. Yes"); + std::thread::sleep(CR_AFTER + Duration::from_millis(20)); + app.tick_deliver(); + if case == "not idle" { + app.pane_mut(2).unwrap().term.set_hook_state(Some(AgentState::Working)); + } else { + let front = app.pane(2).unwrap().pty.foreground().unwrap(); + app.pane_mut(2).unwrap().pty.child_pid = Some(front as u32); + } + app.pane_mut(2).unwrap().term.feed(b"\x1b[2J\x1b[H"); + app.tick_deliver(); + std::thread::sleep(Duration::from_millis(60)); + assert_eq!(read(typed), pasted, "no carriage return: {case}"); + assert_eq!(notes(&rec(app, &id)).last().and_then(|n| n.1.clone()).as_deref(), Some("typed, not submitted"), "{case}"); + }); + } + } + #[test] fn the_shell_in_front_is_never_typed_into() { with_app("shell-front", "codex", AgentState::Idle, |app, _, typed| { @@ -816,15 +941,19 @@ mod tests { } #[test] - fn a_restart_requeues_an_unfinished_hand_over() { + fn a_restart_requeues_an_unfinished_hand_over_and_leaves_a_pasted_one_delivered() { with_app("restart", "codex", AgentState::Blocked, |app, _, _| { let id = queue(app); app.mail.set(&id, State::Delivered, None, Some("hook hand-over".into())); crate::mail::mailbox_put(2, &id, "out", b"x\n").unwrap(); crate::mail::mailbox_put(2, "01OTHER", "claim", b"x\n").unwrap(); + let pasted = queue(app); + app.mail.set(&pasted, State::Delivered, None, Some("typing hand-over".into())); assert!(app.tick_deliver()); assert_eq!(notes(&rec(app, &id)).last().unwrap(), &(State::Queued, Some("after a restart".into()))); assert!(!crate::mail::mailbox(2).exists(), "nothing left from before"); + let m = rec(app, &pasted); + assert_eq!((m.state.clone(), m.via, notes(&m).last().unwrap().1.as_deref()), (State::Delivered, Some(Via::Typed), Some("typed, not submitted")), "pasted already: never typed twice"); }); } } diff --git a/src/app/input.rs b/src/app/input.rs index 459cdfb..03cb87c 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -536,6 +536,8 @@ impl App { pub(super) fn pane_type(&mut self, text: &str) { if let Some(p) = self.tab_mut().and_then(Tab::focused_mut) { p.pty.write(text.as_bytes()); + let id = p.id; + self.owner_typed(id); } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 8bb9962..d0617aa 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -812,6 +812,30 @@ mod tests { app } + #[test] + fn a_worktree_nerd_will_not_delete_keeps_its_space_open() { + let dir = crate::git::tests::init_repo("keep-space"); + let (added, _) = crate::git::add_worktree(&dir, "keep").unwrap(); + assert!(std::process::Command::new("git").arg("-C").arg(&dir).args(["config", "include.path", "x.cfg"]).status().unwrap().success()); + let mut app = with_one_space(); + app.spaces[0].cwd = added.clone(); + app.perform(Pending::RemoveWorktree(0)); + assert_eq!(app.spaces.len(), 1, "its panes stay"); + assert!(app.notice.as_deref().is_some_and(|n| n.contains("includes other files")), "{:?}", app.notice); + assert!(added.is_dir()); + let _ = std::fs::remove_dir_all(&added); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn what_nerd_types_into_a_pane_for_the_owner_holds_messages_like_the_owner_typing() { + let mut app = with_one_space(); + let pane = agent_pane(&app, 1, Default::default()); + app.spaces[0].tabs[0].panes.insert(1, pane); + app.pane_type("cd /tmp\n"); + assert_eq!(app.mail.owner_typed.map(|(p, _)| p), Some(1)); + } + #[test] fn the_hook_item_asks_before_hooking_an_agent_for_the_first_time() { crate::session::tests::with_temp_state_dir("first-install", || { diff --git a/src/catalog.rs b/src/catalog.rs index 11cf361..4637716 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -284,6 +284,29 @@ mod tests { }); } + #[test] + fn a_catalog_whose_links_leave_its_folder_on_disk_is_refused() { + crate::session::tests::with_temp_state_dir("catalog-links", || { + let root = crate::paths::state_root(); + let cat = root.join("src/catalog"); + std::fs::create_dir_all(cat.join("plugins")).unwrap(); + git(&cat, &["init", "-q", "-b", "main"]); + std::fs::write(cat.join("revoked.json"), "[]").unwrap(); + std::os::unix::fs::symlink("revoked.json", cat.join("inside")).unwrap(); + git(&cat, &["add", "-A"]); + git(&cat, &["commit", "-qm", "a link inside"]); + refresh_from(&cat.to_string_lossy(), true).expect("a link that stays inside"); + // Each link's text stays inside; on disk the second one is the folder above the catalog. + std::os::unix::fs::symlink(".", cat.join("here")).unwrap(); + std::os::unix::fs::symlink("here/..", cat.join("up")).unwrap(); + git(&cat, &["add", "-A"]); + git(&cat, &["commit", "-qm", "a link out"]); + let e = refresh_from(&cat.to_string_lossy(), true).unwrap_err(); + assert!(e.contains("up links outside"), "{e}"); + assert!(!dir().join("up").exists(), "the copy in place is the one before"); + }); + } + #[test] fn an_update_whose_lock_cannot_be_written_puts_the_old_files_back() { crate::session::tests::with_temp_state_dir("update-lock-fails", || { diff --git a/src/client.rs b/src/client.rs index f6f038b..268df97 100644 --- a/src/client.rs +++ b/src/client.rs @@ -683,7 +683,7 @@ fn session( let pump = std::thread::Builder::new().name("screen".into()).spawn(move || { let mut out = Screen; pump(first, reader, &mut out, switches, |r| { - if r.reload { + if reloads(r, connects) { reload_ghostty(); } for _ in 0..r.open { @@ -749,6 +749,13 @@ fn session( } } } + // A paste the server could not read whole is dropped here, as it was there: sent, it would end the session. + if let Event::Paste(p) = &ev + && !crate::proto::fits(&ClientMsg::Event(Event::Paste(p.clone()))) + { + crate::log::warn(&format!("a paste of {} bytes is over the limit of one message; dropped", p.len())); + continue; + } if ClientMsg::Event(ev).write_to(&mut writer).is_err() { break; } @@ -767,6 +774,11 @@ fn connect_asks(r: &Requests, connects: bool) -> (&[String], &[(String, Option bool { + connects && r.reload +} + /// The id of server `query` of this Mac, by id or name; never the legacy one, which has no id to resume. fn own_server(query: &str) -> Option { crate::session::resolve(query).filter(|id| crate::session::valid_id(id)) @@ -854,7 +866,9 @@ fn last_line(stderr: &str) -> String { fn list_remote(ssh: &Path, entry: &crate::config::RemoteEntry, limit: Option) -> Result, (bool, String)> { use std::process::Stdio; let mut cmd = crate::spawn::cmd(ssh); + // Neither this Mac's agent nor its display goes to the machine, whatever ~/.ssh/config says for it. cmd.args(["-o", "ConnectTimeout=10", "-o", "ControlMaster=no", "-o", "ControlPath=none"]); + cmd.args(["-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", "-o", "ForwardX11=no"]); if limit.is_some() { cmd.args(["-n", "-o", "BatchMode=yes"]); } @@ -872,7 +886,7 @@ fn list_remote(ssh: &Path, entry: &crate::config::RemoteEntry, limit: Option String { } /// A pipe read to its end on its own thread, and what came so far. -type Collected = (std::thread::JoinHandle<()>, std::sync::Arc>>); +type Collected = (std::thread::JoinHandle<()>, std::sync::Arc>>); -fn collect(mut pipe: impl Read + Send + 'static) -> Collected { - let got = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); +/// What a machine's ssh may leave in memory per pipe; the pipe is still drained to its end. +const KEPT: usize = 1 << 20; + +/// The first `KEPT` bytes, or with `tail` the last (stderr's last line says why ssh failed). +fn collect(mut pipe: impl Read + Send + 'static, tail: bool) -> Collected { + let got = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())); let keep = got.clone(); let reader = std::thread::spawn(move || { let mut buf = [0u8; 4096]; while let Ok(n @ 1..) = pipe.read(&mut buf) { - keep.lock().unwrap_or_else(|e| e.into_inner()).extend_from_slice(&buf[..n]); + let mut g = keep.lock().unwrap_or_else(|e| e.into_inner()); + if tail { + g.extend(&buf[..n]); + let over = g.len().saturating_sub(KEPT); + g.drain(..over); + } else { + let room = KEPT.saturating_sub(g.len()); + g.extend(&buf[..n.min(room)]); + } } }); (reader, got) @@ -1043,7 +1069,7 @@ fn collected((reader, got): Collected, until: Instant) -> Vec { while !reader.is_finished() && Instant::now() < until { std::thread::sleep(Duration::from_millis(10)); } - got.lock().unwrap_or_else(|e| e.into_inner()).clone() + got.lock().unwrap_or_else(|e| e.into_inner()).iter().copied().collect() } /// ssh running `nerd --relay ` there, its stdio the session's stream. @@ -1065,6 +1091,7 @@ fn spawn_relay(ssh: &Path, host: &str, id: &str) -> std::io::Result { let mut child = crate::spawn::cmd(ssh) .args(["-T", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3"]) .args(["-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ClearAllForwardings=yes"]) + .args(["-o", "ForwardAgent=no", "-o", "ForwardX11=no"]) .arg("--") .arg(host) .arg(format!("{REMOTE_PATH}; nerd --relay {id}")) @@ -1077,7 +1104,7 @@ fn spawn_relay(ssh: &Path, host: &str, id: &str) -> std::io::Result { let (Some(stdin), Some(stdout), Some(err)) = (child.stdin.take(), child.stdout.take(), child.stderr.take()) else { return Err(std::io::Error::other("ssh started without its pipes")); }; - Ok(Relay { child, stdin, stdout, stderr: collect(err) }) + Ok(Relay { child, stdin, stdout, stderr: collect(err, true) }) } /// ssh's exit code and what it said; ssh gets a few seconds to go after its stdin closed. @@ -2137,6 +2164,16 @@ mod remote_tests { remote_outcome("studio", "1", seen, exit, said, &[inst("1", "1", true)]) } + #[test] + fn a_machine_s_output_is_kept_to_a_mib_stdout_from_its_start_stderr_from_its_end() { + let three: Vec = (0..3 * KEPT).map(|i| (i % 251) as u8).collect(); + let until = Instant::now() + Duration::from_secs(10); + let head = collected(collect(std::io::Cursor::new(three.clone()), false), until); + assert_eq!(head, three[..KEPT], "stdout: the first MiB"); + let tail = collected(collect(std::io::Cursor::new(three.clone()), true), until); + assert_eq!(tail, three[2 * KEPT..], "stderr: the last MiB"); + } + #[test] fn a_list_call_fails_in_words_and_passes_nothing_of_ours_to_the_host() { let fake = Fake::new("list", "json"); @@ -2149,7 +2186,7 @@ mod remote_tests { assert_eq!(list.iter().map(|i| i.id.as_str()).collect::>(), ["1"]); list_remote(&fake.ssh(), &fake.entry(), Some(Duration::from_secs(5))).unwrap(); let cmd = format!("{REMOTE_PATH}; nerd --list --json"); - let base = ["-o", "ConnectTimeout=10", "-o", "ControlMaster=no", "-o", "ControlPath=none"]; + let base = ["-o", "ConnectTimeout=10", "-o", "ControlMaster=no", "-o", "ControlPath=none", "-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", "-o", "ForwardX11=no"]; let calls = fake.calls(); assert_eq!(calls[0], [&base[..], &["--", "fake-host", &cmd]].concat()); assert_eq!(calls[1], [&base[..], &["-n", "-o", "BatchMode=yes", "--", "fake-host", &cmd]].concat(), "the menu's call cannot prompt"); @@ -2203,7 +2240,7 @@ mod remote_tests { let cmd = format!("{REMOTE_PATH}; nerd --relay 1"); let relay = [ "-T", "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3", "-o", "ControlMaster=no", - "-o", "ControlPath=none", "-o", "ClearAllForwardings=yes", "--", "fake-host", &cmd, + "-o", "ControlPath=none", "-o", "ClearAllForwardings=yes", "-o", "ForwardAgent=no", "-o", "ForwardX11=no", "--", "fake-host", &cmd, ]; assert_eq!(fake.calls()[0], relay); assert_eq!(fake.leftovers(), "", "nothing of the session is left running"); @@ -2261,6 +2298,9 @@ mod remote_tests { assert_eq!((list.len(), connect.len()), (1, 1)); let (list, connect) = connect_asks(&r, false); assert!(list.is_empty() && connect.is_empty(), "a server on another Mac cannot make this one ssh or open windows"); + let r = take_requests(crate::proto::RELOAD_KEYS, &mut Vec::new()); + assert!(reloads(&r, true)); + assert!(!reloads(&r, false), "nor signal its terminal"); } #[test] diff --git a/src/config.rs b/src/config.rs index bd7af8d..833b2c6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -57,6 +57,9 @@ pub struct Config { /// Per-plugin settings, kept exactly as written; nerd reads nothing from it yet. #[serde(skip_serializing_if = "serde_json::Value::is_null")] pub plugins: serde_json::Value, + /// Keys this build does not know (a newer nerd's, or a renamed one's old name): written back as found, never read. + #[serde(flatten)] + pub extra: serde_json::Map, } /// One machine as the human wrote it; a bad one is shown with its reason and never reaches ssh. @@ -259,6 +262,7 @@ impl Default for Config { prefix: Prefix::CtrlB, remotes: serde_json::Value::Null, plugins: serde_json::Value::Null, + extra: serde_json::Map::new(), } } } @@ -600,6 +604,11 @@ pub(crate) mod tests { assert_eq!(loaded.sidebar_width, cfg.sidebar_width); std::fs::write(config_path(), r#"{"allow_from_panes": true, "pane_gap": 2}"#).unwrap(); assert_eq!(load_or_init().pane_gap, 2, "a file with the switch that moved to policy.json still loads"); + std::fs::write(config_path(), r#"{"pane_gap": 2, "from_a_newer_nerd": {"x": [1]}}"#).unwrap(); + save(&load_or_init()).unwrap(); + let kept: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(config_path()).unwrap()).unwrap(); + assert_eq!(kept["from_a_newer_nerd"], serde_json::json!({"x": [1]}), "a key this build does not know survives a save"); + assert_eq!(kept["pane_gap"], 2); }); } diff --git a/src/daemon.rs b/src/daemon.rs index cb6cf1a..24984b7 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -196,7 +196,7 @@ fn serve(started: &mut bool) -> std::io::Result<()> { accept_loop(listener, inbox.clone(), carried, waiting.clone()); match plugin_listener { Ok(l) => crate::plugins::listen(l, inbox.clone()), - Err(e) => error(&format!("plugin socket failed: {e}, plugins cannot connect")), + Err(e) => error(&format!("plugin socket failed: {e}; plugins do not start")), } match msock_listener { Ok(l) => crate::msock::start(l, inbox.clone()), @@ -319,8 +319,7 @@ fn serve(started: &mut bool) -> std::io::Result<()> { nix::fcntl::fcntl(fd, nix::fcntl::FcntlArg::F_SETFD(nix::fcntl::FdFlag::FD_CLOEXEC))?; let stream = unsafe { UnixStream::from_raw_fd(fd) }; let peer = Peer::of(&stream); - app.client_inside = nix::sys::socket::getsockopt(&stream, nix::sys::socket::sockopt::LocalPeerPid) - .is_ok_and(|pid| crate::transport::inside(pid, crate::broker::pid(), crate::transport::parent_pid)); + app.client_inside = peer.inside == Some(true); if let Ok(reader) = stream.try_clone() { let inbox = inbox.clone(); std::thread::spawn(move || read_client(id, reader, inbox, true, Waiting::default(), crate::transport::allow_from_panes)); diff --git a/src/git.rs b/src/git.rs index c32d7a8..def6977 100644 --- a/src/git.rs +++ b/src/git.rs @@ -21,22 +21,34 @@ pub struct Worktree { pub is_current: bool, } -/// An error is always git's own trimmed stderr, shown to the user as-is. stdin is -/// `null`, or an interactive git could hang waiting on a prompt. -fn run_git(dir: &Path, args: I) -> Result +/// A worktree added or removed from the menu, in a repository a pane may have written: read_git's limits, no hook, +/// and `env` from worktree_env. An error is git's own trimmed stderr, shown to the user as-is. +fn run_git(dir: &Path, env: &[(String, String)], args: I) -> Result where I: IntoIterator, S: AsRef, { - let mut cmd = crate::spawn::cmd("git"); - cmd.arg("-C").arg(dir).args(args); + let mut cmd = read_cmd(dir, ["-c", "core.hooksPath=/dev/null"], env); + cmd.args(args); run(cmd) } +const INCLUDES: &str = "nerd does not run git worktree here: this repository's git config includes other files, and what they make git run in a checkout cannot be checked first; use git in a terminal"; + +/// filters_off's env for git run on a worktree, the config read in `root`, or why nerd will not run it there. +fn worktree_env(root: &Path) -> Result, String> { + config_env_known()?; + // An include can pick its file in the other checkout's context (includeIf gitdir or onbranch, a relative path in a copied config.worktree), which this read cannot see. + if !own_keys(root, r"^include(if\..*)?\.path$")?.is_empty() { + return Err(INCLUDES.into()); + } + filters_off(root) +} + /// A folder the user only opened: its own config may not run anything (fsmonitor, lazy fetch, and an /// index write, which runs post-index-change: diff ignores GIT_OPTIONAL_LOCKS, so autoRefreshIndex is off too). // Only helper `_` is allowed (no repo alias can be named remote-_, autocorrect is off; "" would allow helper "", answered by alias remote-), so no git lazy-fetches through the repo's transport. -// ceiling: a denylist over git's mechanisms; gits before 2.31 ignore filters_off's GIT_CONFIG_COUNT, and stat-dirty files are re-hashed each call; a git version floor lifts it. +// ceiling: a denylist over git's mechanisms, and stat-dirty files are re-hashed each call. fn read_git(dir: &Path, args: I, env: &[(String, String)]) -> Result where I: IntoIterator, @@ -45,6 +57,7 @@ where run(read_cmd(dir, args, env)) } +/// stdin is `null` (run_within), or an interactive git could hang waiting on a prompt. fn read_cmd(dir: &Path, args: I, env: &[(String, String)]) -> Command where I: IntoIterator, @@ -56,24 +69,23 @@ where cmd } -/// The repo's own filter drivers and git-lfs extensions (local and worktree scope), blanked through env pairs, -/// since a name may hold `=`, which `-c` would split; the user's global ones, such as LFS, still run. +/// The repo's own filter drivers, git-lfs extensions and config hooks (local and worktree scope), blanked through env +/// pairs, since a name may hold `=`, which `-c` would split; the user's global ones, such as LFS, still run. /// An unreadable config is an error: the caller must not run git at all. +// ceiling: a pane that swaps the repository's config files between this read and git's own can win a refresh; running nerd's git under a profile that lets only git exec closes it. fn filters_off(root: &Path) -> Result, String> { - let pattern = r"^(filter\..*\.(clean|smudge|process)|lfs\.extension\..*\.(clean|smudge))$"; - let raw = read_git(root, ["config", "--show-scope", "--name-only", "-z", "--get-regexp", pattern], &[]).or_else(|e| { - // No matching key: exit 1 with nothing said. - if !e.is_empty() { - return Err(e); - } - Ok(String::new()) - })?; - let fields: Vec<&str> = raw.split('\0').collect(); + config_env_known()?; let mut blank = Vec::new(); - for f in fields.chunks(2).filter(|f| f.len() == 2 && matches!(f[0], "local" | "worktree")) { - let Some((section, _)) = f[1].rsplit_once('.') else { continue }; - let keys: &[(&str, &str)] = - if section.starts_with("filter.") { &[("clean", ""), ("smudge", ""), ("process", ""), ("required", "false")] } else { &[("clean", ""), ("smudge", "")] }; + for name in own_keys(root, r"^(filter\..*\.(clean|smudge|process)|lfs\.extension\..*\.(clean|smudge)|hook\..*\.command)$")? { + let Some((section, _)) = name.rsplit_once('.') else { continue }; + // core.hooksPath does not reach a hook named in config; an empty event clears its list. + let keys: &[(&str, &str)] = if section.starts_with("filter.") { + &[("clean", ""), ("smudge", ""), ("process", ""), ("required", "false")] + } else if section.starts_with("hook.") { + &[("enabled", "false"), ("event", "")] + } else { + &[("clean", ""), ("smudge", "")] + }; blank.extend(keys.iter().map(|(key, value)| (format!("{section}.{key}"), *value))); } blank.sort_unstable(); @@ -89,6 +101,47 @@ fn filters_off(root: &Path) -> Result, String> { Ok(env) } +/// Names of the keys matching `pattern` that the repository's own config (local and worktree scope) sets. +fn own_keys(root: &Path, pattern: &str) -> Result, String> { + let raw = read_git(root, ["config", "--show-scope", "--name-only", "-z", "--get-regexp", pattern], &[]).or_else(|e| { + // No matching key: exit 1 with nothing said. + if !e.is_empty() { + return Err(e); + } + Ok(String::new()) + })?; + let fields: Vec<&str> = raw.split('\0').collect(); + Ok(fields.chunks(2).filter(|f| f.len() == 2 && matches!(f[0], "local" | "worktree")).map(|f| f[1].to_string()).collect()) +} + +/// The env pairs filters_off hands git: a git before 2.31 ignores GIT_CONFIG_COUNT, and the filters would run. +fn config_env_known() -> Result<(), String> { + static SAID: std::sync::OnceLock = std::sync::OnceLock::new(); + let said = match SAID.get() { + Some(s) => s, + None => { + let s = read_git(Path::new("/"), ["--version"], &[])?; + SAID.get_or_init(|| s) + } + }; + floor(said) +} + +fn floor(said: &str) -> Result<(), String> { + let needs = "nerd needs git 2.31 or later to keep a repository's filters and hooks from running"; + match version(said) { + Some(v) if v >= (2, 31, 0) => Ok(()), + Some(_) => Err(format!("{said}; {needs}")), + None => Err(format!("git's version could not be read from {said:?}; {needs}")), + } +} + +/// `git version 2.39.5 (Apple Git-154)` as (2, 39, 5). +pub fn version(said: &str) -> Option<(u32, u32, u32)> { + let mut p = said.split_whitespace().nth(2)?.split('.').map(|x| x.parse::().ok()); + Some((p.next()??, p.next()??, p.next().flatten().unwrap_or(0))) +} + /// Logs the first failure only: callers retry on every refresh. fn filters_unknown(e: &str) { static ONCE: std::sync::Once = std::sync::Once::new(); @@ -198,31 +251,46 @@ fn sibling_path(dir: &Path, branch: &str) -> Result { } /// Branch existence isn't checked here; git refuses with its own message, which is -/// exactly what the user should see. -pub fn add_worktree(dir: &Path, branch: &str) -> Result { +/// exactly what the user should see. With the new checkout, a notice naming the repository's filters git did not run. +pub fn add_worktree(dir: &Path, branch: &str) -> Result<(PathBuf, Option), String> { validate_branch_name(branch)?; let dest = sibling_path(dir, branch)?; let dest_str = dest.to_string_lossy().into_owned(); + let env = worktree_env(dir)?; // `--` keeps a name from an input field from ever being read as a flag. - run_git(dir, ["worktree", "add", "-b", branch, "--", dest_str.as_str()]).map(|_| dest) + run_git(dir, &env, ["worktree", "add", "-b", branch, "--", dest_str.as_str()])?; + let names: Vec<&str> = env.iter().filter_map(|(_, v)| v.strip_prefix("filter.")?.strip_suffix(".smudge")).collect(); + let notice = match names.as_slice() { + [] => None, + [one] => Some(format!("{one}, the repository's own filter, was not run in the new checkout")), + many => Some(format!("{}, the repository's own filters, were not run in the new checkout", many.join(", "))), + }; + Ok((dest, notice)) } /// Parses `git worktree list --porcelain`: blank-line-separated entries with /// `worktree`/`HEAD`/`branch refs/heads/` keys. Git guarantees the first entry is main. pub fn worktrees(dir: &Path) -> Vec { - let Ok(raw) = read_git(dir, ["worktree", "list", "--porcelain"], &[]) else { - return Vec::new(); + // -z keeps a path with a newline in it one entry; a git before 2.36 refuses it, and its lines are read instead. + let (raw, sep) = match read_git(dir, ["worktree", "list", "--porcelain", "-z"], &[]) { + Ok(raw) => (raw, '\0'), + Err(_) => match read_git(dir, ["worktree", "list", "--porcelain"], &[]) { + Ok(raw) => (raw, '\n'), + Err(_) => return Vec::new(), + }, }; - let current = dir.canonicalize().ok(); + parse_worktrees(&raw, sep, dir.canonicalize().ok()) +} +fn parse_worktrees(raw: &str, sep: char, current: Option) -> Vec { let mut result = Vec::new(); let mut path: Option = None; let mut branch: Option = None; let mut is_first = true; - // Synthetic trailing empty line closes the last entry: run_git's trim() may have + // Synthetic trailing empty field closes the last entry: run_within's trim_end() may have // eaten the real separator after it. - for line in raw.lines().chain(std::iter::once("")) { + for line in raw.split(sep).chain(std::iter::once("")) { if line.is_empty() { if let Some(p) = path.take() { let is_current = p.canonicalize().ok() == current; @@ -243,7 +311,17 @@ pub fn worktrees(dir: &Path) -> Vec { /// Removes a checkout from disk. Doesn't touch the branch. pub fn remove_worktree(dir: &Path, path: &Path) -> Result<(), String> { let path_str = path.to_string_lossy().into_owned(); - run_git(dir, ["worktree", "remove", "--", path_str.as_str()]).map(|_| ()) + run_git(dir, &remove_env(dir, path)?, ["worktree", "remove", "--", path_str.as_str()]).map(|_| ()) +} + +/// Why nerd will not remove `path`, asked before its space closes. +pub fn removable(dir: &Path, path: &Path) -> Result<(), String> { + remove_env(dir, path).map(|_| ()) +} + +fn remove_env(dir: &Path, path: &Path) -> Result, String> { + // The checkout's own config: git reads its status before it removes it. + worktree_env(if path.is_dir() { path } else { dir }) } /// `--porcelain=v1 -z`: unescaped paths, NUL-separated entries. On rename/copy an @@ -288,7 +366,7 @@ pub fn status_marks(dir: &Path) -> HashMap { return marks; }; - // run_git's trim doesn't touch the trailing NUL, so split('\0') leaves an empty + // run_within's trim_end doesn't touch the trailing NUL, so split('\0') leaves an empty // trailing token; drop it. let mut fields = raw.split('\0').filter(|f| !f.is_empty()); while let Some(entry) = fields.next() { @@ -551,6 +629,24 @@ pub(crate) mod tests { dir } + #[test] + fn a_checkout_whose_path_has_a_newline_is_one_entry() { + if !git_available() { + return; + } + let dir = init_repo("newline"); + let odd = dir.with_file_name(format!("{}-a\nworktree b", dir.file_name().unwrap().to_string_lossy())); + run(&dir, &["worktree", "add", "-q", "-b", "nl", odd.to_str().unwrap()]); + let list = worktrees(&dir); + assert_eq!(list.len(), 2, "{list:?}"); + assert_eq!(list[1].path, odd.canonicalize().unwrap()); + assert_eq!(list[1].branch.as_deref(), Some("nl")); + let old = parse_worktrees("worktree /a\nHEAD 1\nbranch refs/heads/main\n\nworktree /b\nbranch refs/heads/x", '\n', None); + assert_eq!(old.iter().map(|w| (w.path.clone(), w.branch.clone())).collect::>(), [(PathBuf::from("/a"), Some("main".into())), (PathBuf::from("/b"), Some("x".into()))], "an older git's lines"); + let _ = std::fs::remove_dir_all(&odd); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn repo_facts_and_worktree_lifecycle() { if !git_available() { @@ -570,7 +666,8 @@ pub(crate) mod tests { assert!(list[0].is_main); assert!(list[0].is_current); - let added = add_worktree(&dir, "feature-x").expect("creating a checkout"); + let (added, skipped) = add_worktree(&dir, "feature-x").expect("creating a checkout"); + assert_eq!(skipped, None, "no filter of the repository's own, no notice"); assert!(added.is_dir()); assert_eq!(repo_kind(&added), RepoKind::Linked); @@ -606,7 +703,7 @@ pub(crate) mod tests { assert!(!is_main_checkout(&outside)); std::fs::remove_dir_all(&outside).unwrap(); - let added = add_worktree(&dir, "grouped").expect("creating a checkout"); + let (added, _) = add_worktree(&dir, "grouped").expect("creating a checkout"); let main_key = group_key(&dir).expect("main checkout's key"); assert_eq!(group_key(&added), Some(main_key)); assert!(is_main_checkout(&dir)); @@ -931,21 +1028,128 @@ pub(crate) mod tests { std::fs::remove_dir_all(&tmp).unwrap(); } + /// Runs in a child process whose PATH starts with a git that says it is 2.30.0: set only by the test below. + #[test] + fn a_git_before_2_31_runs_no_status_or_diff_child() { + if std::env::var_os("NERD_GIT_OLD").is_none() { + return; + } + let (dir, root, hit) = hit_repo("old-git"); + run(&dir, &["config", "filter.x.clean", &format!("sh -c 'touch \"{}\"; cat'", hit.display())]); + std::fs::write(dir.join(".git/info/attributes"), "* filter=x\n").unwrap(); + let old = "git version 2.30.0; nerd needs git 2.31 or later to keep a repository's filters and hooks from running"; + assert_eq!(filters_off(&root).unwrap_err(), old); + assert_eq!(worktree_env(&root).unwrap_err(), old, "the floor text, not git's usage error from the include query"); + assert!(status_marks(&dir).is_empty(), "no status from a git that ignores GIT_CONFIG_COUNT"); + assert_eq!(diff(&dir, &root.join("README.md")), ""); + assert_eq!(add_worktree(&dir, "old").unwrap_err(), old, "no menu worktree either"); + assert!(!ran(&hit)); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_git_before_2_31_runs_no_status_or_diff() { + if !git_available() { + return; + } + let real = Command::new("sh").args(["-c", "command -v git"]).output().unwrap(); + let real = String::from_utf8_lossy(&real.stdout).trim().to_string(); + let bin = std::env::temp_dir().join(format!("nerd-git-test-old-bin-{}", std::process::id())); + std::fs::create_dir_all(&bin).unwrap(); + let fake = bin.join("git"); + std::fs::write(&fake, format!("#!/bin/sh\ncase \" $* \" in *' --version '*) echo 'git version 2.30.0'; exit 0;; *' --show-scope '*) echo 'error: unknown option show-scope' >&2; exit 129;; esac\nexec '{real}' \"$@\"\n")).unwrap(); + std::fs::set_permissions(&fake, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_default()); + let out = Command::new(std::env::current_exe().unwrap()) + .args(["git::tests::a_git_before_2_31_runs_no_status_or_diff_child", "--exact", "--test-threads=1"]) + .env("PATH", path) + .env("NERD_GIT_OLD", "1") + .output() + .unwrap(); + let text = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success() && text.contains("1 passed"), "{text}{}", String::from_utf8_lossy(&out.stderr)); + std::fs::remove_dir_all(&bin).unwrap(); + } + #[test] - fn adding_a_worktree_still_runs_the_repos_smudge() { + fn a_worktree_from_the_menu_runs_no_hook_filter_or_fsmonitor_of_the_repository() { if !git_available() { return; } - let (dir, _, hit) = hit_repo("smudge"); + let (dir, _, hit) = hit_repo("menu-worktree"); run(&dir, &["checkout", "-q", "--", "README.md"]); - run(&dir, &["config", "filter.s.smudge", &format!("sh -c 'touch \"{}\"; cat'", hit.display())]); + let touch = |what: &str| format!("touch '{}.{what}'", hit.display()); + let hook = dir.join(".git/hooks/post-checkout"); + std::fs::write(&hook, format!("#!/bin/sh\n{}\n", touch("hook"))).unwrap(); + std::fs::set_permissions(&hook, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + for (key, value) in [ + ("hook.x.command", touch("config-hook")), + ("hook.x.event", "post-checkout".into()), + ("filter.s.smudge", format!("sh -c \"{}; cat\"", touch("smudge"))), + ("filter.s.clean", format!("sh -c \"{}; cat\"", touch("clean"))), + ("core.fsmonitor", format!("{}; false", touch("fsmonitor"))), + ] { + run(&dir, &["config", key, &value]); + } std::fs::write(dir.join(".git/info/attributes"), "* filter=s\n").unwrap(); - let added = add_worktree(&dir, "smudged").expect("creating a checkout"); - assert!(ran(&hit), "a worktree the user asked for checks out as their git would"); - remove_worktree(&dir, &added).unwrap(); + let (added, skipped) = add_worktree(&dir, "menu").expect("creating a checkout"); + assert_eq!(skipped.as_deref(), Some("s, the repository's own filter, was not run in the new checkout")); + age(&added.join("README.md")); + let removed = remove_worktree(&dir, &added); + let fired: Vec<&str> = ["hook", "config-hook", "smudge", "clean", "fsmonitor"].into_iter().filter(|w| ran(&hit.with_extension(w))).collect(); + assert!(fired.is_empty(), "ran: {fired:?}"); + removed.expect("removing a checkout"); std::fs::remove_dir_all(&dir).unwrap(); } + /// Each include names its file only in the new checkout's context, where filters_off's read never looked. + #[test] + fn a_menu_worktree_is_refused_when_the_repository_includes_config() { + if !git_available() { + return; + } + let mut wrong = Vec::new(); + for case in ["gitdir", "onbranch", "worktree config"] { + let (dir, _, hit) = hit_repo(&format!("include-{}", case.replace(' ', "-"))); + run(&dir, &["checkout", "-q", "--", "README.md"]); + let touch = |what: &str| format!("touch '{}.{what}'", hit.display()); + let smudge = dir.join(".git/smudge.sh"); + std::fs::write(&smudge, format!("#!/bin/sh\n{}\nexec cat\n", touch("smudge"))).unwrap(); + std::fs::set_permissions(&smudge, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap(); + let planted = format!("[filter \"f\"]\n\tsmudge = \"{}\"\n[hook \"h\"]\n\tcommand = {}\n\tevent = post-checkout\n", smudge.display(), touch("hook")); + std::fs::write(dir.join(".git/x.cfg"), planted).unwrap(); + std::fs::write(dir.join(".git/info/attributes"), "* filter=f\n").unwrap(); + match case { + "gitdir" => run(&dir, &["config", "includeIf.gitdir:worktrees/.path", "x.cfg"]), + "onbranch" => run(&dir, &["config", "includeIf.onbranch:menu.path", "x.cfg"]), + _ => { + // From the main checkout it names a file above the repository; copied into the new one's folder, .git/x.cfg. + run(&dir, &["config", "extensions.worktreeConfig", "true"]); + std::fs::write(dir.join(".git/config.worktree"), "[include]\n\tpath = ../../x.cfg\n").unwrap(); + } + } + let added = add_worktree(&dir, "menu"); + let fired: Vec<&str> = ["smudge", "hook"].into_iter().filter(|w| ran(&hit.with_extension(w))).collect(); + if !added.as_ref().is_err_and(|e| e == INCLUDES) || !fired.is_empty() { + wrong.push(format!("{case}: {:?}, ran {fired:?}", added.as_ref().map(|a| &a.1))); + } + if let Ok((path, _)) = added { + let _ = std::fs::remove_dir_all(path); + } + std::fs::remove_dir_all(&dir).unwrap(); + } + assert!(wrong.is_empty(), "{wrong:#?}"); + } + + #[test] + fn the_version_floor_says_why() { + assert_eq!(floor("git version 2.39.5 (Apple Git-154)"), Ok(())); + assert_eq!(floor("git version 2.31.0"), Ok(())); + let needs = "nerd needs git 2.31 or later to keep a repository's filters and hooks from running"; + assert_eq!(floor("git version 2.30.9"), Err(format!("git version 2.30.9; {needs}"))); + assert_eq!(floor("hub version x"), Err(format!("git's version could not be read from \"hub version x\"; {needs}"))); + } + #[test] fn a_submodules_diff_programs_do_not_run_through_the_parent() { if !git_available() { diff --git a/src/hook.rs b/src/hook.rs index 24ce4db..ccdd133 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -990,7 +990,13 @@ fn write_settings(path: &Path, root: &Value) -> std::io::Result<()> { // dotfiles manager's) is written where it points, so the link stays the live file. let path = if path.is_symlink() { std::fs::canonicalize(path)? } else { path.to_path_buf() }; let tmp = path.with_extension("json.nerd-tmp"); - std::fs::write(&tmp, text)?; + // The file keeps its mode, a new one is the user's alone; set before any byte is in it. + use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; + let mode = std::fs::metadata(&path).map_or(0o600, |m| m.permissions().mode() & 0o777); + let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?; + f.set_permissions(std::fs::Permissions::from_mode(mode))?; + std::io::Write::write_all(&mut f, text.as_bytes())?; + drop(f); std::fs::rename(&tmp, &path) } @@ -1064,6 +1070,26 @@ fn remove_entries(root: &mut Value) { pub(crate) mod tests { use super::*; + #[test] + fn a_rewritten_config_keeps_its_mode_and_a_new_one_is_0600() { + use std::os::unix::fs::PermissionsExt as _; + let dir = std::env::temp_dir().join(format!("nerd-hook-mode-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + for want in [0o600, 0o644] { + let p = dir.join(format!("{want:o}.json")); + std::fs::write(&p, "{}").unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(want)).unwrap(); + write_settings(&p, &serde_json::json!({ "hooks": {} })).unwrap(); + assert_eq!(mode(&p), want, "{want:o}"); + } + let new = dir.join("new.json"); + write_settings(&new, &serde_json::json!({})).unwrap(); + assert_eq!(mode(&new), 0o600, "a new file"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_config_that_is_a_link_stays_a_link_and_its_target_is_written() { let dir = std::env::temp_dir().join(format!("nerd-hook-link-{}", std::process::id())); diff --git a/src/install.rs b/src/install.rs index 946b30d..1a1e026 100644 --- a/src/install.rs +++ b/src/install.rs @@ -192,11 +192,7 @@ fn git_exe() -> Result { } let out = crate::spawn::cmd(&exe).arg("--version").env_remove("GIT_EXEC_PATH").output().map_err(|e| format!("git: {e}"))?; let text = String::from_utf8_lossy(&out.stdout); - let v = text.split_whitespace().nth(2).and_then(|v| { - let mut p = v.split('.').map(|x| x.parse::().ok()); - Some((p.next()??, p.next()??, p.next().flatten().unwrap_or(0))) - }); - match v { + match crate::git::version(&text) { // Apple's git is asked about each time: removing the Command Line Tools turns it back into the stub. Some(v) if v >= GIT_MIN && exe == Path::new("/usr/bin/git") => Ok(exe), Some(v) if v >= GIT_MIN => Ok(FOUND.get_or_init(|| exe).clone()), @@ -420,6 +416,13 @@ pub fn fetch_tree(url: &str, local: bool, into: &Path) -> Result<(), String> { let commit = text(git(Net::No, Some(&repo), &["rev-parse", "--verify", "HEAD^{commit}"])?); inspect(&repo, &commit)?; git(Net::No, Some(&repo), &["checkout", "--quiet", "--detach", &commit])?; + // inspect reads each link's text; only the files on disk show a chain of links (s -> ., l -> s/..) leaving. + let root = repo.canonicalize().map_err(|e| e.to_string())?; + let mut problems = Vec::new(); + crate::agents::folder_rules(&root, &root, &mut problems); + if let Some(p) = problems.into_iter().next() { + return Err(format!("the catalog: {p}")); + } std::fs::remove_dir_all(repo.join(".git")).map_err(|e| e.to_string())?; let _g = guard()?; let placed = if into.exists() { swap(&repo, into) } else { std::fs::rename(&repo, into) }; diff --git a/src/isolation.rs b/src/isolation.rs index 1a89929..a3ff54b 100644 --- a/src/isolation.rs +++ b/src/isolation.rs @@ -79,6 +79,7 @@ pub fn wrap(id: PaneId, argv: &[String], cwd: &Path, iso: &Isolation, env: &mut }; let files = if iso.files == Files::Folder { "folder" } else { "everything" }; flags.extend(["--files", files, "--cwd", &cwd.to_string_lossy(), "--msock", &msock, "--msock-uid", &uid, "--bin", &bin].map(String::from)); + flags.extend(["--sock".into(), crate::session::socket_path().to_string_lossy().into_owned()]); if let Ok(dump) = std::env::var("NERD_HOOK_DUMP") { flags.extend(["--dump".into(), dump]); } @@ -208,6 +209,8 @@ struct Places { run: PathBuf, /// The pane's own `SSH_AUTH_SOCK`. agent: Option, + /// The server's own sockets by path: `NERD_SOCKET` may put them outside `root`. + nerd: Vec, } impl Places { @@ -246,6 +249,7 @@ impl Places { terminal_files, run: "/private/var/run".into(), agent: std::env::var_os("SSH_AUTH_SOCK").map(PathBuf::from).filter(|p| p.is_absolute()), + nerd: Vec::new(), home, }) } @@ -276,8 +280,10 @@ const SOCKETS: &[&str] = &[ const UNREAD: &[&str] = &[".ssh", ".aws", ".gnupg", ".netrc", ".docker", ".kube", ".azure", ".config/gh", ".config/gcloud", "Library/Mail", "Library/Messages", "Library/Cookies"]; /// The pane's Seatbelt profile. An account pane passes `Everything` or `Account`: the account confines its files. -pub fn profile(files: &Files, network: &Network, cwd: &Path, msock: &Path, port: Option) -> Result { - let p = Places::here()?; +/// `sock`: the server's socket, whose plugin and broker sockets sit beside it; None from a wrapper an older server started. +pub fn profile(files: &Files, network: &Network, cwd: &Path, msock: &Path, sock: Option<&Path>, port: Option) -> Result { + let nerd = sock.map(|s| ["sock", "psock", "xsock"].map(|e| crate::plugins::canon(&s.with_extension(e))).to_vec()).unwrap_or_default(); + let p = Places { nerd, ..Places::here()? }; if *files == Files::Folder { p.make_agent_folders(); } @@ -409,7 +415,7 @@ fn text(p: &Places, files: &Files, network: &Network, cwd: &Path, msock: &Path, "(deny network-outbound (regex #\"^/private/tmp/cc-socks\") (regex #\"^{}/cc-socks\") (regex #\"^{}/com\\.apple\\.launchd\\.[^/]+/Listeners$\"){}{})\n(allow network-outbound (literal {msock}))\n", crate::plugins::regex_escape(&tmp.to_string_lossy()), crate::plugins::regex_escape(&run.to_string_lossy()), - rules("literal", [run.join("docker.sock")].into_iter().chain(p.agent.clone()))?, + rules("literal", [run.join("docker.sock")].into_iter().chain(p.agent.clone()).chain(p.nerd.iter().cloned()))?, rules("subpath", [root.clone(), p.home.join(".codex"), tmp.join("podman")].into_iter().chain(SOCKETS.iter().map(|s| p.home.join(s))))? ); } @@ -589,7 +595,7 @@ pub fn pane_exec_main(args: &[String]) -> std::io::Result<()> { }; if !matches!(network, Network::Providers(_)) { let confined = files == Files::Folder || network != Network::Open; - let profile = profile(&files, &network, &cwd, &msock, None).unwrap_or_else(|e| fail(e)); + let profile = profile(&files, &network, &cwd, &msock, get("--sock").map(Path::new), None).unwrap_or_else(|e| fail(e)); fail(become_pane(cmd, confined.then_some(profile.as_str()), &data)); } let net = crate::proxy::Net::parse(need("--hosts")).unwrap_or_else(|| fail("--pane-exec: --hosts does not parse")); @@ -599,7 +605,7 @@ pub fn pane_exec_main(args: &[String]) -> std::io::Result<()> { cmd.env(k, format!("http://127.0.0.1:{port}")); } cmd.env("NODE_USE_ENV_PROXY", "1").env("NO_PROXY", "").env("no_proxy", ""); - let profile = profile(&files, &network, &cwd, &msock, Some(port)).unwrap_or_else(|e| fail(e)); + let profile = profile(&files, &network, &cwd, &msock, get("--sock").map(Path::new), Some(port)).unwrap_or_else(|e| fail(e)); // SAFETY: no thread exists yet, so the child may allocate; it never returns into the proxy's code. match unsafe { nix::unistd::fork() }? { nix::unistd::ForkResult::Child => { @@ -661,6 +667,7 @@ mod tests { terminal_files: vec![], run: d.join("run"), agent: Some(d.join("agent/s.sock")), + nerd: vec![], home, } } @@ -710,6 +717,7 @@ mod tests { terminal_files: vec![], run: "/private/var/run".into(), agent: Some("/Users/u/.1p/agent.sock".into()), + nerd: vec![], }; let m = Path::new("/Users/u/Library/Application Support/nerd/nerd.msock"); let t = |f: Files, n: Network, port| text(&p, &f, &n, d, m, port).unwrap(); @@ -898,7 +906,8 @@ mod tests { let mut env = vec![("NERD_STATE".to_string(), "/s".to_string()), ("NERD_MSOCK".into(), "/m.sock".into()), ("NERD_MSOCK_UID".into(), "501".into())]; let got = wrap(3, &argv, Path::new("/w"), &Isolation { files: Files::Folder, network: Network::None }, &mut env).unwrap(); let exe = std::env::current_exe().unwrap().to_string_lossy().into_owned(); - let want: Vec = [&exe, "--pane-exec", "--pane", "3", "--network", "none", "--files", "folder", "--cwd", "/w", "--msock", "/m.sock", "--msock-uid", "501", "--bin", &exe].iter().map(|s| s.to_string()).collect(); + let sock = crate::session::socket_path().to_string_lossy().into_owned(); + let want: Vec = [&exe, "--pane-exec", "--pane", "3", "--network", "none", "--files", "folder", "--cwd", "/w", "--msock", "/m.sock", "--msock-uid", "501", "--bin", &exe, "--sock", &sock].iter().map(|s| s.to_string()).collect(); assert_eq!(got[..want.len()], want[..], "{got:?}"); assert_eq!(got[got.len() - 3..], ["--", "/bin/zsh", "-l"], "{got:?}"); assert!(!env.iter().any(|(k, _)| k == "NERD_STATE"), "{env:?}"); @@ -1014,6 +1023,20 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn the_servers_own_sockets_are_closed_by_path_when_nerd_socket_puts_them_outside_the_state_root() { + let d = scratch("own-sockets"); + let nerd: Vec = ["sock", "psock", "xsock"].map(|e| d.join(format!("elsewhere/test.{e}"))).to_vec(); + let p = Places { nerd: nerd.clone(), ..places(&d) }; + let profile = text(&p, &Files::Folder, &Network::Open, &d.join("work"), Path::new("/tmp/none.msock"), None).unwrap(); + let deny = profile.lines().find(|l| l.starts_with("(deny network-outbound")).expect("the folder pane's socket rule"); + for s in &nerd { + assert!(deny.contains(&format!("(literal \"{}\")", s.display())), "{s:?} in {deny}"); + } + assert!(!deny.contains(".msock"), "the message socket stays open: {deny}"); + let _ = std::fs::remove_dir_all(&d); + } + #[test] fn folder_only_cannot_write_the_state_root_config_a_hook_file_or_zshrc() { let d = scratch("write"); diff --git a/src/msock.rs b/src/msock.rs index 1920134..e84416f 100644 --- a/src/msock.rs +++ b/src/msock.rs @@ -28,6 +28,9 @@ const PER_PANE: usize = 8; const SHARED: usize = 8; /// Over this many `Hook`/`Append` calls of one pane within `BUDGET_WINDOW`, only state words pass. const BUDGET: usize = 30; +/// State words count apart, far above any agent: a refused one is caught up by the pane's next. +/// ceiling: still up to ~100 `/bin/sh` a second per pane; folding a pane's queued state words into its last is the way past. +const STATE_BUDGET: usize = 300; const BUDGET_WINDOW: Duration = Duration::from_secs(3); const STATE_WORDS: &[&str] = &["working", "idle", "blocked", "fail", "stop", "notify"]; @@ -38,7 +41,9 @@ static INBOX: OnceLock = OnceLock::new(); static LIVE: AtomicUsize = AtomicUsize::new(0); static LIVE_SHARED: AtomicUsize = AtomicUsize::new(0); static PANES: Mutex>> = Mutex::new(None); -static SPENT: Mutex>>> = Mutex::new(None); +/// Each pane's recent calls, state words (true) apart from the rest. +type Spent = HashMap<(PaneId, bool), VecDeque>; +static SPENT: Mutex> = Mutex::new(None); static NO_AGENT: Mutex>> = Mutex::new(None); /// One request line of the wire, after `Hello`. @@ -378,8 +383,9 @@ fn here(pane: PaneId, req: Request, reader: &mut BufReader, until: I if !(1..=64).contains(&from.len()) || !from.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') { return Done::Reply(error("from is 1-64 of a-z, 0-9 and -")); } - if !spend(pane, STATE_WORDS.contains(&word.as_str()), Instant::now()) { - return Done::Reply(over_budget(pane)); + let state = STATE_WORDS.contains(&word.as_str()); + if !spend(pane, state, Instant::now()) { + return Done::Reply(over_budget(pane, state)); } Done::Reply(match crate::hook::run_for(pane, &word, &from, &payload) { Ok(out) => json!({ "out": out }), @@ -389,7 +395,7 @@ fn here(pane: PaneId, req: Request, reader: &mut BufReader, until: I Request::Put { file, data } => Done::Reply(answer(put(pane, &file, &data))), Request::Append { file, line } => { if !spend(pane, false, Instant::now()) { - return Done::Reply(over_budget(pane)); + return Done::Reply(over_budget(pane, false)); } Done::Reply(answer(append(pane, &file, &line))) } @@ -410,9 +416,10 @@ fn answer(r: Result<(), String>) -> Value { } } -fn over_budget(pane: PaneId) -> Value { - crate::log::warn_limited("message socket hook budget", &format!("pane {pane}: over {BUDGET} hook calls within 3 s; only state words pass")); - error(format!("over {BUDGET} hook calls within 3 s; only state words pass")) +fn over_budget(pane: PaneId, state: bool) -> Value { + let why = if state { format!("over {STATE_BUDGET} state words within 3 s") } else { format!("over {BUDGET} hook calls within 3 s; only state words pass") }; + crate::log::warn_limited("message socket hook budget", &format!("pane {pane}: {why}")); + error(why) } /// Exactly `n` bytes by `until`, or None. @@ -435,16 +442,17 @@ fn read_body(reader: &mut BufReader, n: usize, until: Instant) -> Op Some(buf) } -/// Counts a `Hook` or `Append` call; false when over the budget, unless `always`. -fn spend(pane: PaneId, always: bool, now: Instant) -> bool { +/// Counts a `Hook` or `Append` call, a state word against its own budget; false when over it. +fn spend(pane: PaneId, state: bool, now: Instant) -> bool { + let budget = if state { STATE_BUDGET } else { BUDGET }; let mut g = SPENT.lock().unwrap_or_else(|e| e.into_inner()); - let q = g.get_or_insert_default().entry(pane).or_default(); - let over = q.len() >= BUDGET && q.front().is_some_and(|t| now.duration_since(*t) <= BUDGET_WINDOW); + let q = g.get_or_insert_default().entry((pane, state)).or_default(); + let over = q.len() >= budget && q.front().is_some_and(|t| now.duration_since(*t) <= BUDGET_WINDOW); q.push_back(now); - if q.len() > BUDGET { + if q.len() > budget { q.pop_front(); } - always || !over + !over } /// The verified pane's own file under the hook's folder. @@ -633,9 +641,16 @@ mod tests { } let t = t + Duration::from_millis(100); assert!(!spend(pane, false, t), "a tool word over the budget"); - assert!(spend(pane, true, t), "a state word always passes"); + assert!(spend(pane, true, t), "a state word still passes"); assert!(!spend(pane, false, t), "Append too"); assert!(spend(pane, false, t + Duration::from_secs(4)), "the window moved on"); + let pane = 90_021; + for i in 0..STATE_BUDGET { + assert!(spend(pane, true, t + Duration::from_millis(i as u64)), "state word {i}"); + } + assert!(!spend(pane, true, t + Duration::from_millis(400)), "the 301st state word within 3 s"); + assert!(spend(pane, false, t + Duration::from_millis(400)), "tool words count apart"); + assert!(spend(pane, true, t + Duration::from_secs(4)), "the window moved on"); } #[test] diff --git a/src/paths.rs b/src/paths.rs index c46ebe6..36edd38 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -84,12 +84,15 @@ pub fn config_path() -> PathBuf { /// Via a 0600 temp file (`path` with extension `tmp_ext`) and a rename: no reader sees a partial or wrong-mode file. pub fn write_private(path: &Path, tmp_ext: &str, bytes: &[u8]) -> std::io::Result<()> { let tmp = path.with_extension(tmp_ext); - let mut f = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&tmp)?; + // A link planted at the temp name is never followed: removed, and the open tried once more. + let open = || std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).custom_flags(nix::libc::O_NOFOLLOW).open(&tmp); + let mut f = match open() { + Err(e) if e.raw_os_error() == Some(nix::libc::ELOOP) => { + std::fs::remove_file(&tmp)?; + open()? + } + r => r?, + }; // A temp file left by an older build keeps its mode through `open`. f.set_permissions(std::fs::Permissions::from_mode(0o600))?; f.write_all(bytes)?; @@ -127,6 +130,21 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_link_at_the_temporary_name_is_not_followed() { + let dir = std::env::temp_dir().join(format!("nerd-private-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (p, target) = (dir.join("config.json"), dir.join("elsewhere")); + std::fs::write(&target, "theirs").unwrap(); + std::os::unix::fs::symlink(&target, dir.join("config.json.tmp")).unwrap(); + write_private(&p, "json.tmp", b"ours").unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"theirs", "the link's target is unchanged"); + assert_eq!(std::fs::read(&p).unwrap(), b"ours"); + assert!(!p.is_symlink() && mode(&p) == 0o600); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn the_temporary_file_is_named_by_the_caller_and_ends_0600() { let dir = std::env::temp_dir().join(format!("nerd-private-tmp-{}", std::process::id())); diff --git a/src/plugins.rs b/src/plugins.rs index f53b5de..7d9b5a3 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -56,7 +56,7 @@ pub fn exited(exec: usize) { } fn descends(pid: i32, from: i32) -> bool { - pid == from || crate::transport::inside(pid, from, crate::transport::parent_pid) + pid == from || crate::transport::inside(pid, from, crate::transport::parent_pid) == Some(true) } fn proven(name: &str, pid: i32) -> bool { @@ -283,7 +283,8 @@ pub(crate) const TERMINALS: &str = "(deny file-read* file-write* file-ioctl (reg /// `p` as a string in a sandbox profile. pub(crate) fn quote(p: &Path) -> Result { - let s = p.to_string_lossy(); + // Not lossy: a byte that is not UTF-8 would become U+FFFD, and the rule would name another path than the one meant. + let Some(s) = p.to_str() else { return Err(format!("path {:?} is not UTF-8 and cannot go into a sandbox profile", p.to_string_lossy())) }; if s.contains(['"', '\\']) || s.chars().any(char::is_control) { return Err(format!("path {s:?} cannot go into a sandbox profile")); } @@ -628,7 +629,11 @@ fn take_slot(pid: i32) -> Result { Ok(Slot(owner)) } +/// Set once this server listens on its own plugin socket: a plugin is never handed a path no one here answers. +static BOUND: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + pub fn listen(listener: std::os::unix::net::UnixListener, tx: Inbox) { + BOUND.store(true, std::sync::atomic::Ordering::SeqCst); std::thread::spawn(move || { // SAFETY: geteuid has no preconditions and cannot fail. let me = unsafe { nix::libc::geteuid() }; @@ -891,6 +896,11 @@ impl Plugins { return Err(format!("plugin {} is starting; choose it again in a moment", m.name)); } let broker = crate::broker::exec_handle().ok_or(OLD_BROKER)?; + if !BOUND.load(std::sync::atomic::Ordering::SeqCst) { + let why = format!("plugin {} not started: this server's plugin socket did not bind", m.name); + crate::log::warn_limited("plugin socket unbound", &why); + return Err(why); + } let (Some(exec), Some(dir)) = (&m.exec, &m.dir) else { return Err(format!("plugin {} has nothing to run", m.name)) }; let data = real_data_dir(&m.name).map_err(|e| format!("plugin {}: data folder: {e}", m.name))?; let exe = std::env::current_exe().map_err(|e| e.to_string())?; @@ -1317,6 +1327,36 @@ mod tests { got } + #[test] + fn a_path_goes_into_a_profile_only_as_itself() { + use std::os::unix::ffi::OsStrExt as _; + assert_eq!(quote(Path::new("/a/b c")).unwrap(), "\"/a/b c\""); + assert!(quote(Path::new(std::ffi::OsStr::from_bytes(b"/a/\xff"))).unwrap_err().contains("not UTF-8")); + for bad in ["/a\"b", "/a\\b", "/a\nb"] { + assert!(quote(Path::new(bad)).is_err(), "{bad:?}"); + } + } + + #[test] + fn a_plugin_line_over_the_limit_ends_its_connection() { + let (ours, theirs) = UnixStream::pair().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let inbox = Inbox { tx, bell: crate::bell::new().0 }; + let t = std::thread::spawn(move || read_loop("p", 7, BufReader::new(theirs), &inbox)); + let mut w = ours.try_clone().unwrap(); + std::thread::spawn(move || { + let _ = w.write_all(&vec![b'x'; 8 << 20]); + let _ = w.write_all(b"{\"ReportState\":{\"pane\":1,\"state\":\"idle\"}}\n"); + }); + let end = Instant::now() + std::time::Duration::from_secs(1); + while !t.is_finished() { + assert!(Instant::now() < end, "the loop still reads with its writer open"); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert!(rx.try_iter().next().is_none(), "a message after the long line was read"); + drop(ours); + } + #[test] fn a_screen_message_on_the_plugin_socket_does_nothing() { use crossterm::event::{Event as Ev, KeyCode, KeyEvent, KeyModifiers}; diff --git a/src/proto.rs b/src/proto.rs index ef4034f..e66b213 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -194,14 +194,20 @@ impl PluginMsg { } } +/// Whether `msg` goes out as a line `read_json` takes whole. +pub fn fits(msg: &impl Serialize) -> bool { + serde_json::to_vec(msg).is_ok_and(|v| (v.len() as u64) < MAX_LINE) +} + /// One JSON line, capped at `MAX_LINE`; `None` at EOF. pub fn read_json(r: &mut impl BufRead) -> std::io::Result> { let mut line = String::new(); if r.by_ref().take(MAX_LINE).read_line(&mut line)? == 0 { return Ok(None); } + // InvalidData, not Other: the rest of that line must never be read as the next message, so the connection ends. if !line.ends_with('\n') && line.len() as u64 >= MAX_LINE { - return Err(std::io::Error::other("message longer than the limit")); + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "message longer than the limit")); } let msg = serde_json::from_str(line.trim_end()).map_err(std::io::Error::other)?; Ok(Some(msg)) @@ -690,11 +696,24 @@ mod tests { } } + #[test] + fn a_paste_that_fits_is_one_the_reader_takes_whole() { + use crossterm::event::Event; + let at = |n: usize| ClientMsg::Event(Event::Paste("x".repeat(n))); + assert!(fits(&at(1000))); + assert!(!fits(&at(MAX_LINE as usize))); + // Each control character is escaped to six bytes: the line, not the paste, is what counts. + assert!(!fits(&ClientMsg::Event(Event::Paste("\u{1}".repeat(MAX_LINE as usize / 6 + 1))))); + let mut line = serde_json::to_vec(&at(MAX_LINE as usize - 64)).unwrap(); + line.push(b'\n'); + assert!(ClientMsg::read_from(&mut std::io::Cursor::new(line)).is_ok(), "what fits is read"); + } + #[test] fn a_line_over_the_limit_is_an_error_not_a_hang() { let junk = vec![b'x'; (MAX_LINE + 10) as usize]; let mut r = std::io::Cursor::new(junk); - assert!(ClientMsg::read_from(&mut r).is_err()); + assert_eq!(ClientMsg::read_from(&mut r).unwrap_err().kind(), std::io::ErrorKind::InvalidData, "ends the connection"); } #[test] diff --git a/src/term.rs b/src/term.rs index 9552f29..d2f3971 100644 --- a/src/term.rs +++ b/src/term.rs @@ -88,6 +88,14 @@ fn private_mode(bytes: &[u8], mode: &[u8]) -> Option { found } +/// Whether `text` shows the blocked-text entry `t`; a digit in it stands for any of 1-9, the option the cursor is on. +fn shows(t: &str, text: &str) -> bool { + match t.find(|c: char| c.is_ascii_digit()) { + Some(i) => ('1'..='9').any(|d| text.contains(&format!("{}{d}{}", &t[..i], &t[i + 1..]))), + None => text.contains(t), + } +} + /// Whether a ground counts as dark: its luma under the middle. pub fn dark((r, g, b): (u8, u8, u8)) -> bool { (r as u32 * 299 + g as u32 * 587 + b as u32 * 114) / 1000 < 128 @@ -404,10 +412,17 @@ impl Term { /// Whether `text` shows the agent's question; a "❯ 1." row matches whichever option the cursor is on. fn asks(&self, text: &str) -> bool { - self.blocked_text.iter().any(|t| match t.find(|c: char| c.is_ascii_digit()) { - Some(i) => ('1'..='9').any(|d| text.contains(&format!("{}{d}{}", &t[..i], &t[i + 1..]))), - None => text.contains(t.as_str()), - }) + self.blocked_text.iter().any(|t| shows(t, text)) + } + + /// Whether the live screen shows one of the question's numbered choices ("❯ 1."), whatever the hook says: + /// a carriage return typed now would answer it. + pub fn shows_question(&self) -> bool { + let numbered = || self.blocked_text.iter().filter(|t| t.contains(|c: char| c.is_ascii_digit())); + numbered().next().is_some() && { + let live = self.screen().live_contents(); + numbered().any(|t| shows(t, &live)) + } } /// Base state disregarding "seen": what's visible on screen and by timing. diff --git a/src/transport.rs b/src/transport.rs index cd290f8..5dc57ef 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -333,7 +333,7 @@ pub fn read_client(id: u64, stream: UnixStream, tx: Inbox, greeted: bool, waitin _ => None, }; if let Some(what) = what - && refused(&out, what, allow) + && refused(&peer, what, allow) { let line = format!("refused: {what} from inside a pane"); let text = if what == "attach" { farewell_text(&line) } else { format!("{line}\n") }; @@ -344,7 +344,7 @@ pub fn read_client(id: u64, stream: UnixStream, tx: Inbox, greeted: bool, waitin Ok(Some(hello @ ClientMsg::Hello { .. })) => { let (Some(caps), ClientMsg::Hello { cwd, cols, rows, host, if_free, reopened, .. }) = (crate::proto::ClientCaps::from_v1_hello(&hello), hello) else { return }; let program = peer_program(&out); - let inside = nix::sys::socket::getsockopt(&out, nix::sys::socket::sockopt::LocalPeerPid).is_ok_and(|pid| inside(pid, crate::broker::pid(), parent_pid)); + let inside = peer.inside == Some(true); let attach = Inbound::Attach { id, stream: out, cwd, cols, rows, host, program, caps, inside, if_free, reopened }; if tx.send(attach).is_err() { return; @@ -388,11 +388,11 @@ pub fn read_client(id: u64, stream: UnixStream, tx: Inbox, greeted: bool, waitin } // A client newer than this daemon sending an unknown message: log it, don't disconnect. Err(e) if e.kind() == std::io::ErrorKind::Other => { - warn(&format!("client {id}: unrecognized message: {e}")); + crate::log::warn_limited("client message unrecognized", &format!("client {id}: unrecognized message: {}", crate::log::escape(&e.to_string()))); } // The screen has one writer, so a refusal here is only logged. - Ok(Some(ClientMsg::Shutdown)) if refused(reader.get_ref(), "stop", allow) => {} - Ok(Some(ClientMsg::Update)) if refused(reader.get_ref(), "update", allow) => {} + Ok(Some(ClientMsg::Shutdown)) if refused(&peer, "stop", allow) => {} + Ok(Some(ClientMsg::Update)) if refused(&peer, "update", allow) => {} Ok(Some(ClientMsg::Shutdown)) => { let _ = tx.send(Inbound::Shutdown); return; @@ -434,14 +434,21 @@ pub(crate) fn farewell_text(text: &str) -> String { format!("\x1b[?1049l\r\n{text}\r\n\x1b[?1049h") } -/// Whether the peer runs inside one of this server's panes, with `allow_from_panes` off or the peer a plugin's; a refusal is logged. -fn refused(stream: &UnixStream, what: &str, allow: fn() -> bool) -> bool { - let Ok(pid) = nix::sys::socket::getsockopt(stream, nix::sys::socket::sockopt::LocalPeerPid) else { return false }; - if !inside(pid, crate::broker::pid(), parent_pid) || let_in(allow(), crate::plugins::within_a_plugin(pid)) { - return false; +/// Whether the peer runs inside one of this server's panes, with `allow_from_panes` off or the peer a plugin's, or its +/// ancestors could not be read at connect (a pane's program that wrote and exited at once); a refusal is logged. +fn refused(peer: &Peer, what: &str, allow: fn() -> bool) -> bool { + match (peer.inside, peer.pid) { + (Some(false), _) => false, + (Some(true), Some(pid)) if let_in(allow(), crate::plugins::within_a_plugin(pid)) => false, + (Some(true), pid) => { + warn(&format!("refused {what} from pid {}, inside a pane", pid.unwrap_or(0))); + true + } + (None, pid) => { + warn(&format!("refused {what} from pid {}: its ancestors could not be read", pid.map_or("unknown".into(), |p| p.to_string()))); + true + } } - warn(&format!("refused {what} from pid {pid}, inside a pane")); - true } /// A peer inside: `allow_from_panes` lets a pane's program in, never a plugin's. @@ -449,17 +456,21 @@ fn let_in(allow_from_panes: bool, plugin: bool) -> bool { allow_from_panes && !plugin } -/// Whether `anchor` is among `pid`'s ancestors, read by `parent`, within 32 steps and short of launchd. -pub(crate) fn inside(pid: i32, anchor: i32, parent: impl Fn(i32) -> Option) -> bool { +/// Whether `anchor` is among `pid`'s ancestors, read by `parent`, within 32 steps and short of launchd; None when a +/// parent cannot be read or the 32 steps run out, which is not "outside". +pub(crate) fn inside(pid: i32, anchor: i32, parent: impl Fn(i32) -> Option) -> Option { let mut pid = pid; for _ in 0..32 { - let Some(p) = parent(pid).filter(|p| *p > 1) else { return false }; + let p = parent(pid)?; + if p <= 1 { + return Some(false); + } if p == anchor { - return true; + return Some(true); } pid = p; } - false + None } /// The peer's process name, or its `sshd*` ancestor's: a relay started over ssh is itself `nerd`. @@ -484,6 +495,8 @@ pub(crate) fn proc_name(pid: i32) -> Option { /// Each pid with its start time read at connect, so a pid reused by the detach reads `gone`. pub(crate) struct Peer { pid: Option, + /// Inside one of this server's panes, read once at connect; None when the pid or its ancestors cannot be read. + pub(crate) inside: Option, started: Option, top: Option<(i32, String, Option)>, } @@ -492,7 +505,8 @@ impl Peer { pub(crate) fn of(stream: &UnixStream) -> Peer { let pid = nix::sys::socket::getsockopt(stream, nix::sys::socket::sockopt::LocalPeerPid).ok(); let top = pid.map(|p| top_ancestor(p, parent_pid)).filter(|t| Some(*t) != pid).map(|t| (t, proc_name(t).unwrap_or_else(|| "unknown".into()), start_time(t))); - Peer { pid, started: pid.and_then(start_time), top } + let inside = pid.and_then(|p| inside(p, crate::broker::pid(), parent_pid)); + Peer { pid, inside, started: pid.and_then(start_time), top } } /// `client pid 812 running, ghostty pid 700 gone`. @@ -694,7 +708,7 @@ mod tests { fn a_pid_reused_since_the_connect_reads_gone() { let me = std::process::id() as i32; let started = super::start_time(me); - let peer = super::Peer { pid: Some(me), started, top: Some((me, "ghostty".into(), Some(1))) }; + let peer = super::Peer { pid: Some(me), inside: Some(false), started, top: Some((me, "ghostty".into(), Some(1))) }; assert_eq!(peer.states(), format!("client pid {me} running, ghostty pid {me} gone")); assert_eq!(super::state_since(me, None), "running", "a start time that could not be read is not compared"); } @@ -824,25 +838,26 @@ mod tests { }; // S1: tool shell 73876 -> claude 11908 -> zsh 9663 -> broker 9619 -> daemon 9618 -> launchd let pane = chain(&[(73876, 11908), (11908, 9663), (9663, 9619), (9619, 9618), (9618, 1)]); - assert!(super::inside(73876, 9619, &pane), "an agent's tool shell"); - assert!(super::inside(9663, 9619, &pane), "the pane's shell"); - assert!(super::inside(73876, 9618, &pane), "a handover from a build that kept no broker pid falls back to the daemon's"); - assert!(!super::inside(9619, 9619, &pane), "the broker itself is not a client"); + assert_eq!(super::inside(73876, 9619, &pane), Some(true), "an agent's tool shell"); + assert_eq!(super::inside(9663, 9619, &pane), Some(true), "the pane's shell"); + assert_eq!(super::inside(73876, 9618, &pane), Some(true), "a handover from a build that kept no broker pid falls back to the daemon's"); + assert_eq!(super::inside(9619, 9619, &pane), Some(false), "the broker itself is not a client"); // After an in-place update the daemon keeps its pid and the broker is adopted, so the carried pid still matches. let after = chain(&[(73876, 11908), (11908, 9663), (9663, 9619), (9619, 9618)]); - assert!(super::inside(73876, 9619, after)); + assert_eq!(super::inside(73876, 9619, after), Some(true)); let terminal = chain(&[(502, 501), (501, 500), (500, 1)]); - assert!(!super::inside(502, 9619, &terminal), "a terminal's shell"); - assert!(!super::inside(502, 1, &terminal), "launchd is never an anchor"); + assert_eq!(super::inside(502, 9619, &terminal), Some(false), "a terminal's shell"); + assert_eq!(super::inside(502, 1, &terminal), Some(false), "launchd is never an anchor"); let detached = chain(&[(800, 1)]); - assert!(!super::inside(800, 9619, detached), "a double fork escapes: the stated limit"); - let gone = chain(&[(900, 901)]); - assert!(!super::inside(900, 9619, gone), "an ancestor that cannot be read ends the walk"); + assert_eq!(super::inside(800, 9619, detached), Some(false), "a double fork escapes: the stated limit"); + let gone = chain(&[(73876, 11908), (11908, 9663)]); + assert_eq!(super::inside(73876, 9619, gone), None, "an ancestor that cannot be read mid-chain is not outside"); + assert_eq!(super::inside(900, 9619, chain(&[])), None, "nor is a pid whose own parent cannot be read"); let cycle = chain(&[(5, 6), (6, 5)]); - assert!(!super::inside(5, 9619, cycle), "a loop ends at the cap"); + assert_eq!(super::inside(5, 9619, cycle), None, "a loop uses up the cap"); let long: Vec<(i32, i32)> = (0..40).map(|i| (1000 + i, 1001 + i)).collect(); - assert!(super::inside(1000, 1032, chain(&long)), "32 steps up"); - assert!(!super::inside(1000, 1033, chain(&long)), "not 33"); + assert_eq!(super::inside(1000, 1032, chain(&long)), Some(true), "32 steps up"); + assert_eq!(super::inside(1000, 1033, chain(&long)), None, "33 steps are not read, so not outside"); assert_eq!(crate::broker::pid(), std::process::id() as i32, "with no broker the daemon's own pid is the anchor"); } @@ -904,6 +919,41 @@ mod tests { assert!(!acted(&got), "the screen was taken"); } + #[test] + fn a_program_gone_before_its_ancestors_were_read_is_refused_even_with_panes_allowed() { + let path = std::env::temp_dir().join(format!("nerd-gone-{}.sock", std::process::id())); + let _ = std::fs::remove_file(&path); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + // Connected, wrote and exited before the server accepts: nothing about it can be read any more. + let send = r#"$s = IO::Socket::UNIX->new(Peer => $ARGV[0]) or die; print $s qq("Shutdown"\n); close $s"#; + assert!(std::process::Command::new("/usr/bin/perl").args(["-MIO::Socket::UNIX", "-e", send]).arg(&path).status().unwrap().success()); + let (stream, _) = listener.accept().unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(super::Peer::of(&stream).inside, None, "neither outside nor inside"); + let (tx, rx) = std::sync::mpsc::channel(); + let inbox = super::Inbox { tx, bell: crate::bell::new().0 }; + std::thread::spawn(move || super::read_client(1, stream, inbox, false, super::Waiting::default(), || true)); + let got: Vec<_> = std::iter::from_fn(|| rx.recv_timeout(Duration::from_millis(500)).ok()).collect(); + assert!(!acted(&got), "a stop from a process that left no trace went through"); + } + + #[test] + fn a_line_over_the_limit_ends_the_connection_and_nothing_after_it_is_read() { + let (ours, theirs) = std::os::unix::net::UnixStream::pair().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let inbox = super::Inbox { tx, bell: crate::bell::new().0 }; + std::thread::spawn(move || super::read_client(1, theirs, inbox, true, super::Waiting::default(), || false)); + let mut w = ours.try_clone().unwrap(); + std::thread::spawn(move || { + let _ = std::io::Write::write_all(&mut w, &vec![b'x'; 8 << 20]); + let _ = crate::proto::ClientMsg::Appearance(true).write_to(&mut w); + }); + let got = rx.recv_timeout(Duration::from_secs(1)).expect("no Detach within 1 s"); + assert!(matches!(&got, super::Inbound::Detach(1, why) if why.starts_with(super::READ_ERROR)), "the writer is still open"); + assert!(rx.recv_timeout(Duration::from_millis(200)).is_err(), "a message after the long line was read"); + drop(ours); + } + #[test] fn after_hello_a_program_inside_a_pane_still_cannot_stop_or_update() { let (out, got) = from_a_child("\"Update\"\n\"Shutdown\"\n", true, || false); diff --git a/tests/daemon.rs b/tests/daemon.rs index f4f9a87..c61c720 100644 --- a/tests/daemon.rs +++ b/tests/daemon.rs @@ -731,7 +731,9 @@ fn only_a_remote_client_hears_the_markers_and_its_way_back() { // The server stops: the remote client learns why, and no BYE claims it was on purpose. let mut remote = connect_remote(&env); assert!(has(&read_until(&mut remote, ARMED, Duration::from_secs(5)), ARMED)); - ClientMsg::Shutdown.write_to(&mut UnixStream::connect(&env.socket).unwrap()).unwrap(); + // Held open until the server exits: a peer gone before its pid is read is refused, as a pane's program would be. + let mut stop = UnixStream::connect(&env.socket).unwrap(); + ClientMsg::Shutdown.write_to(&mut stop).unwrap(); // Read while it stops: the server waits only 1 s for a client that takes none of its bytes, then exits without the marker. let rest = read_until(&mut remote, b"\x00", Duration::from_secs(10)); assert!(wait_exit(&mut daemon.0, Duration::from_secs(10)).success()); @@ -1978,7 +1980,8 @@ fn a_stop_still_reaches_a_client_that_is_behind() { client.write_all(format!("{hello}\n").as_bytes()).unwrap(); assert!(has(&read_until(&mut client, ARMED, Duration::from_secs(5)), ARMED), "{}", daemon_log(&env)); fall_behind(&mut client, 1); - ClientMsg::Shutdown.write_to(&mut UnixStream::connect(&env.socket).unwrap()).unwrap(); + let mut stop = UnixStream::connect(&env.socket).unwrap(); + ClientMsg::Shutdown.write_to(&mut stop).unwrap(); // A slow reader: the marker is queued behind a full frame when the server stops. let marker = b"\x1b_nerd;stopped:--stop\x1b\\"; let (mut got, mut buf, deadline) = (Vec::new(), [0u8; 4096], Instant::now() + Duration::from_secs(5)); @@ -2363,6 +2366,32 @@ fn after_an_update_a_pane_is_still_refused() { let _ = std::fs::remove_dir_all(&env.dir); } +/// A pane's program that writes Stop and exits at once is refused whether the server read its ancestors in time or not +/// (the unit test in transport.rs pins the second case). +#[test] +fn a_pane_s_program_that_writes_a_stop_and_exits_at_once_is_refused() { + let env = setup_env("inside-gone"); + let mut daemon = spawn_daemon(&env); + let mut client = connect(&env, 200, 50); + assert!(wait_for(&mut client, "spaces", Duration::from_secs(5)), "no frame: {}", daemon_log(&env)); + let send = r#"/usr/bin/perl -MIO::Socket::UNIX -e '$s = IO::Socket::UNIX->new(Peer => $ARGV[0]) or die; print $s qq("Shutdown"\n); close $s'"#; + type_line(&mut client, &format!("{send} '{}'; echo $? >\"$HOME/rc\"", env.socket.display())); + let t = Instant::now(); + while !std::fs::read_to_string(env.dir.join("rc")).is_ok_and(|s| s.ends_with('\n')) { + assert!(t.elapsed() < Duration::from_secs(10), "the command did not finish: {}", daemon_log(&env)); + std::thread::sleep(Duration::from_millis(50)); + } + assert_eq!(std::fs::read_to_string(env.dir.join("rc")).unwrap(), "0\n", "the frame was sent"); + let t = Instant::now(); + while !daemon_log(&env).contains("refused stop from pid") && t.elapsed() < Duration::from_secs(5) { + std::thread::sleep(Duration::from_millis(50)); + } + assert!(daemon_log(&env).contains("refused stop from pid"), "{}", daemon_log(&env)); + assert!(daemon.0.try_wait().unwrap().is_none(), "the server stopped: {}", daemon_log(&env)); + stop_and_wait(&env, &mut daemon); + let _ = std::fs::remove_dir_all(&env.dir); +} + /// An agent nerd has never heard of, described by a manifest in the plugins folder: hooked after /// a yes, found in its pane, and back on its own session after the server restarts. #[test] @@ -2586,6 +2615,24 @@ fn a_silent_connection_to_the_plugin_socket_is_closed() { let _ = std::fs::remove_dir_all(&env.dir); } +/// A server whose plugin socket did not bind starts no plugin: it would hand it a path no one here answers. +#[test] +fn without_its_own_plugin_socket_the_server_starts_no_plugin() { + let env = setup_env("plugin-unbound"); + let data = install_plugin(&env, "badger", serde_json::json!({ "name": "badger", "title": "B", "exec": { "command": "sh", "args": ["plugin.sh"] }, "events": ["command"], "badges": ["tab"] })); + std::fs::write(env.dir.join("plugins/badger/plugin.sh"), "touch \"$NERD_PLUGIN_DATA/ran\"\nexec sleep 30\n").unwrap(); + // A folder that is not empty where the plugin socket goes: the bind fails, the server still starts. + std::fs::create_dir_all(env.dir.join("nerd.psock/held")).unwrap(); + let mut daemon = spawn_daemon(&env); + let mut client = connect(&env, 100, 30); + assert!(wait_for(&mut client, "spaces", Duration::from_secs(5)), "no frame: {}", daemon_log(&env)); + assert!(!wait_reading(&mut client, Duration::from_secs(3), || data.join("ran").exists()), "a plugin started: {}", daemon_log(&env)); + let log = daemon_log(&env); + assert!(log.contains("plugin socket failed") && log.contains("plugin badger not started: this server's plugin socket did not bind"), "{log}"); + stop_and_wait(&env, &mut daemon); + let _ = std::fs::remove_dir_all(&env.dir); +} + /// A plugin from tests/fixtures/plugins in this server's plugins folder; returns its data folder. fn install_plugin(env: &Env, name: &str, manifest: serde_json::Value) -> PathBuf { let dir = env.dir.join("plugins").join(name); diff --git a/tests/egress.rs b/tests/egress.rs index b6425f6..67c5dde 100644 --- a/tests/egress.rs +++ b/tests/egress.rs @@ -83,8 +83,7 @@ const ALLOW: &[(&str, &str, &str, &str, &str, &str)] = &[ ("src/client.rs", "switch_in_place", "spawn", "exe --resume", "local", "becomes nerd --resume N in the same terminal when you open another server of this Mac in this session"), ("src/daemon.rs", "start", "spawn", "& exe --version", "local", "asks the installed build its version"), ("src/daemon.rs", "handover_exec", "command", "std :: env :: current_exe ()", "local", "update in place: the new build takes over the open panes and sockets"), - ("src/git.rs", "run_git", "spawn", "\"git\"", "local", "the file tree's worktree add and remove, when you choose them"), - ("src/git.rs", "read_cmd", "spawn", "\"git\"", "local", "the file tree's git, without lazy fetch, fsmonitor or filters"), + ("src/git.rs", "read_cmd", "spawn", "\"git\"", "local", "the file tree's git and the menu's worktree add and remove, without lazy fetch, fsmonitor, the repository's hooks or filters"), ("src/install.rs", "git_exe", "spawn", "\"/usr/bin/xcode-select\"", "local", "checks that Apple's git is installed"), ("src/install.rs", "git_exe", "spawn", "& exe --version", "local", "asks git its version"), ("src/notify.rs", "run", "spawn", "cmd", "local", "runs what a notify call below names"),