diff --git a/AGENTS.md b/AGENTS.md index 420e96e..c074e86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ Never report a step as done before `mise run check` has passed, and say which ga - **No model calls in tests.** Unit and integration tests run a stand-in `copilot` (a script on `PATH`, such as `testdata/copilot`, which plays each role in the walking skeleton), never the real one. - **tmux on its own socket.** Every tmux server that a test, a smoke run or a live check starts gets a config file of its own (`-f /dev/null`, or a file of the test's), so the user's `~/.tmux.conf` and its plugins never load, and runs without `TMUX` and `TMUX_PANE` (`env -u TMUX -u TMUX_PANE`), so a slip can't reach the user's server. Tests run tmux with `-L baton-test-`, in a `TMUX_TMPDIR` and a `HOME` of their own, and kill only their servers. The walking skeleton (`TestSkeleton`) runs baton itself, so it runs tmux as a smoke run does, on the default socket, in a `TMUX_TMPDIR` (`/tmp/baton-test-skeleton-*`) and a `HOME` of its own. Smoke runs and live checks use a socket of their own, such as `-L baton-live`. baton itself uses tmux's default socket, or `TMUX`'s, so a smoke run of the binary gets a `TMUX_TMPDIR` and a `HOME` of its own, and starts that server first, with `-f /dev/null`; it never runs on the user's default server without the user's OK. The tmux tests' `TestMain` ends every server whose socket is in its `TMUX_TMPDIR`, even one that a test started on another socket, before it removes that folder. A test run that fails or stops early skips its cleanup, so after one, look for leftover servers (`pgrep -lf '^tmux .*(baton-test-|/Test)'`: a test's server shows its `-L baton-test-`, or its config file in the test's temporary folder), end each on its own socket, or with `kill ` once its socket is gone, and remove leftover `baton-tmux-` folders in `/tmp` and `baton-test-skeleton-*` folders in `/tmp` and `$TMPDIR`. Never kill the user's tmux server, and never touch a session or window that baton didn't create. - **git without the user's config.** Tests run git, theirs and baton's, with `GIT_CONFIG_GLOBAL=/dev/null`, `GIT_CONFIG_NOSYSTEM=1`, `LC_ALL=C` and a test author, and without the `GIT_*` variables that a hook of git's sets, such as `GIT_DIR`: each package's `TestMain` sets them. A test spells out git's arguments, so it runs no command that it doesn't show. -- **baton without the user's config.** Tests run baton with a `HOME` of their own and without `XDG_CONFIG_HOME` and `XDG_CACHE_HOME`, so it never reads the user's config file or writes in their cache: the cli tests' `TestMain` gives them an empty `HOME`, a `baton-test-home-*` folder in `$TMPDIR`, and a test that writes there, such as a config file, gives itself one of its own first. `TestMain` fails the run if a test left files in that folder. A run that stops early, such as one whose test panics, leaves the folder, so remove it after one. +- **baton without the user's config.** Tests run baton with a `HOME` of their own and without `XDG_CONFIG_HOME` and `XDG_CACHE_HOME`, so it never reads the user's config file or writes in their cache: the cli tests' `TestMain` gives them an empty `HOME`, a `baton-test-home-*` folder in `$TMPDIR`, and a test that writes there, such as a config file, gives itself one of its own first. `TestMain` fails the run if a test left files in that folder. A run that stops early, such as one whose test panics, leaves the folder, so remove it after one. That `TestMain` also clears the GitHub tokens (`GH_TOKEN`, `GITHUB_TOKEN`, their enterprise forms and `COPILOT_GITHUB_TOKEN`), so that no test hands the user's token to a stand-in. - **Copilot CLI config stays untouched.** Develop with `--plugin-dir`, flags or a throwaway `COPILOT_HOME`, never by changing `~/.copilot/`. - **Live checks are rare and small**: a throwaway repo in the spikes folder, the cheapest model that can do the job, and a `--max-ai-credits` cap (30 at least, the CLI's minimum). The PR records each live check's cost. On a Mac, a live check's `copilot` keeps the user's `HOME`, whose login keychain holds its login, and gets a throwaway `COPILOT_HOME`; its tmux server and the commands sent to it run in a clean environment (`env -i`), since a pane takes the `PATH` of the client that makes it (fact 30 in the plan). diff --git a/README.md b/README.md index 9f036eb..60509ce 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ You talk to one agent, the orchestrator. It reads the ticket, plans the work as ## Requirements - macOS, git 2.36 or newer, and tmux 3.5 or newer. -- [GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/copilot-cli). +- [GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/copilot-cli), logged in with `copilot login`, or with a token in `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN`: `gh` doesn't work in the roles' panes, so their `copilot` can't take its login from `gh`. - `gh` for GitHub pull requests; a Bitbucket Cloud API token for Bitbucket pull requests; a read-only Jira API token for Jira tickets. ## Config diff --git a/docs/plan.md b/docs/plan.md index eaecd6b..d75b7c2 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -56,8 +56,8 @@ The first milestone is a walking skeleton (`skeleton`): one feature on a throwaw | `guard-http` | `internal/guard` reads the options of `curl`, `wget` and `gh api`, and watches only those that send data (decision 51, fact 35) | Done (#33) | | `guard-hook` | `baton hook guard` with its `preToolUse` hook in the plugin: in a role's pane, it denies, with a reason for the role, a bash call that the guard can't read or that runs a command that the guard watches, but baton's commands for a role (`baton wait`, `handover`, `ask`, `status` and help), and a `create` or `edit` of a file in baton's folder, however its path names it; it says nothing in the orchestrator's pane yet (decisions 9, 17, 48 and 52; fact 36, risk 11) | Done (#34) | | `plugin-check` | `baton pane` checks the files of the plugin folder that it finds in its cache before each `copilot` starts, a role's after its wait too, and writes the plugin under the next name beside a changed folder; `baton hook guard` denies a role's `create` and `edit` in baton's cache, so that a role can't turn the guard off for the panes that start after it (decisions 36, 52 and 53, risk 12) | Done (#35) | -| `guard-orchestrator` | In the orchestrator's pane, `baton hook guard` asks the user before each outward action, and before each `baton feature start` while `planApproval`, a new setting, is on; it lets baton's commands for the orchestrator run, denies the other commands that it watches, and denies `edit` and `create` outside the session's folder; the orchestrator runs with `--allow-all` (decisions 8, 19, 45 and 54; fact 37) | In review (#36) | -| `belt` | Risk 2's second belt, if it works: the role panes' credentials taken away, such as an empty `GH_CONFIG_DIR` and a git `pushInsteadOf` that fails | Todo | +| `guard-orchestrator` | In the orchestrator's pane, `baton hook guard` asks the user before each outward action, and before each `baton feature start` while `planApproval`, a new setting, is on; it lets baton's commands for the orchestrator run, denies the other commands that it watches, and denies `edit` and `create` outside the session's folder; the orchestrator runs with `--allow-all` (decisions 8, 19, 45 and 54; fact 37) | Done (#36) | +| `belt` | Risk 2's second belt, in a role's pane: settings in the environment of the role's `copilot`, which each command and script there inherits, so that git's pushes out of the machine fail, sent by `pushInsteadOf` rules to a remote helper that doesn't exist, with a rule for each remote's URL and the remotes' push URLs cleared, and `gh` fails, its config folder under `/dev/null`; the pane goes without the GitHub tokens of the user's environment, and `copilot` gets `GH_TOKEN`'s or `GITHUB_TOKEN`'s as `COPILOT_GITHUB_TOKEN`, for its own login (decision 55, fact 38, risk 2) | In review (#37) | | `gates` | The engineer's handover runs the repo's gate command and refuses a red one | Todo | | `jira` | `baton jira `: the ticket as text, from Jira's REST API with a read-only token in the Keychain (decision 6) | Todo | | `deliver-github` | `baton deliver` for GitHub: push the branch and open the pull request with `gh`, the lead's final report as its body | Todo | @@ -132,7 +132,8 @@ Checked on 2026-10-31 with Copilot CLI 1.0.105 and tmux 3.8, unless a fact says 34. **git's options and gh's commands** (git 2.56.0 and gh 2.103.2, on 2026-11-03). Before its command, git takes the next word as the value of `-C`, `-c`, `--attr-source`, `--config-env`, `--git-dir`, `--namespace`, `--shallow-file` and `--work-tree`, unless the option holds it after `=`, and fails on an option that it doesn't know, `--super-prefix` among them, which older gits take with a value. `--` ends git's options. git's and gh's commands match case: `git PUSH` fails with `cannot handle PUSH as a builtin`, and `gh PR view` with `unknown command "PR" for "gh"`. 35. **curl's, wget's and gh api's options** (curl 8.7.1, GNU Wget 1.25.0 and gh 2.103.2, on 2026-11-03, from their help and their requests to a server on 127.0.0.1). curl sends data with `-d` (`--data`, and its `-ascii`, `-binary`, `-raw` and `-urlencode` forms), `-F` (`--form`, `--form-string`), `--json` and `-T` (`--upload-file`), which, with no method (`-X`, `--request`), make a POST, or a PUT for `-T`. With `-X GET`, `-d` sends a GET with a body. `-G` puts the data of `-d` and `--json` into the URL, but leaves `-F` a POST and `-T` a PUT, and `--no-get` undoes it; `-I` with `-d` fails. `--expand-` before an option's name fills in variables (`--expand-data` POSTs), and `--next` (`-:`) starts another request with options of its own. curl refuses a long option's abbreviation; wget takes the start of a name (`--meth=POST`, `--post-d=x`). wget sends data with `--post-data` and `--post-file`, and, with `--method`, `--body-data` and `--body-file`; `-e` (`--execute`) gives it a line of its settings, whose name ignores case, `-` and `_` (`-e Post_Data=x` POSTs). gh api POSTs once it has a field (`-f`, `-F`, `--raw-field`, `--field`) or `--input`, unless `-X GET` (`--method`) puts the fields into the URL, and it reads `-X=GET` as `GET`. A short option that takes a value, by each tool's help, takes the rest of its word, or else the next word: curl's `AbcCdDeEFhHKmoPQrtTuUwxXyYz`, wget's `aABDeiIlnoOPQRtTUwX` (`-nv` is `-n` with `v`) and gh api's `fFHpqtX`. curl also reads its options from `~/.curlrc` and from `-K` (`--config`) files, and wget its settings from `~/.wgetrc` and the file that `WGETRC` names. 36. **The tools' arguments in a `preToolUse` hook** (Copilot CLI 1.0.105, on 2026-11-03, checked live). The hook's input names the tool in `toolName` and gives its arguments in `toolArgs`, which the hooks reference types as `unknown`, as a JSON object: for `bash`, `command` and `description`, with `mode` and, for an async call, `initial_wait` and `shellId` (the agent-wake spike's hook log); for `create`, `path` and `file_text`; for `edit`, `path`, `old_str` and `new_str`, the paths absolute, as the CLI's tool schemas ask. The input also has `sessionId`, `timestamp` and `cwd`. The answer `{"permissionDecision":"deny","permissionDecisionReason":…}` stops the tool, under `--allow-all` too, and the agent gets `Denied by preToolUse hook: ` with the reason, which a denial needs. The live check ran `copilot -p` with Claude Haiku 5.5, `--allow-all`, the plugin of `guard-hook` and a throwaway `COPILOT_HOME`, in a throwaway repo, for 0.21 AI credits: `baton hook guard` denied `tmux -V` and a `create` in baton's folder, and said nothing to a `create` and an `edit` of a file in the repo, which ran. Of a role's tools (decision 10), `bash`, `create` and `edit` write; `view`, `grep`, `glob` and `web_fetch` read; and `read_bash`, `stop_bash` and `list_bash` look after the shells that `bash` starts. -37. **The user's answer to a `preToolUse` hook's `ask`** (Copilot CLI 1.0.105, on 2026-11-03, checked live). In an interactive session under `--allow-all`, the answer `{"permissionDecision":"ask","permissionDecisionReason":…}` holds the tool and shows the "Hook permission request" of fact 4 in the pane: the reason, the tool's name and its arguments as JSON, and "Do you want to allow this tool call?". Yes runs the tool; No is a field for what the agent should do instead, which does nothing while it's empty. After a No, the agent gets `Denied by user via preToolUse hook prompt: ` with the reason, and `The user provided the following feedback: ` with the text; copilot puts a full stop after the reason, so a reason that ends in one shows two. The input's `sessionId` is the name of the session's folder in `$COPILOT_HOME/session-state/`. Without `COPILOT_ALLOW_ALL`, the folder-trust question still shows under `--allow-all` (fact 9), and the footer shows `Allow All`, where fact 27's showed `Manual Approval`. The live check ran the orchestrator's pane with Claude Haiku 5.5, the plugin of `guard-orchestrator` and a throwaway `COPILOT_HOME`, in a throwaway repo without a remote, on a tmux server of its own, for 0.32 AI credits: the guard asked about `git push --dry-run`, which the check declined with feedback, and about `baton feature start`, which it allowed, and which ran; a `create` in the session's `files/` folder ran, and one in the repo was denied. +37. **The user's answer to a `preToolUse` hook's `ask`** (Copilot CLI 1.0.105, on 2026-11-03, checked live). In an interactive session under `--allow-all`, the answer `{"permissionDecision":"ask","permissionDecisionReason":…}` holds the tool and shows the "Hook permission request" of fact 4 in the pane: the reason, the tool's name and its arguments as JSON, and "Do you want to allow this tool call?". Yes runs the tool; No is a field for what the agent should do instead, which does nothing while it's empty. After a No, the agent gets `Denied by user via preToolUse hook prompt: ` with the reason, and `The user provided the following feedback: ` with the text; copilot puts a full stop after the reason, so a reason that ends in one shows two. The input's `sessionId` is the name of the session's folder in `$COPILOT_HOME/session-state/`. Without `COPILOT_ALLOW_ALL`, the folder-trust question still shows under `--allow-all` (fact 9), and the footer shows `Allow All`, where fact 27's showed `Manual Approval`. The live check ran the orchestrator's pane with Claude Haiku 5.5, the plugin of `guard-orchestrator` and a throwaway `COPILOT_HOME`, in a throwaway repo without a remote, on a tmux server of its own, for 0.32 AI credits: the guard asked about `git push --dry-run`, which the check declined with feedback, and about `baton feature start`, which it allowed, and whose script ran but failed with `baton: command not found`, since baton wasn't on the live pane's `PATH`; a `create` in the session's `files/` folder ran, and one in the repo was denied. +38. **What makes git's pushes and gh fail** (git 2.56.0, gh 2.103.2 and Copilot CLI 1.0.105, on 2026-11-04, in throwaway folders, against `.invalid` hosts and local paths). git reads the settings that `GIT_CONFIG_COUNT` counts in `GIT_CONFIG_KEY_` and `GIT_CONFIG_VALUE_` after its config files, and those of `git -c` after them; it reads the count as C's `strtoul` does, so it takes ` 1` and `+1`, and fails with 128 on `x`, and on `-1` (`too many entries`). A push to a remote's URL takes the `url..pushInsteadOf` rule whose prefix matches the most of the URL, and of equal ones the rule whose base git read first, or else an `insteadOf` rule; a remote's push URL (`remote..pushurl`) skips the `pushInsteadOf` rules, but not the `insteadOf` ones. Since git 2.46 (its release notes), an empty `pushurl` clears the remote's push URLs that git read before it, so its pushes go to its URL; an older git takes it for one more push URL, and still pushes to the others. git takes a URL for a path on the machine when it has no colon, or a slash before its first colon, or starts with `file://`; `https://…`, `ssh://…`, `git@host:repo`, `host:repo` and `[::1]:repo` leave the machine. A push to `::`, whose remote helper `git-remote-` isn't there, fails with 128 and `git: 'remote-' is not a git command`, before any network. `git send-pack` takes its URL as it is, with no rule: it went on to look the host up. `git config --get-regexp` exits with 1 when no setting matches. Copilot's `bash` tool adds three settings of its own after those that it inherits, `safe.bareRepository=explicit`, `credential.interactive=never` and `core.fsmonitor=false`, and raises `GIT_CONFIG_COUNT` to match; it keeps `GIT_CONFIG_PARAMETERS`, and reads `BASH_ENV` only with its `bashEnv` setting, off by default, which `--bash-env` saves in the user's config (`copilot help config`). With `GH_CONFIG_DIR` under a file, such as `/dev/null/x`, every `gh` command, `--version` and `auth` too, fails before it starts, with 1 and `failed to create root command: failed to read configuration: open /dev/null/x/config.yml: not a directory`, and writes nothing; with an empty folder, gh still takes the user's login from the Keychain (gh's source). Copilot CLI takes its GitHub token from `COPILOT_GITHUB_TOKEN`, then `GH_TOKEN`, then `GITHUB_TOKEN`, then its own login, which `copilot login` keeps in the Keychain, and last from `gh auth token` (`copilot help environment`, and GitHub's docs on its authentication). The belt (decision 55) was checked with `baton feature start` on a tmux server of its own, with a stand-in `copilot`, and once with the real one, offline (`COPILOT_OFFLINE`) with a stand-in model on 127.0.0.1, for 0 AI credits. In the lead's pane, pushes to `origin`, whose push URL the belt had cleared, and to typed `https` and scp-like URLs failed, pushes to a local remote and to a typed path went through, `gh --version` failed, and copilot's `bash` added its three settings after the belt's; the orchestrator's pane kept `GH_TOKEN`. ## 3. Design @@ -163,7 +164,7 @@ A round is one pass of the engineer. A rejection starts the next round, with the **The final handover is the user's.** When a feature is ready, the orchestrator tells the user its branch, the lead's report and the open findings. The user decides: deliver it (`baton deliver` pushes the branch and opens the pull request), send it back with changes (a new brief to the lead, and the round count starts again), or close it. -**Panes** (decisions 9 to 11, 29, 32, 35, 36, 38 to 42 and 49). `baton feature start` makes the worktree `-/` next to the repo's main worktree, on a new branch `` (`feature/` unless the config file gives another prefix) from the main worktree's `HEAD`, and a tmux session `baton--` with four tiled panes titled lead, engineer, qa and reviewer (`pane-border-status top`, and `allow-set-title off` so that the titles stay), set on that session's window only. Each pane runs `baton pane`, from the baton that started the feature, with `BATON_ROLE` and `BATON_FEATURE` in its environment. `baton pane` finds `copilot`, writes the plugin and reads the role's settings from the config file, waits without a model until the role's first work arrives and picks it up, as `baton wait` does, then becomes, in the same process: +**Panes** (decisions 9 to 11, 29, 32, 35, 36, 38 to 42, 49 and 55). `baton feature start` makes the worktree `-/` next to the repo's main worktree, on a new branch `` (`feature/` unless the config file gives another prefix) from the main worktree's `HEAD`, and a tmux session `baton--` with four tiled panes titled lead, engineer, qa and reviewer (`pane-border-status top`, and `allow-set-title off` so that the titles stay), set on that session's window only. Each pane runs `baton pane`, from the baton that started the feature, with `BATON_ROLE` and `BATON_FEATURE` in its environment. `baton pane` finds `copilot`, writes the plugin and reads the role's settings from the config file, waits without a model until the role's first work arrives and picks it up, as `baton wait` does, then becomes, in the same process: ```sh copilot --agent baton: --plugin-dir /plugin- --model \ @@ -173,7 +174,7 @@ copilot --agent baton: --plugin-dir /plugin- --model -i "" ``` -`copilot` keeps the pane's terminal and environment, so the role's `baton` commands read `BATON_ROLE` and `BATON_FEATURE` there, and gets `COPILOT_ALLOW_ALL=true` too, which trusts the worktree for that session (decision 39), and `BATON_BIN`, the path of the pane's baton, which the plugin's `agentStop` hook runs (decision 47). The plugin comes from the binary: `baton pane` writes it into ``, the `baton` folder in the user's cache folder (`~/Library/Caches/baton` on a Mac), unless it's there already, unchanged, in a folder named by a hash of its files (decision 36); a role's pane checks it again after the wait (decision 53). baton keeps each role's session ID, so it can find the role's events and resume the session. +`copilot` keeps the pane's terminal and environment, so the role's `baton` commands read `BATON_ROLE` and `BATON_FEATURE` there, and gets `COPILOT_ALLOW_ALL=true` too, which trusts the worktree for that session (decision 39), and `BATON_BIN`, the path of the pane's baton, which the plugin's `agentStop` hook runs (decision 47). A role's `copilot` gets the belt too: settings in its environment that make git's pushes out of the machine and `gh` fail, without the GitHub tokens of the user's environment, but with `COPILOT_GITHUB_TOKEN` for its own login (decision 55). The plugin comes from the binary: `baton pane` writes it into ``, the `baton` folder in the user's cache folder (`~/Library/Caches/baton` on a Mac), unless it's there already, unchanged, in a folder named by a hash of its files (decision 36); a role's pane checks it again after the wait (decision 53). baton keeps each role's session ID, so it can find the role's events and resume the session. **The orchestrator's session** (decisions 44, 45, 49 and 54). `baton start`, or plain `baton`, in the user's shell, makes the tmux session `baton-` in the repo's main worktree, unless it runs already, with one pane titled orchestrator, which runs `baton pane` with `BATON_ROLE=orchestrator` and no `BATON_FEATURE`. It reads the config file before it makes the session, so a mistake there shows in the user's terminal, not in a pane that takes its session with it when it fails. Then it shows the session: outside tmux, baton becomes `tmux attach-session`, and inside tmux, it switches the user's client to the session. The orchestrator's `baton pane` writes the plugin, reads its settings, and becomes its `copilot` at once, with no first prompt, since the user speaks first, and with the user's environment, plus `BATON_BIN` (decision 47): @@ -190,7 +191,7 @@ It runs with every permission, as the roles do, since baton's guard asks the use **Questions** (decisions 18 and 46). A role never asks the user. The holder starts `baton ask ""` in a background shell and ends its turn, as with `baton wait`: baton stores the question in the feature's file and logs it, then reads the file every second until the answer comes, prints it and exits, and its end wakes the role (fact 5). The orchestrator's `baton wait` picks up the question, as it picks up its work, and prints it with the command that answers it. The orchestrator answers from the plan with `baton answer ""`, or asks the user first, in its pane or through nudge. A role asks one question at a time, and can't hand over while it waits for the answer. Asking the same question again doesn't ask it twice: it waits for the answer, or prints it if it came, so a role whose `baton ask` ended early still gets its answer. The role's handover clears its question, which the journal keeps. -**Safety** (decisions 9, 17, 19, 39 and 54). The roles run with every permission (`--allow-all`), in a worktree that their `copilot` trusts, and no `ask_user`, so nothing waits on a prompt in a pane nobody watches, and with the tool allow-list, so no hidden agent and none of the user's skills or MCP tools. The guard hook (`preToolUse` → `baton hook guard`) denies outward actions in a role's pane: `git push`, pull request and merge commands, `gh api` writes, HTTP writes with `curl` and `wget`, Jira writes and `ssh`; and `tmux`, `osascript`, another agent's CLI, baton's commands but a role's own (`baton wait`, `handover`, `ask` and `status`), a program that only the run decides, and `edit` and `create` in baton's folder and in its cache, which holds the plugin (decisions 51 to 53). It stops a role's mistakes, not a role that sets out to get past it (risks 2, 11 and 12). In the orchestrator's pane, which the user watches, it answers `ask` for outward actions, so the user approves each one, and for `baton feature start` while plan approval is on (decision 8); it denies `tmux`, `osascript`, another agent's CLI and baton's commands that aren't the orchestrator's; and it denies `edit` and `create` outside its session's folder, where it writes its briefs and notes (decision 48), so the orchestrator plans and dispatches but doesn't write the code (decision 54). The guard reads the commands of a bash call with a bash parser (decision 50), not its text, and quickly, because a hook that times out allows (fact 4). The orchestrator, too, runs with `--allow-all`, so the user answers only the guard's questions, but it keeps `ask_user` and copilot's folder-trust question (decision 45). +**Safety** (decisions 9, 17, 19, 39, 54 and 55). The roles run with every permission (`--allow-all`), in a worktree that their `copilot` trusts, and no `ask_user`, so nothing waits on a prompt in a pane nobody watches, and with the tool allow-list, so no hidden agent and none of the user's skills or MCP tools. The guard hook (`preToolUse` → `baton hook guard`) denies outward actions in a role's pane: `git push`, pull request and merge commands, `gh api` writes, HTTP writes with `curl` and `wget`, Jira writes and `ssh`; and `tmux`, `osascript`, another agent's CLI, baton's commands but a role's own (`baton wait`, `handover`, `ask` and `status`), a program that only the run decides, and `edit` and `create` in baton's folder and in its cache, which holds the plugin (decisions 51 to 53). Behind the guard, the belt makes git's pushes out of the machine and every `gh` command fail in a role's pane, however they run, with settings in the environment of the role's `copilot` (decision 55). Both stop a role's mistakes, not a role that sets out to get past them (risks 2, 11 and 12). In the orchestrator's pane, which the user watches, it answers `ask` for outward actions, so the user approves each one, and for `baton feature start` while plan approval is on (decision 8); it denies `tmux`, `osascript`, another agent's CLI and baton's commands that aren't the orchestrator's; and it denies `edit` and `create` outside its session's folder, where it writes its briefs and notes (decision 48), so the orchestrator plans and dispatches but doesn't write the code (decision 54). The guard reads the commands of a bash call with a bash parser (decision 50), not its text, and quickly, because a hook that times out allows (fact 4). The orchestrator, too, runs with `--allow-all`, so the user answers only the guard's questions, but it keeps `ask_user` and copilot's folder-trust question (decision 45). **State** (decision 15). Everything lives in `$(git rev-parse --git-common-dir)/baton/`, which every worktree of the repo shares and git never commits: @@ -229,7 +230,7 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 6. **Jira's REST API with a scoped, read-only token** (lead's call): nothing else to install, and no Rovo credits. The token (`read:jira-work`) lives in the login Keychain, which the user fills. 7. **Claude Opus 5.5 for every role** (lead's call), configurable per role. A weaker engineer ignored rejections in zeroshot's spike (fact 16). 8. **Plan approval on** (lead's call), configurable. The guard makes the user approve each `baton feature start` in the orchestrator's pane, so an orchestrator can't skip it (decision 54); `"planApproval": false` in the config file turns that off (decision 49). -9. **The roles run with `--allow-all` and `--no-ask-user`, guarded by a hook** (lead's call, flagged): the CLI's own prompts would stop a role in a pane nobody watches, and a role can't approve its own tools. The guard keeps outward actions out of the role panes (fact 4), and the orchestrator, whom the user watches, keeps `ask_user`. +9. **The roles run with `--allow-all` and `--no-ask-user`, guarded by a hook** (lead's call, flagged): the CLI's own prompts would stop a role in a pane nobody watches, and a role can't approve its own tools. The guard keeps outward actions out of the role panes (fact 4), and the belt makes git's pushes and `gh` fail there (decision 55); the orchestrator, whom the user watches, keeps `ask_user`. 10. **A tool allow-list per role (`--available-tools`)** (lead's call): it removes `task` and every other tool that runs hidden agents, the user's skills and MCP tools, and it fails closed when a new CLI version adds such a tool (fact 7). An exclude list would fail open. 11. **The agents and hooks ship as a plugin** (lead's call): the binary embeds the plugin, writes it to its cache and passes it with `--plugin-dir`, so `~/.copilot/` stays untouched and the agents are `baton:` (fact 3), clear of the user's own agents. 12. **Wake-ups through `baton wait`** (lead's call): a background shell's end wakes the role (fact 5), and nobody types into a pane. The first work starts the pane's `copilot` (`baton pane`), so a role spends nothing before it has work. The stop hook catches a role that forgets to wait; typing into the pane is only the fallback. @@ -270,16 +271,17 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The 47. **A stop hook sends back a `copilot` that stops while nothing of its role waits** (lead's call, flagged; facts 4 and 5). The plugin's `agentStop` hook runs `"$BATON_BIN" hook stop`, and `baton pane` gives each pane's `copilot` `BATON_BIN`, the path of its own baton, in place of any value of the user's, so the hook runs the baton that started the pane, not one from the `PATH` (decision 42). `baton wait` and `baton ask`, and `baton pane` while it waits, mark their role as waiting: a shared `flock` on `.wait` in the feature's folder, or on `orchestrator.wait` in baton's folder, files that baton never removes, as it never removes a lock (decision 40). The kernel releases the mark when its process ends, so a wait that crashed leaves none, and two waits of one role share it. A wait takes the mark only when its first look finds nothing, so a wait that refuses, or picks its work up at once, writes nothing; that look takes milliseconds, and a model's turn far longer, so the hook sees the mark of a wait that the turn started. `baton hook stop` lets the `copilot` stop while its role's mark is held, since the wait's end wakes it (fact 5). Otherwise it sends it back, with a reason that becomes its next prompt: to start `baton wait` when work can come to it, because another role holds the feature or its work waits there; to ask its question again when that waits for its answer, since baton doesn't ask it twice (decision 46); and otherwise to finish its part and hand over, or just to end its turn when a command of its own runs in the background. The orchestrator gets the first reason while a role holds a feature or its work waits in one, and may stop once it holds every feature with its work picked up, since those wait on the user, or when there is none. The hook changes nothing in baton's folder, and lets the `copilot` stop when its block has sent this turn back already (`stop_hook_active`), so a `copilot` that can't do what the reason says stops one turn later; that turn is also the cost for a role that stops to wait for a command of its own. It finds the repo from the `copilot`'s folder in its input, or from its own folder without one. A hook that fails lets the `copilot` stop (fact 4), so baton reports why on stderr and exits with 1, and copilot logs it. When two hooks look at one mark at the same moment, one can take the other's look for a wait and let its `copilot` stop, as a hook that fails does; only two `copilot`s of one role could do that. Rejected: looking for a `baton wait` in the process table, which needs `ps` and can't tell one pane's wait from another's; a PID file, which a crash leaves behind; and a mark that every wait takes at its start, which would write files for waits that refuse. 48. **Only baton writes in its folder** (lead's call, flagged; fact 29, risk 11). `baton handover` refuses a note, and `baton feature start` a brief, that lies in baton's folder, once a relative path is made absolute from the working folder, so that the check and the read look at the same file, and the symlinks of both paths are resolved; it says to move the file to the session's `files/` folder, and the refusal changes nothing. In the live run the reviewer wrote its notes there, named as baton names the next one: a stray file there misleads whoever reads the notes, and baton numbers a new note after the folder's highest one (decision 26), so a stray shifts the numbers. The prompts send each role's notes, and the orchestrator's briefs and notes, to its session's `files/` folder (`$COPILOT_HOME/session-state//files/`), which Copilot CLI names in each session's context, never to the repo's worktrees, where they could get committed, or to baton's folder. They also tell the roles that the folder of the notes that baton hands them is baton's, and to run git and their other commands in the feature's worktree, where their pane starts, since the notes' paths run through the main worktree. So decision 19's guard lets the orchestrator `edit` and `create` only in its session's folder, no longer in baton's. On a disk that ignores case, as a Mac's does by default, a path that spells baton's folder in other case gets past this refusal, which stops only the note that a handover names; the guard's `preToolUse` hook keeps the file tools out of baton's folder, in any case (decision 52), but not `bash` (risk 11). baton doesn't refuse a note in a worktree, which only risks a commit, and doesn't remove a stray that is there already. 49. **One config file, in layers** (lead's call, flagged; decision 16, fact 31). `config.json` in baton's folder under the user's config folder holds the settings for every repo at its top, and each repo's section in `repos`, keyed by the remote's URL as its host and path: `git@github.com:codedogapp/baton.git`, `https://github.com/codedogapp/baton` and `ssh://git@github.com:22/codedogapp/baton.git` all give `github.com/codedogapp/baton`, so a repo's HTTPS and SSH clones share a section; a remote that is a local path keeps its path. baton refuses a key in another form, and says the form; a key keeps the URL's case. The repo's section lies over the top, and the top over baton's own settings, one setting at a time: a setting that a layer leaves out, or gives its zero value, keeps the one below, so a `roundCap` or a `maxAiCredits` of 0 keeps the layer's below, and the branch prefix can't be empty; `planApproval`, `true` or `false`, keeps the one below only when the layer leaves it out or gives `null`, so `false` turns plan approval off; the orchestrator's `extraTools`, a list, replaces the list below whole, and `[]` gives none. baton's own: Claude Opus 5.5 for every role (decision 7), copilot's own reasoning effort, no cap, nudge's two tools for the orchestrator (decision 45), three rounds (decision 13), `feature/` (decision 41) and plan approval on (decision 8). baton reads and checks the whole file, the other repos' sections too, so that a mistake shows at once, with the file's path, and not in a `copilot` that it stops: it refuses a member that it doesn't know (names match case: `roundCap`, not `RoundCap`), a member given twice, a role that it doesn't know, a model or a tool that starts with a dash, which would read as one of copilot's flags, a reasoning effort that copilot doesn't take (CLI 1.0.105's, so a new level needs a new baton), a cap below copilot's least, 30 (fact 10), a negative round cap, and extra tools for any role but the orchestrator (decision 10). A missing file gives baton's own settings. The step came in two: `config` reads the file, and `config-cli` gives its settings to `baton pane` and `baton feature start`. They find the repo's section by its `origin` remote's URL, as `git remote get-url origin` gives it, with the user's `insteadOf` rewrites (fact 32): a repo can have many remotes, and `origin` is the one that `git clone` makes. A repo without an `origin` gets the top's settings, even with other remotes, since baton can't tell which of them is the repo's own. `baton pane` reads the file after it writes the plugin and before it waits (decision 38), the orchestrator's pane too, so a mistake in the file stops a pane before its role picks up work; `baton feature start` reads it before it makes the feature; and `baton start` reads it before it makes the orchestrator's session, since a pane that fails takes its session, and its reason, with it, but not to show a session that runs, which has its settings already; `baton hook guard`, in the orchestrator's pane, reads it for a script that starts a feature (decision 54). A role's settings come after its plugin on copilot's command line: `--model`, then `--reasoning-effort` and `--max-ai-credits` only when the file sets them, the cap in plain digits, as `1000000.5`, never as `1e+06`. The other settings come with their steps. -50. **The guard reads bash with mvdan.cc/sh** (the user's call, 2026-11-03, for the module; lead's call, flagged, for the rest). The guard needs the commands that a bash call runs: a match on the text would miss `git -C x push`, `nohup git push` and `$(git push)`, and catch `echo 'git push'`. The standard library has no bash parser, so `internal/bash`, and only it, imports `mvdan.cc/sh/v3`, the parser of `shfmt`, pinned at exactly v3.14.1 in `go.mod`, which Renovate updates as it does the rest; its license is BSD-3, whose notice the release carries (`homebrew`). `bash.Calls` gives each command that a script runs as its words, as bash passes them: quotes and backslashes taken off, and each part that only the run decides, such as `$HOME` or `$(…)`, as a marker, `bash.Dynamic`. It finds the commands in substitutions, here-documents, functions and conditions too; for a wrapper (`builtin`, `caffeinate`, `command`, `env`, `exec`, `nice`, `nohup`, `stdbuf`, `sudo`, `time`, `timeout`, `xargs` and Homebrew's `g` names, such as `gtimeout`), the command that it runs, past its options, `NAME=value` words and `timeout`'s duration; and for `eval`, and `bash`, `dash`, `ksh`, `sh` and `zsh` with `-c`, the commands of their script. A program's name ignores case, as a Mac's file system does. A script that doesn't parse is an error, which the guard denies, and that is rare (fact 33). It can't see what only the run decides: an alias, a script file or `source`, a variable as the program (`"$X" push`), a script on standard input (`sh -s`, `| sh`, `bash < .git/baton/…` (risk 11), and to the binary that `BATON_BIN` names, which the hooks run. It misses a hard link, outside baton's folder, to a file in it, which the walk up the link's folders can't see; data that `web_fetch` sends in a URL, as it only reads; other repos' baton folders; and what decisions 50 and 51 list. It denies the file tools in the plugin's cache too, and `baton pane` checks the plugin's files there before each `copilot` starts (decision 53), but `bash` can still change them while a `copilot` starts or runs (risk 12). Risk 2's second belt (`belt`) is for the rest. +50. **The guard reads bash with mvdan.cc/sh** (the user's call, 2026-11-03, for the module; lead's call, flagged, for the rest). The guard needs the commands that a bash call runs: a match on the text would miss `git -C x push`, `nohup git push` and `$(git push)`, and catch `echo 'git push'`. The standard library has no bash parser, so `internal/bash`, and only it, imports `mvdan.cc/sh/v3`, the parser of `shfmt`, pinned at exactly v3.14.1 in `go.mod`, which Renovate updates as it does the rest; its license is BSD-3, whose notice the release carries (`homebrew`). `bash.Calls` gives each command that a script runs as its words, as bash passes them: quotes and backslashes taken off, and each part that only the run decides, such as `$HOME` or `$(…)`, as a marker, `bash.Dynamic`. It finds the commands in substitutions, here-documents, functions and conditions too; for a wrapper (`builtin`, `caffeinate`, `command`, `env`, `exec`, `nice`, `nohup`, `stdbuf`, `sudo`, `time`, `timeout`, `xargs` and Homebrew's `g` names, such as `gtimeout`), the command that it runs, past its options, `NAME=value` words and `timeout`'s duration; and for `eval`, and `bash`, `dash`, `ksh`, `sh` and `zsh` with `-c`, the commands of their script. A program's name ignores case, as a Mac's file system does. A script that doesn't parse is an error, which the guard denies, and that is rare (fact 33). It can't see what only the run decides: an alias, a script file or `source`, a variable as the program (`"$X" push`), a script on standard input (`sh -s`, `| sh`, `bash < .git/baton/…` (risk 11), and to the binary that `BATON_BIN` names, which the hooks run. It misses a hard link, outside baton's folder, to a file in it, which the walk up the link's folders can't see; data that `web_fetch` sends in a URL, as it only reads; other repos' baton folders; and what decisions 50 and 51 list. It denies the file tools in the plugin's cache too, and `baton pane` checks the plugin's files there before each `copilot` starts (decision 53), but `bash` can still change them while a `copilot` starts or runs (risk 12). Of all these, the belt (decision 55) stops git's pushes and `gh`; the rest stays with risks 2, 11 and 12. 53. **baton checks the plugin's folder before each `copilot` starts** (lead's call, flagged; decisions 36, 38 and 52, risk 12). `plugin.Write` hashes the files of a folder that has the plugin's name, by their paths in the folder and their contents, as it hashes the binary's, and uses the folder only if the hashes match and it is a folder, not a symlink to one. A folder with other files, more, fewer or changed ones, or with anything but folders and regular files, such as a symlink, and anything else under the name, it leaves as it is, and writes the plugin under the next name, `plugin--1`, then `-2`, up to `-7`; with all eight names taken, the pane fails and says why. baton never replaces a folder (Go's `os.Rename` refuses to), so a starting `copilot` never misses the plugin's folder, as it could while a swap ran, and goes on without the hooks and with the default agent (risk 10). Panes that start at once take the same new name, since a folder appears whole (decision 36). A role's pane checks again after its wait, which can last hours, just before it becomes `copilot`; a failure there leaves the work picked up, as a `copilot` that fails to start does (decision 38). The guard denies a role's `create` and `edit` in baton's cache folder, as in baton's folder, and fails without the user's cache folder (decision 52). `bash` can still change the plugin's files after a pane's check, while its `copilot` starts or runs, or through a hard link (risk 2), and a Finder `.DS_Store` in a folder costs one more folder. Rejected: rewriting a changed folder, which a starting `copilot` could see half written; removing it, which a running pane may still use; and making a checked folder read-only, which a role that runs as the user can undo. 54. **`baton hook guard` asks the user in the orchestrator's pane** (lead's call, flagged; decisions 8, 17, 19, 45, 48, 49 and 52, facts 4, 28 and 37, risks 2 and 3). The orchestrator's `copilot` runs with `--allow-all`, so its tools run unasked, and the guard answers `ask` where the user should decide, which copilot shows in the pane with the reason and the call, for the user to allow, or to decline with a word for the orchestrator (fact 37); the orchestrator's prompt tells it to ask the user what they want instead. For `bash`, the guard reads the script's commands (decision 51). Baton's commands for the orchestrator run unasked: `baton wait`, `baton answer`, `baton handover`, `baton status` and baton's help. `baton feature start` asks while plan approval is on (decision 8), so the user checks the plan first, and runs unasked once `planApproval` is `false` (decision 49); the guard reads the config file only for a script that starts a feature, for the repo of the hook's folder, and a mistake in the file, or a folder outside a repo, fails the hook, so `copilot` denies the script (fact 4). An outward command asks, so the user approves each push and each pull request, until `baton deliver` comes (decision 17). The guard denies, with a reason for the orchestrator, `tmux` and `osascript`, another agent's CLI, baton's other commands, such as `baton ask` or `baton pane`, and, as in a role's pane (decision 52), a program that only the run decides and a script that it can't read. A script runs whole or not at all: a command that the guard denies denies the script, and otherwise the first command that asks names the question, so the user's yes runs the whole script. The guard names baton's `feature` and `hook` commands by two words (decision 52), so that `baton feature start` asks and a later `baton feature close` can differ. For `create` and `edit`, it denies a path outside the session's folder, `session-state/` in copilot's home folder, which `COPILOT_HOME` names in the pane, or else `~/.copilot`, with the walk of decision 52; the input's `sessionId` names the session (fact 37). A session ID that isn't one folder's name fails the hook, and a session's folder that isn't there holds nothing. The orchestrator keeps `ask_user` and copilot's folder-trust question (decision 45). The guard misses what it misses in a role's pane (decision 52): the orchestrator can write any file with `bash`, and run local commands, such as `rm`, unasked (risk 3), and `web_fetch` can send data in a URL. Its extra tools, such as nudge's, run unasked, and send what they send. The guard reads the plan approval of the hook's folder's repo, not of a repo that the script `cd`s into, and `baton feature start -h` asks too. Rejected: denying outward commands in this pane until `baton deliver` comes, which would leave the user no way to push from it; asking about every command, as copilot did before (decision 45), which buries the questions that matter among the rest; and copilot's `--deny-tool` rules, which only deny (fact 28). +55. **A role's pane runs with a second belt** (the user's call, 2026-11-04, for the git and `gh` parts; lead's call, flagged, for the rest; decisions 9, 17, 52 and 54, facts 28 and 38, risk 2). The guard sees only what a command shows (risk 2), so `baton pane` gives a role's `copilot` settings in its environment, which each command and script that the role runs inherits, whatever runs it, and which make git's pushes out of the machine and `gh` fail. For git, after the settings that `GIT_CONFIG_COUNT` counts in baton's own environment, which it keeps, it adds a `pushInsteadOf` rule for each character that starts a URL that leaves the machine, a letter, a digit or `[`, which sends the push to `baton-blocks-push::`, a remote helper that doesn't exist, so that git fails before any network and names baton's belt; a rule that keeps `file://` URLs as they are; and, for the remotes in git's config for the repo, the user's own config included, as the pane finds them when it starts, a rule for each remote's URL that leaves, which beats the user's own rules for a shorter prefix, and an empty `pushurl` for each remote with a push URL that leaves, which clears its push URLs, so that its pushes go to its URL. A git before 2.46, which can't clear them, gets an `insteadOf` rule for each such push URL instead, which also blocks fetches from the URLs that start with it (fact 38). Pushes to a path on the machine, `file://` URLs among them, still work, and fetches, pulls and clones don't change. For `gh`, `GH_CONFIG_DIR` names a folder under `/dev/null`, so that every `gh` command fails before it starts and never reaches the user's login in the Keychain, as it would with an empty folder (fact 38). The pane goes without `GH_TOKEN`, `GITHUB_TOKEN`, `GH_ENTERPRISE_TOKEN` and `GITHUB_ENTERPRISE_TOKEN`, which other tools would use; but `copilot` takes its own login from `GH_TOKEN` or `GITHUB_TOKEN`, or, after its own login, from `gh` (fact 38), so it gets the first of the two that is set as `COPILOT_GITHUB_TOKEN`, unless that is set already, and a user whose `copilot` logs in only through `gh` runs `copilot login` once (README). `baton pane` makes the belt before it waits, and fails when it can't: when git can't read the repo's config or its own version, or when `GIT_CONFIG_COUNT` is a count that git reads but Go doesn't, such as ` 1`, since baton can't tell then where to put its settings. The orchestrator's pane goes without the belt, since the user approves its outward actions there (decision 54). The costs: a push to a relative path that starts with a letter or a digit, such as `git push backup.git`, fails too, as does one to a git server on the machine by its URL, such as `http://localhost/…`; and the roles lose `gh`'s reads, which their prompts don't ask for. Like the guard, the belt stops a role's mistakes, and what the guard can't see, not a role that sets out to get past it (risk 2). Rejected: an empty `GH_CONFIG_DIR` folder, which leaves `gh` the user's login in the Keychain; `BASH_ENV`, which `copilot` reads only with a setting of the user's (fact 38); `copilot`'s deny rules, which match the command that the bash tool runs, as the guard does, not what it runs in turn (fact 28); and `copilot`'s command sandbox, whose policy, such as whether commands reach the network, lives in the user's settings, which each of their `copilot` sessions shares (risk 2). ## 5. Risks 1. **Wake-ups aren't a documented contract** (fact 5). A CLI update could break them. The stop hook and typing into the pane are the fallbacks; `live-run` saw the whole loop work with CLI 1.0.105 (fact 26). -2. **The guard sees only what a command shows** (decisions 9, 50 and 51): a role can reach the network in ways that it can't see (a script file, another language, a tool that it doesn't know, git's aliases). It is a seatbelt, not a sandbox. Taking the role panes' credentials away (an empty `GH_CONFIG_DIR`, a git `pushInsteadOf` that fails) would add a second belt; `belt` checks whether that works. +2. **The guard and the belt see only part of what a role can do** (decisions 9, 50, 51 and 55). A role can reach the network in ways that the guard can't see (a script file, another language, a tool that it doesn't know, git's aliases), and the belt makes only git's pushes out of the machine and `gh` fail. The belt misses `git send-pack`, which takes its URL as it is (fact 38), and likely `git http-push`; `git lfs push`, which sends to a server of its own, unchecked; the settings of `git -c`, which git reads after the belt's; a user's own `pushInsteadOf` or `insteadOf` rule that matches as much of a URL as the belt's, or more, such as one for a host, which a URL typed in a command meets (fact 38); the push URLs of a remote that the pane didn't see when it started, in another repo (`git -C`) or added later; a typed scp-like URL whose host starts with another character, such as `_host:repo`, which ssh's config can name; tools that reach the network themselves, such as `ssh`, `rsync` to a host, `curl`, `npm publish` or `docker push`, with credentials of their own, the user's git credentials among them, which `git credential fill` prints; `COPILOT_GITHUB_TOKEN`, which stays in the pane's environment; and a role that sets out to get past it, with `env -i`, say, or a `git-remote-baton-blocks-push` of its own on its `PATH`. Both are seatbelts, not a sandbox. Copilot CLI's command sandbox (`copilot help sandbox`; Seatbelt on a Mac) can fence a pane's commands in, the network among them, but by a policy in the user's settings, which each of their `copilot` sessions shares (`--sandbox` turns the sandbox on for one session, not a policy of its own), so it stays the user's call. 3. **`--allow-all` lets a role, and the orchestrator, read and write any path** on the Mac, and run local commands, such as `rm`, unasked. Roles work in their worktree by their instructions, and the orchestrator in its session's folder; the guard keeps the file tools out of baton's folders in a role's pane, and in the session's folder in the orchestrator's (decisions 52 and 54), and watches outward actions, but doesn't follow `bash`'s writes. 4. **A hook that times out allows** (fact 4): the guard must answer within its timeout and never wait on anything. 5. **AI credits**: four Opus sessions per feature, three of them in every round, plus the orchestrator. The per-role cap (`maxAiCredits` in the config, for `--max-ai-credits`, decision 49) and `baton status` keep it visible; the round cap bounds it. @@ -298,7 +300,8 @@ Decisions marked **lead's call** are the lead's, for the user's end review. The ## 7. Sources -- Copilot CLI: `copilot --help` and `copilot help config|environment|permissions` (1.0.105); the hooks reference, the plugins overview, the CLI plugin reference and the custom agents reference on docs.github.com; the Agent Plugins 1.0.0 schema at agent-plugins.org; the `agent-wake` spike (facts 3 to 10); the `live-run` step (facts 26 to 30). +- Copilot CLI: `copilot --help` and `copilot help config|environment|permissions|sandbox` (1.0.105); the hooks reference, the plugins overview, the CLI plugin reference, the custom agents reference and the CLI's authentication page on docs.github.com; the Agent Plugins 1.0.0 schema at agent-plugins.org; the `agent-wake` spike (facts 3 to 10); the `live-run` step (facts 26 to 30). +- git's manual and its 2.46 release notes, and gh's source, v2.103.2 (fact 38). - tmux 3.8 manual and its `spawn.c`; [tmux/tmux-builds](https://github.com/tmux/tmux-builds); GitHub's [runner images](https://github.com/actions/runner-images). - Jira Cloud REST API v3 and Bitbucket Cloud REST API 2.0 reference. - Bakhyard's AGENTS.md (the pipeline baton runs), nudge (this repo's rules, CI and gates), zeroshot (ideas only, fact 16). diff --git a/internal/cli/belt.go b/internal/cli/belt.go new file mode 100644 index 0000000..ab1d78a --- /dev/null +++ b/internal/cli/belt.go @@ -0,0 +1,156 @@ +package cli + +import ( + "cmp" + "context" + "fmt" + "os" + "slices" + "strconv" + "strings" + + "github.com/codedogapp/baton/internal/repo" +) + +// The belt is a role's second belt, after baton's guard: settings in the environment of the role's +// copilot, which every command and script there inherits, so that git's pushes out of the machine +// and gh fail, even where the guard can't see them (decision 55). + +// blocked is the remote helper that git's push rules send a push to: one that doesn't exist, so +// that git refuses the push, with no network, and names baton's belt (fact 38). +const blocked = "baton-blocks-push::" + +// outward are the characters that start a URL that leaves the machine, such as https://host/repo, +// host:repo or [::1]:repo, but no absolute path, and no ./ or ../ path (fact 38). +const outward = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789[" + +// ghBlocked is gh's config folder in a role's pane: one under a file, which can't be there, so that +// gh fails before it starts, its auth commands too, and never reaches the user's login (fact 38). +const ghBlocked = "/dev/null/baton-blocks-gh" + +// tokens are the variables that give gh and other tools a GitHub token, which a role's pane goes +// without (decision 55). +var tokens = []string{"GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"} + +// setting is a setting of git's, a name and its value. +type setting struct{ key, value string } + +// belted returns a role's environment for copilot, from the folder: baton's, with the variables, +// and with the belt (decision 55). +func belted(ctx context.Context, dir string, vars ...string) ([]string, error) { + count, err := gitCount() + if err != nil { + return nil, err + } + + rules, err := pushRules(ctx, dir) + if err != nil { + return nil, err + } + + vars = slices.Concat(vars, gitVars(count, rules), ghVars()) + + return slices.DeleteFunc(environment(vars...), func(variable string) bool { + name, _, _ := strings.Cut(variable, "=") + + return slices.Contains(tokens, name) + }), nil +} + +// gitCount returns the count of git's settings in baton's environment, which the belt's follow. It +// refuses one that git reads but Go doesn't, such as " 1"; with a negative one, git fails itself. +func gitCount() (int, error) { + text := os.Getenv("GIT_CONFIG_COUNT") + if text == "" { + return 0, nil + } + + count, err := strconv.Atoi(text) + if err != nil { + return 0, fmt.Errorf("GIT_CONFIG_COUNT=%q isn't a count of git's settings", text) + } + + return count, nil +} + +// pushRules returns git's settings that block a push out of the machine, from the folder: a +// pushInsteadOf rule for each character that starts such a URL, and one that keeps file:// URLs +// local; one for each remote's url that leaves, which beats the user's own shorter rules; and for +// each remote's pushurl that leaves, which those rules skip, an empty pushurl, which clears the +// remote's pushurls, so that the push goes to its url, or, in a git that can't clear them, an +// insteadOf rule, which blocks fetches from URLs that start with the pushurl too (fact 38). +func pushRules(ctx context.Context, dir string) ([]setting, error) { + urls, err := repo.RemoteURLs(ctx, dir) + if err != nil { + return nil, err + } + + clears, err := repo.ClearsPushURLs(ctx) + if err != nil { + return nil, err + } + + rules := make([]setting, 0, len(outward)+1+len(urls)) + for _, char := range outward { + rules = append(rules, blockPush(string(char))) + } + + rules = append(rules, setting{"url.file://.pushInsteadOf", "file://"}) + + for _, url := range urls { + switch { + case !leaves(url.URL): + case !url.Push: + rules = append(rules, blockPush(url.URL)) + case !clears: + rules = append(rules, setting{"url." + blocked + url.URL + ".insteadOf", url.URL}) + case !slices.Contains(rules, setting{"remote." + url.Remote + ".pushurl", ""}): + rules = append(rules, setting{"remote." + url.Remote + ".pushurl", ""}) + } + } + + return rules, nil +} + +// blockPush returns git's rule that sends a push to a URL that starts with the prefix to the +// blocked remote helper. +func blockPush(prefix string) setting { + return setting{"url." + blocked + prefix + ".pushInsteadOf", prefix} +} + +// leaves reports whether git takes the URL for one that leaves the machine: one with a colon before +// any slash, as git reads it, but no file:// URL (fact 38). +func leaves(url string) bool { + before, _, found := strings.Cut(url, ":") + + return found && !strings.Contains(before, "/") && !strings.HasPrefix(url, "file://") +} + +// gitVars returns the variables that give git the settings, after the count of those in baton's +// environment. +func gitVars(count int, settings []setting) []string { + vars := make([]string, 0, 2*len(settings)+1) + for i, each := range settings { + vars = append(vars, fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", count+i, each.key), + fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", count+i, each.value)) + } + + return append(vars, "GIT_CONFIG_COUNT="+strconv.Itoa(count+len(settings))) +} + +// ghVars returns the variables that block gh, and that give copilot the token that it would have +// taken from GH_TOKEN or GITHUB_TOKEN, which the role's pane goes without, unless it has its own +// (fact 38). +func ghVars() []string { + vars := []string{"GH_CONFIG_DIR=" + ghBlocked} + + if os.Getenv("COPILOT_GITHUB_TOKEN") != "" { + return vars + } + + if token := cmp.Or(os.Getenv("GH_TOKEN"), os.Getenv("GITHUB_TOKEN")); token != "" { + vars = append(vars, "COPILOT_GITHUB_TOKEN="+token) + } + + return vars +} diff --git a/internal/cli/belt_env_test.go b/internal/cli/belt_env_test.go new file mode 100644 index 0000000..88a6ba8 --- /dev/null +++ b/internal/cli/belt_env_test.go @@ -0,0 +1,176 @@ +package cli_test + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + "testing" + "testing/synctest" + + "github.com/codedogapp/baton/internal/flow" + "github.com/codedogapp/baton/internal/state" +) + +// outward are the characters that start a URL that leaves the machine, in baton's push rules +// (fact 38). +const outward = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789[" + +// belt returns the variables that block a role's pushes and gh, after the count of git's settings +// in baton's environment, with git's settings for the repo's remotes: a push rule for each +// character that starts a URL that leaves the machine, one that keeps file:// URLs local, then +// the remotes'; and gh's config folder, which can't be there (decision 55). +func belt(count int, remotes ...[2]string) []string { + rules := make([][2]string, 0, len(outward)+1+len(remotes)) + for _, char := range outward { + rules = append(rules, pushRule(string(char))) + } + + rules = append(rules, [2]string{"url.file://.pushInsteadOf", "file://"}) + rules = append(rules, remotes...) + + vars := make([]string, 0, 2*len(rules)+2) + for i, rule := range rules { + vars = append(vars, fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", count+i, rule[0]), + fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", count+i, rule[1])) + } + + return append(vars, fmt.Sprintf("GIT_CONFIG_COUNT=%d", count+len(rules)), + "GH_CONFIG_DIR=/dev/null/baton-blocks-gh") +} + +// pushRule returns git's setting, and its value, that sends a push to a URL that starts with the +// prefix to baton's missing remote helper. +func pushRule(prefix string) [2]string { + return [2]string{"url.baton-blocks-push::" + prefix + ".pushInsteadOf", prefix} +} + +// TestRolePaneAddsToGitsSettings has the lead's copilot get the belt's settings after git's own in +// baton's environment, in place of those past their count: a push rule for each remote's url that +// leaves the machine, and for the pushurls that leave, one empty pushurl for their remote, in a +// git that clears pushurls, as 2.46 does, or else a rule for each; none for local URLs (decision +// 55). +func TestRolePaneAddsToGitsSettings(t *testing.T) { + cases := []struct { + version string + pushURL [][2]string // The belt's settings for origin's pushurls. + }{ + {version: "git version 2.46.0", pushURL: [][2]string{{"remote.origin.pushurl", ""}}}, + {version: "git version 2.45.2", pushURL: [][2]string{ + { + "url.baton-blocks-push::git@example.invalid:one.git.insteadOf", + "git@example.invalid:one.git", + }, + { + "url.baton-blocks-push::git@example.invalid:two.git.insteadOf", + "git@example.invalid:two.git", + }, + }}, + } + + for _, test := range cases { + t.Run(test.version, func(t *testing.T) { + copilot, cache := standIn(t) + gitBut(t, "version) echo '"+test.version+"'; exit ;;") + setenv(t, "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=user.name", "GIT_CONFIG_VALUE_0=user", + "GIT_CONFIG_KEY_1=core.editor", "GIT_CONFIG_VALUE_1=vi") + + mainDir, linked := repos(t) + write(t, filepath.Join(mainDir, ".git"), "remotes", `[remote "origin"] + url = https://example.invalid/demo.git + pushurl = git@example.invalid:one.git + pushurl = git@example.invalid:two.git +[remote "local"] + url = ../local.git + pushurl = /srv/local.git +[remote "sibling"] + url = sibling.git +`) + gitIn(t, mainDir, exec.CommandContext(t.Context(), "git", "config", "include.path", + "remotes")) + + store := started(t, mainDir, fresh()) + + remotes := append([][2]string{pushRule("https://example.invalid/demo.git")}, + test.pushURL...) + + synctest.Test(t, func(t *testing.T) { + env, _ := inPane(t, linked, paneOf(flow.RoleLead)) + + wantCopilot(t, paneFor(t, env), flow.RoleLead, execed{ + path: copilot, + args: copilotLine(flow.RoleLead, pluginIn(t, cache), leadsBrief(store)), + env: roleEnv(belt(1, remotes...)...), + }) + }) + }) + } +} + +// TestRolePaneMovesTheGitHubToken has the lead's copilot go without the GitHub tokens that gh and +// other tools read, and get the one that copilot would have read from them, GH_TOKEN's, or else +// GITHUB_TOKEN's, as its own, unless it has one (fact 38). +func TestRolePaneMovesTheGitHubToken(t *testing.T) { + cases := []struct { + name string + tokens []string // The user's variables. + copilot []string // Copilot's own token, if baton gives it one. + }{ + { + name: "every token", + tokens: []string{ + "GH_TOKEN=gh", "GITHUB_TOKEN=github", "GH_ENTERPRISE_TOKEN=ghe", + "GITHUB_ENTERPRISE_TOKEN=github-enterprise", + }, + copilot: []string{"COPILOT_GITHUB_TOKEN=gh"}, + }, + { + name: "GITHUB_TOKEN's", + tokens: []string{"GH_TOKEN=", "GITHUB_TOKEN=github"}, + copilot: []string{"COPILOT_GITHUB_TOKEN=github"}, + }, + {name: "copilot's own", tokens: []string{"COPILOT_GITHUB_TOKEN=copilot", "GH_TOKEN=gh"}}, + { + name: "copilot's own, empty", + tokens: []string{"COPILOT_GITHUB_TOKEN=", "GH_TOKEN=gh"}, + copilot: []string{"COPILOT_GITHUB_TOKEN=gh"}, + }, + {name: "an enterprise's", tokens: []string{"GH_ENTERPRISE_TOKEN=ghe"}}, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + copilot, cache := standIn(t) + setenv(t, test.tokens...) + + mainDir, linked := repos(t) + store := started(t, mainDir, fresh()) + + synctest.Test(t, func(t *testing.T) { + env, _ := inPane(t, linked, paneOf(flow.RoleLead)) + + wantCopilot(t, paneFor(t, env), flow.RoleLead, execed{ + path: copilot, + args: copilotLine(flow.RoleLead, pluginIn(t, cache), leadsBrief(store)), + env: roleEnv(append(belt(0), test.copilot...)...), + }) + }) + }) + } +} + +// setenv sets the variables, each one name=value, for the test. +func setenv(t *testing.T, vars ...string) { + t.Helper() + + for _, variable := range vars { + name, value, _ := strings.Cut(variable, "=") + t.Setenv(name, value) + } +} + +// leadsBrief returns the lead's first prompt in a new feature, with the orchestrator's brief. +func leadsBrief(store state.Store) string { + return "Picked up demo-1 as lead: round 0 of 3, from orchestrator.\nNote: " + + notePath(store, "01-orchestrator-to-lead.md") + "\n" +} diff --git a/internal/cli/belt_test.go b/internal/cli/belt_test.go new file mode 100644 index 0000000..e2dba92 --- /dev/null +++ b/internal/cli/belt_test.go @@ -0,0 +1,202 @@ +package cli_test + +import ( + "cmp" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "testing/synctest" + + "github.com/codedogapp/baton/internal/flow" + "github.com/codedogapp/baton/internal/state" +) + +// The belt tests run baton pane in the lead's pane, then git with the environment that baton gave +// the lead's copilot, as a command or a script there would (decision 55). Each push that would +// leave the machine goes to a host under .invalid, which no resolver knows, and git runs ssh as +// false, so a push that the belt misses fails too, without reaching a host, but for another reason. + +// TestRolePaneBlocksPushesThatLeave has the lead's copilot's git, in a repo with remotes, refuse +// each push that would leave the machine, with no network: to a remote's url, ahead of the user's +// own shorter rule for it; to a remote's pushurl, in this git, and in a git before 2.46, which +// can't clear pushurls; and to URLs typed in full. Pushes to a local remote, whose path holds a +// colon, a file:// remote and a path go on, and so do fetches (fact 38). +func TestRolePaneBlocksPushesThatLeave(t *testing.T) { + for _, version := range []string{"", "git version 2.45.2"} { + t.Run(cmp.Or(version, "this git"), func(t *testing.T) { + standIn(t) + + mainDir, linked := withRemotes(t) + if version != "" { + gitBut(t, "version) echo '"+version+"'; exit ;;") + } + + started(t, mainDir, fresh()) + + var vars []string + + synctest.Test(t, func(t *testing.T) { + env, _ := inPane(t, linked, paneOf(flow.RoleLead)) + vars = copilotsEnv(t, paneFor(t, env)) + }) + + ctx := t.Context() + + for _, cmd := range []*exec.Cmd{ + exec.CommandContext(ctx, "git", "push", "origin", "HEAD:refs/heads/demo"), + exec.CommandContext(ctx, "git", "push", "mirror", "HEAD:refs/heads/demo"), + exec.CommandContext(ctx, "git", "push", "git@example.invalid:typed.git", + "HEAD:refs/heads/demo"), + exec.CommandContext(ctx, "git", "push", "HTTPS://example.invalid/typed.git", + "HEAD:refs/heads/demo"), + exec.CommandContext(ctx, "git", "push", "[git@example.invalid]:typed.git", + "HEAD:refs/heads/demo"), + } { + wantBlocked(t, cmd, linked, vars) + } + + for _, cmd := range []*exec.Cmd{ + exec.CommandContext(ctx, "git", "push", "local", "HEAD:refs/heads/local"), + exec.CommandContext(ctx, "git", "push", "files", "HEAD:refs/heads/files"), + exec.CommandContext(ctx, "git", "push", "../local:bare.git", "HEAD:refs/heads/path"), + exec.CommandContext(ctx, "git", "fetch", "local", "refs/heads/local"), + } { + wantDone(t, cmd, linked, vars) + } + }) + } +} + +// withRemotes makes a repo with a linked worktree, as repos does, and a bare repo next to them, +// whose path holds a colon, and gives the repo remotes, in a config file that its own includes: +// origin, which leaves the machine, under a shorter rule of the user's that pushes over ssh; +// mirror, whose pushurl leaves too; and local and files, the bare repo by its path and by a +// file:// URL. +func withRemotes(t *testing.T) (mainDir, linked string) { + t.Helper() + + mainDir, linked = repos(t) + gitIn(t, mainDir, exec.CommandContext(t.Context(), "git", "init", "--quiet", "--bare", + "../local:bare.git")) + + bare := filepath.Join(filepath.Dir(mainDir), "local:bare.git") + write(t, filepath.Join(mainDir, ".git"), "remotes", `[url "ssh://git@example.invalid/"] + pushInsteadOf = https://example.invalid/ +[remote "origin"] + url = https://example.invalid/demo.git +[remote "mirror"] + url = https://example.invalid/mirror.git + pushurl = git@example.invalid:mirror.git +[remote "local"] + url = `+bare+` +[remote "files"] + url = file://`+bare+"\n") + gitIn(t, mainDir, exec.CommandContext(t.Context(), "git", "config", "include.path", + "remotes")) + + return mainDir, linked +} + +// copilotsEnv returns the environment that baton pane gave copilot, with ssh as false for git. +func copilotsEnv(t *testing.T, got ran) []string { + t.Helper() + + if len(got.execs) != 1 { + t.Fatalf("got %+v; want copilot started", got) + } + + return append(slices.Clone(got.execs[0].env), "GIT_SSH_COMMAND=false") +} + +// wantBlocked checks that git's command, run in the folder with the environment, fails at baton's +// missing remote helper. +func wantBlocked(t *testing.T, cmd *exec.Cmd, dir string, vars []string) { + t.Helper() + + cmd.Dir, cmd.Env = dir, vars + out, err := cmd.CombinedOutput() + + if err == nil || !strings.Contains(string(out), "'remote-baton-blocks-push' is not a git") { + t.Errorf("%s: %v, %s; want baton's missing remote helper", cmd, err, out) + } +} + +// wantDone checks that git's command, run in the folder with the environment, succeeds. +func wantDone(t *testing.T, cmd *exec.Cmd, dir string, vars []string) { + t.Helper() + + cmd.Dir, cmd.Env = dir, vars + if out, err := cmd.CombinedOutput(); err != nil { + t.Errorf("%s: %v, %s; want it done", cmd, err, out) + } +} + +// TestRolePaneRefusesWithoutItsBelt has the lead's pane refuse before it waits, so that the lead's +// work stays where it is, when it can't make the belt: when git fails as it reads the remotes' +// URLs, or git's version, or when baton can't read the count of git's settings in its environment. +func TestRolePaneRefusesWithoutItsBelt(t *testing.T) { + cases := []struct { + name string + fails string // git's command that fails, if any. + count string // GIT_CONFIG_COUNT, if any. + reason string + }{ + {name: "remotes", fails: "config", reason: "read the remotes' URLs: exit status 3"}, + {name: "version", fails: "version", reason: "find git's version: exit status 3"}, + { + name: "count", + count: " 0", + reason: `GIT_CONFIG_COUNT=" 0" isn't a count of git's settings`, + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + standIn(t) + + mainDir, linked := repos(t) + store := started(t, mainDir, fresh()) + + if test.fails != "" { + gitBut(t, test.fails+") exit 3 ;;") + } + + if test.count != "" { + t.Setenv("GIT_CONFIG_COUNT", test.count) + } + + synctest.Test(t, func(t *testing.T) { + env, _ := inPane(t, linked, paneOf(flow.RoleLead)) + wantRefused(t, paneFor(t, env), "", test.reason+"\n") + }) + + wantWork(t, store, state.Work{ + Note: "01-orchestrator-to-lead.md", From: flow.RoleOrchestrator, + }) + }) + } +} + +// gitBut puts a git first on the PATH that runs the shell's case for git's first argument, which +// ends the script, and the real git for every other. +func gitBut(t *testing.T, cases string) { + t.Helper() + + program, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + + dir, executable := t.TempDir(), os.FileMode(0o700) + text := fmt.Sprintf("#!/bin/sh\ncase $1 in\n%s\nesac\nexec '%s' \"$@\"\n", cases, program) + + if err := os.WriteFile(filepath.Join(dir, "git"), []byte(text), executable); err != nil { + t.Fatal(err) + } + + t.Setenv("PATH", dir+string(filepath.ListSeparator)+os.Getenv("PATH")) +} diff --git a/internal/cli/helpers_test.go b/internal/cli/helpers_test.go index f267954..a99f720 100644 --- a/internal/cli/helpers_test.go +++ b/internal/cli/helpers_test.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" @@ -20,18 +21,30 @@ import ( // The feature that the tests hand over. const slug = "demo-1" +// githubTokens are the variables that give gh and other tools a GitHub token, which a role's pane +// goes without (decision 55). +var githubTokens = []string{ + "GH_TOKEN", + "GITHUB_TOKEN", + "GH_ENTERPRISE_TOKEN", + "GITHUB_ENTERPRISE_TOKEN", +} + // TestMain runs git without the user's config, in the C locale, with a test author: baton's own // git too, which makes the features' branches and worktrees. It clears the variables that git sets -// for its hooks, such as GIT_DIR, which would point git at the repo that runs the hook. It gives -// the tests an empty home folder, which holds the user's config and cache folders, since it -// clears the XDG ones, so that baton reads none of the user's files, such as its config file. The -// tests share that folder, so a test that writes there, as a config file, moves it into one of its -// own first: one that leaves anything there fails them all. +// for its hooks, such as GIT_DIR, which would point git at the repo that runs the hook, and those +// that hold a GitHub token, copilot's too, so that no test hands the user's to a stand-in, and a +// role's environment is the same on every machine. It gives the tests an empty home folder, which +// holds the user's config and cache folders, since it clears the XDG ones, so that baton reads none +// of the user's files, such as its config file. The tests share that folder, so a test that writes +// there, as a config file, moves it into one of its own first: one that leaves anything there +// fails them all. func TestMain(m *testing.M) { var errs []error for _, env := range os.Environ() { - if name, _, _ := strings.Cut(env, "="); strings.HasPrefix(name, "GIT_") { + if name, _, _ := strings.Cut(env, "="); strings.HasPrefix(name, "GIT_") || + slices.Contains(githubTokens, name) || name == "COPILOT_GITHUB_TOKEN" { errs = append(errs, os.Unsetenv(name)) } } diff --git a/internal/cli/pane.go b/internal/cli/pane.go index 94aa3ae..0bcdad5 100644 --- a/internal/cli/pane.go +++ b/internal/cli/pane.go @@ -31,9 +31,10 @@ const envBin = "BATON_BIN" // pane becomes the pane's copilot, with the role's settings from the config file (decision 49). // The orchestrator's copilot starts at once, since the user talks to it. A role's pane waits for // the role's first work, then becomes the role's copilot, with that work as its first prompt -// (decision 38). It finds copilot, writes the plugin and reads the settings before it waits, so -// that a pane that can't start copilot never picks up work that nobody would then do. After the -// wait it checks the plugin again, which a role may have changed meanwhile (decision 53). +// (decision 38), and with the belt in its environment (decision 55). It finds copilot, writes the +// plugin, reads the settings and makes the belt before it waits, so that a pane that can't start +// copilot never picks up work that nobody would then do. After the wait it checks the plugin +// again, which a role may have changed meanwhile (decision 53). func pane(ctx context.Context, env Env, args []string) error { if err := parse(newFlags(env, "pane"), args); err != nil { return err @@ -64,6 +65,11 @@ func pane(ctx context.Context, env Env, args []string) error { return becomeCopilot(env, program, orchestrating(dir, own), environment(bin)) } + vars, err := belted(ctx, env.Dir, bin, allowAll+"=true") + if err != nil { + return err + } + work, err := waitForWork(ctx, env, role, slug) if err != nil { return err @@ -73,8 +79,7 @@ func pane(ctx context.Context, env Env, args []string) error { return err } - return becomeCopilot(env, program, command(role, dir, own, work), - environment(bin, allowAll+"=true")) + return becomeCopilot(env, program, command(role, dir, own, work), vars) } // waitForWork says that the role waits, waits for the role's work in the feature, then picks it diff --git a/internal/cli/pane_test.go b/internal/cli/pane_test.go index 3ed5a1f..03916c3 100644 --- a/internal/cli/pane_test.go +++ b/internal/cli/pane_test.go @@ -71,6 +71,7 @@ func TestPaneWaitsForTheRolesWork(t *testing.T) { go func() { engineer <- paneFor(t, env) }() time.Sleep(time.Minute) + stillWaiting(t, engineer) if _, err := io.WriteString(user, "Are you there?\n"); err != nil { t.Fatal(err) @@ -81,13 +82,7 @@ func TestPaneWaitsForTheRolesWork(t *testing.T) { } time.Sleep(time.Minute) - synctest.Wait() - - select { - case got := <-engineer: - t.Fatalf("the engineer's pane ended before its work came: %+v", got) - default: - } + stillWaiting(t, engineer) handOver(t, mainDir, flow.RoleLead, flow.VerdictNone) @@ -110,12 +105,17 @@ func TestPaneWaitsForTheRolesWork(t *testing.T) { // TestPaneBecomesTheOrchestratorsCopilot has the orchestrator's pane write the plugin and become // the orchestrator's copilot at once, with no work to wait for and no first prompt, since the user -// speaks first, and with copilot's own prompts: the user's environment as it is, but for BATON_BIN, -// which names this baton for the stop hook (decisions 45 and 47). +// speaks first, and with copilot's own prompts: the user's environment as it is, with no belt, so +// with the user's GitHub token and git's settings, but for BATON_BIN, which names this baton for +// the stop hook (decisions 45, 47 and 55). func TestPaneBecomesTheOrchestratorsCopilot(t *testing.T) { copilot, cache := standIn(t) t.Setenv("COPILOT_ALLOW_ALL", "1") t.Setenv("BATON_BIN", "/usr/local/bin/baton") + t.Setenv("GH_TOKEN", "gh-token") + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "user.name") + t.Setenv("GIT_CONFIG_VALUE_0", "user") mainDir, _ := repos(t) env, _ := inPane(t, mainDir, orchestratorPane()) @@ -185,6 +185,19 @@ func paneFor(t *testing.T, env cli.Env) ran { return runExec(ctx, env, nil) } +// stillWaiting fails the test if the engineer's pane has ended, once every other goroutine in the +// bubble is blocked, before the test writes to a terminal that nobody reads any more. +func stillWaiting(t *testing.T, engineer <-chan ran) { + t.Helper() + synctest.Wait() + + select { + case got := <-engineer: + t.Fatalf("the engineer's pane ended before its work came: %+v", got) + default: + } +} + // copilotLine returns the role's copilot command line, with the plugin's folder and the role's // work as its first prompt (decisions 7, 9 to 11 and 38). func copilotLine(role flow.Role, plugin, work string) []string { @@ -197,13 +210,31 @@ func copilotLine(role flow.Role, plugin, work string) []string { } } -// copilotEnv returns the environment that a role's copilot gets: baton's, with BATON_BIN naming -// this baton, for the stop hook, and COPILOT_ALLOW_ALL=true, which trusts copilot's folder, each in -// place of the user's own value, since copilot reads the first of two (fact 9, decisions 39 and -// 47). -func copilotEnv() []string { - return append(withoutVars("BATON_BIN", "COPILOT_ALLOW_ALL"), "BATON_BIN="+self, - "COPILOT_ALLOW_ALL=true") +// copilotEnv returns the environment that a role's copilot gets in a repo whose remotes need the +// push rules, with none of git's settings in baton's environment (decision 55). +func copilotEnv(rules ...[2]string) []string { + return roleEnv(belt(0, rules...)...) +} + +// roleEnv returns the environment that a role's copilot gets, with the variables: baton's, without +// its GitHub tokens, with BATON_BIN naming this baton, for the stop hook, +// COPILOT_ALLOW_ALL=true, which trusts copilot's folder, and the variables, each in place of the +// user's own value, since copilot reads the first of two (fact 9, decisions 39, 47 and 55). +func roleEnv(vars ...string) []string { + vars = append([]string{"BATON_BIN=" + self, "COPILOT_ALLOW_ALL=true"}, vars...) + + return append(withoutVars(slices.Concat(names(vars), githubTokens)...), vars...) +} + +// names returns the variables' names. +func names(vars []string) []string { + names := make([]string, 0, len(vars)) + for _, variable := range vars { + name, _, _ := strings.Cut(variable, "=") + names = append(names, name) + } + + return names } // withoutVars returns baton's environment without the variables with the names. diff --git a/internal/cli/settings_test.go b/internal/cli/settings_test.go index 0e41f40..42fe9f8 100644 --- a/internal/cli/settings_test.go +++ b/internal/cli/settings_test.go @@ -16,7 +16,8 @@ import ( // repo an origin remote, whose URL picks the repo's section in the file (decision 49). // TestPaneGivesTheRoleItsSettings has the lead's pane become the lead's copilot with the lead's -// settings: those for every repo, under those of the repo's section, but not another repo's. +// settings: those for every repo, under those of the repo's section, but not another repo's. Its +// belt blocks pushes to the origin remote's URL (decision 55). func TestPaneGivesTheRoleItsSettings(t *testing.T) { copilot, cache := standIn(t) writeConfig(t, `{ @@ -46,7 +47,7 @@ func TestPaneGivesTheRoleItsSettings(t *testing.T) { "-i", "Picked up demo-1 as lead: round 0 of 3, from orchestrator.\nNote: " + notePath(store, "01-orchestrator-to-lead.md") + "\n", }, - env: copilotEnv(), + env: copilotEnv(pushRule("https://github.com/codedogapp/demo.git")), }) }) } diff --git a/internal/repo/remotes.go b/internal/repo/remotes.go new file mode 100644 index 0000000..4c00eef --- /dev/null +++ b/internal/repo/remotes.go @@ -0,0 +1,66 @@ +package repo + +import ( + "context" + "errors" + "fmt" + "os/exec" + "slices" + "strings" +) + +// RemoteURL is a URL of a remote in the repo's config: one of its urls, or of its pushurls. +type RemoteURL struct { + Remote string // The remote's name. + URL string // The URL as the config holds it, before any of git's insteadOf rules. + Push bool // A pushurl, which git's pushInsteadOf rules skip (fact 38). +} + +// noMatch is git config's exit status when no setting matches. +const noMatch = 1 + +// RemoteURLs returns the URLs of the remotes in the config of the repo that holds the folder, its +// own and the user's, in the order that git reads them, as the config holds them: before any of +// git's insteadOf rules, which git's pushInsteadOf rules match too (fact 38). +func RemoteURLs(ctx context.Context, dir string) ([]RemoteURL, error) { + out, err := git(ctx, dir, "config", "--null", "--get-regexp", `^remote\..*\.(push)?url$`) + if exit, ok := errors.AsType[*exec.ExitError](err); ok && exit.ExitCode() == noMatch { + return nil, nil + } + + if err != nil { + return nil, fmt.Errorf("read the remotes' URLs: %w", err) + } + + var urls []RemoteURL + + // Each setting is its name, a newline and its value, ended by a NUL: remote..url or + // remote..pushurl, where the remote's name may hold dots. + for setting := range strings.SplitSeq(strings.TrimSuffix(out, "\x00"), "\x00") { + key, url, _ := strings.Cut(setting, "\n") + remote, kind, _ := strings.CutLast(strings.TrimPrefix(key, "remote."), ".") + urls = append(urls, RemoteURL{Remote: remote, URL: url, Push: kind == "pushurl"}) + } + + return urls, nil +} + +// clearingMajor and clearingMinor are the first git version in which an empty pushurl clears the +// remote's pushurls before it, rather than adding one (fact 38). +const clearingMajor, clearingMinor = 2, 46 + +// ClearsPushURLs reports whether the user's git clears a remote's pushurls with an empty one, as +// git 2.46 and later do. It takes a version that it can't read for an older one. +func ClearsPushURLs(ctx context.Context) (bool, error) { + out, err := git(ctx, "", "version") + if err != nil { + return false, fmt.Errorf("find git's version: %w", err) + } + + var major, minor int + if read, _ := fmt.Sscanf(out, "git version %d.%d", &major, &minor); read < 2 { + return false, nil + } + + return slices.Compare([]int{major, minor}, []int{clearingMajor, clearingMinor}) >= 0, nil +} diff --git a/internal/repo/remotes_test.go b/internal/repo/remotes_test.go new file mode 100644 index 0000000..d161586 --- /dev/null +++ b/internal/repo/remotes_test.go @@ -0,0 +1,102 @@ +package repo_test + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/codedogapp/baton/internal/repo" +) + +func TestRemoteURLsAreAsTheConfigHoldsThem(t *testing.T) { + mainDir, linked := repos(t) + run(t, mainDir, "config", "url.https://example.invalid/.insteadOf", "demo:") + run(t, mainDir, "remote", "add", "origin", "demo:origin.git") + run(t, mainDir, "remote", "set-url", "--add", "--push", "origin", "git@example.invalid:one.git") + run(t, mainDir, "remote", "set-url", "--add", "--push", "origin", "git@example.invalid:two.git") + run(t, mainDir, "remote", "add", "My.Fork", "../fork.git") + + want := []repo.RemoteURL{ + {Remote: "origin", URL: "demo:origin.git"}, + {Remote: "origin", URL: "git@example.invalid:one.git", Push: true}, + {Remote: "origin", URL: "git@example.invalid:two.git", Push: true}, + {Remote: "My.Fork", URL: "../fork.git"}, + } + + for _, dir := range []string{mainDir, linked} { + got, err := repo.RemoteURLs(t.Context(), dir) + if err != nil || !slices.Equal(got, want) { + t.Errorf("RemoteURLs(%s) = %+v, %v; want %+v", dir, got, err, want) + } + } +} + +func TestRemoteURLsAreNoneWithoutRemotes(t *testing.T) { + mainDir, _ := repos(t) + + if got, err := repo.RemoteURLs(t.Context(), mainDir); got != nil || err != nil { + t.Errorf("RemoteURLs = %+v, %v; want none", got, err) + } +} + +func TestRemoteURLsRefuseABrokenConfig(t *testing.T) { + mainDir, _ := repos(t) + run(t, mainDir, "config", "include.path", "broken") + + err := os.WriteFile(filepath.Join(mainDir, ".git", "broken"), []byte("[remote\n"), 0o600) + if err != nil { + t.Fatal(err) + } + + _, err = repo.RemoteURLs(t.Context(), mainDir) + if want := "read the remotes' URLs: fatal: bad config line 1"; err == nil || + !strings.HasPrefix(err.Error(), want) { + t.Errorf("RemoteURLs = %v; want %q, then the rest of git's reason", err, want) + } +} + +func TestRemoteURLsReportAGitThatFailsWithoutAReason(t *testing.T) { + t.Setenv("PATH", script(t, "git", "exit 3\n")) + + _, err := repo.RemoteURLs(t.Context(), t.TempDir()) + if err == nil || err.Error() != "read the remotes' URLs: exit status 3" { + t.Errorf("RemoteURLs = %v; want git's exit status", err) + } +} + +func TestClearsPushURLsFromGit246(t *testing.T) { + cases := []struct { + version string + want bool + }{ + {version: "git version 2.45.2", want: false}, + {version: "git version 2.46.0", want: true}, + {version: "git version 2.50.1 (Apple Git-155)", want: true}, + {version: "git version 3.0.0", want: true}, + {version: "git version 1.99.9", want: false}, + {version: "git version 2.9.5", want: false}, + {version: "git version 3", want: false}, + {version: "nonsense", want: false}, + } + + for _, test := range cases { + t.Run(test.version, func(t *testing.T) { + t.Setenv("PATH", script(t, "git", "echo '"+test.version+"'\n")) + + if got, err := repo.ClearsPushURLs(t.Context()); got != test.want || err != nil { + t.Errorf("ClearsPushURLs = %t, %v; want %t", got, err, test.want) + } + }) + } +} + +func TestClearsPushURLsReportsAGitThatFails(t *testing.T) { + t.Setenv("PATH", script(t, "git", "exit 3\n")) + + _, err := repo.ClearsPushURLs(t.Context()) + if err == nil || err.Error() != "find git's version: exit status 3" { + t.Errorf("ClearsPushURLs = %v; want git's exit status", err) + } +} diff --git a/internal/repo/repo.go b/internal/repo/repo.go index b28ac2e..dc41116 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -1,5 +1,5 @@ -// Package repo runs git for baton: it finds the repo that baton works in, and the repo's origin -// remote, and makes a feature's branch and worktree. +// Package repo runs git for baton: it finds the repo that baton works in, the repo's origin remote +// and its remotes' URLs, and git's version, and makes a feature's branch and worktree. package repo import (