diff --git a/docs/plan.md b/docs/plan.md index 674328e..c9ba9b6 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -205,6 +205,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `note-save` | The editor's Save sends the note with `PUT /api/notes/files/` and `If-Match`, the version from the note's `ETag`; a saved note shows at once, and a note changed since gets a 412, which keeps the user's text and says so (decisions 400 to 402) | Done (#180) | | `copilot-planner` | The Copilot planner (§8.2 Phase 6), by the vault session in `../bakhyard-vault/`, which adds its steps' rows under this one; it starts before `vault-edit-sync` ends, since the rest of that waits on the user's answer (decisions 195 and 403) | In progress | | `planner-plan` | `copilot-planner` splits in fourteen steps: the Copilot client, the service, the stream, the agent's questions, resuming, the image, the laptop, the gateway, the page, the chat, the agent's questions in the chat, the plan's preview, and "Save to Obsidian", last, once the vault's store is in; the CLI keeps the plans, with no table of their own, and the conversation streams as server-sent events. The first of `copilot-planner`'s fourteen steps (decision 403) | Done (#182) | +| `copilot-client` | The planner's client of the Copilot SDK starts the CLI without the user's Copilot and GitHub settings, and works out a plan in plan mode on claude-opus-5.5, with §3's tools, no custom instructions and every permission denied; `mise run copilot:check` keeps mise's `copilot` at the CLI version that the SDK names (decision 404) | Done (#185) | After these: the product phases (§8.2), each split into steps. @@ -285,9 +286,9 @@ MacBook ────┼── HTTPS (tailnet) ──▶ Bakhyard on the host lap - Look and feel per §7. Home = greeting + "Needs you today" feed (attention items from every app) + "Your apps" grid + weekly hours bar. - **Copilot** (planner service, decisions 72 to 75): - The work account's Copilot Business seat, on its `*.ghe.com` host. The host's name reaches the CLI as `COPILOT_GH_HOST`; it's a local setting, never in git. - - The SDK client gets the token as `GitHubToken`: a fine-grained PAT with the Copilot Requests permission, read from the Keychain, or from a Kubernetes Secret on the laptop (decision 85). The CLI's environment drops `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` and `GITHUB_TOKEN`, and its `BaseDirectory` is the service's own, so the personal token and the user's own `~/.copilot` stay out of it. - - The `copilot` CLI comes from mise, pinned at the version the SDK names; the service passes its path to the SDK. - - One session per plan, on claude-opus-5.5, with `AgentMode: plan`. The allowed tools are `ask_user`, `view`, `glob`, `grep`, `create`, `edit` and `exit_plan_mode`. The working directory is empty, and the permission handler denies every request, so the agent writes only the session's `plan.md`, which the CLI allows by itself. + - The SDK client gets the token as `GitHubToken`: a fine-grained PAT with the Copilot Requests permission, read from the Keychain, or from a Kubernetes Secret on the laptop (decision 85). The CLI's environment drops every `COPILOT_*` and `GH_*` variable and `GITHUB_TOKEN`, then sets `COPILOT_GH_HOST`, and its `BaseDirectory` is the service's own, so the personal token, the user's own Copilot settings and `~/.copilot` stay out of it (decision 404). + - The `copilot` CLI comes from mise, pinned at the version the SDK names, which `mise run copilot:check` checks; the service passes its path to the SDK. + - One session per plan, on claude-opus-5.5, with `AgentMode: plan`. The allowed tools are `ask_user`, `view`, `glob`, `grep`, `create`, `edit` and `exit_plan_mode`. The working directory is an empty folder outside any git repository, the session skips custom instructions, and the permission handler denies every request, so the agent writes only the session's `plan.md`, which the CLI allows by itself. - The agent's questions reach `OnUserInputRequest`; the service relays them to the chat and waits for the answer. - The plan arrives with `OnExitPlanModeRequest`; the service keeps `PlanContent` and answers `exit_only`. - The service, not the agent, writes the plan to `Plans/YYYY-MM-DD-.md` with frontmatter. @@ -745,7 +746,7 @@ Go libraries for the kit, added with `@latest` (latest on 2026-10-18): - Prometheus `client_golang` v1.25.0, `common` v0.72.1. - `nats.go` v1.54.0; `nats-server/v2` v2.15.1 (tests). -Later phases: `go-oidc/v3` 3.21.0, `x/oauth2` 0.37.0, Copilot SDK for Go v1.0.25 with the `copilot` CLI 1.0.102 (on 2026-10-22), the remark plugins for math (§2). Re-check all of these when the work starts. +Later phases: `go-oidc/v3` 3.21.0, `x/oauth2` 0.37.0, Copilot SDK for Go v1.0.28 with the `copilot` CLI 1.0.105 (re-checked on 2026-10-30, now in use), the remark plugins for math (§2). Re-check all of these when the work starts. ### 6.2 mise @@ -761,6 +762,7 @@ Root tasks: | `setup` | Installs tools and web dependencies, enables the git hooks | | `gen` | `buf generate`: the contracts' Go code into `internal/gen` (decision 158) | | `gen:check` | Generates into a temporary folder, then fails when `internal/gen` differs (decision 158) | +| `copilot:check` | Fails when mise's `copilot` isn't the CLI version that the Copilot SDK in `go.mod` names (decision 404) | | `proto:breaking` | `buf breaking` against `main`, from the first change to a contract in use (decision 158) | | `build` | Web build into the web service, then `bin/` | | `build:e2e` | The gateway for the end-to-end tests, with no lock, into `bin/e2e/gateway` (decision 107) | @@ -1296,7 +1298,7 @@ Every item is pending unless it says done. The service an item lands in is in br - After the answer: the store behind the save, with the §3 conflict rules; new notes get unique names; history and rollback; and the way edits get back to the Mac, now that the laptop syncs from Gitea's read-only mirror (decision 151, §12). - `copilot-planner` [planner, web]. Needs `spike-copilot-sdk`; its last step needs `vault-edit-sync`'s store. In fourteen steps (decision 403), which start before `vault-edit-sync` ends: - `planner-plan`: this split. Done (#182). - - `copilot-client`: the planner's client of the Copilot SDK starts a plan in plan mode on claude-opus-5.5, with the tool allowlist and a deny-by-default permission handler (§3), and keeps the plan that `exit_plan_mode` brings. `copilot` is pinned in mise.toml at the CLI version the SDK names; Renovate must update the two together (decision 74). + - `copilot-client`: the planner's client of the Copilot SDK starts a plan in plan mode on claude-opus-5.5, with the tool allowlist and a deny-by-default permission handler (§3), and keeps the plan that `exit_plan_mode` brings. `copilot` is pinned in mise.toml at the CLI version the SDK names, and `mise run copilot:check` fails when they differ (decisions 74 and 404). Done (#185). - `planner-service`: a new `planner` service starts a plan from a prompt and answers its messages and its plan so far. The work host's name is a local setting, and the token comes from the Keychain, or a Kubernetes Secret on the laptop (decisions 72, 73 and 85). - `planner-stream`: the assistant's text as it comes, and the plan, as server-sent events. - `planner-questions`: the agent's questions, with their choices, wait for the user's answer (§3). @@ -1414,8 +1416,8 @@ Made on 2026-10-22: 70. **The vault stays where it is**: the user's choice. The vault stays in iCloud, in `~/Documents`, which Desktop & Documents syncs, and the notes service reads it in place. It doesn't move into `iCloud Drive/Obsidian`, and obsidian-git, which the user runs on it, is no longer Bakhyard's concern. The host is on the same Apple ID, and its vault folder is set to *Keep Downloaded*, or Optimize Mac Storage is off, so notes are on disk when read; an offloaded note still reads, about 1 s late (§2). iCloud does the syncing, so `vault-edit-sync` needs no sync adapter; its git snapshots keep their `--git-dir` outside iCloud. Replaces 1. 71. **A Sync button, no file watcher**: the user's choice. The notes service neither watches nor polls the vault: it scans it when it starts and when the user presses Sync, on any device, and re-reads only the notes whose size or mtime differs from its index (§3). A scan took 3 ms for the user's vault and about 20 ms for 5,000 files. Neither watcher the spike tried fits (§2): fsnotify's kqueue backend holds every watched file open, which stops iCloud from offloading it, and doesn't watch recursively; FSEvents needs cgo and macOS, while CI runs on Linux, its Go library is marked unstable, and its flags can't be trusted, so it would need a scan as well. `vault-index-watch` becomes `vault-index`. 72. **Copilot on the work seat**: the user's choice, with the employer's OK. The planner uses the work account's Copilot Business seat rather than a personal plan. The account lives on GitHub Enterprise Cloud with data residency, on a `*.ghe.com` host, so the CLI needs the host's name in `COPILOT_GH_HOST`; the name is a local setting and stays out of git. The organisation's settings apply: its Copilot CLI policy, the models it allows and its budgets (§10). Replaces 4. -73. **A fine-grained PAT in the Keychain**: the user's choice. The planner signs in with a fine-grained PAT owned by the work account, with the Copilot Requests permission and read-only access to public repositories, kept in the macOS Keychain and passed to the SDK as `GitHubToken`. The CLI's environment drops `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` and `GITHUB_TOKEN`, so the personal token never reaches it, and its `BaseDirectory` keeps its state apart from the user's own `~/.copilot` (§3). The PAT expires, so the token-expiry reminders cover it (§8.2 `hardening-docs`). -74. **The CLI pinned with the SDK**: the user's choice. mise installs `copilot`, pinned exactly in mise.toml at the CLI version the SDK release names (1.0.102 for v1.0.25), and the service passes its path to the SDK. It's the one mise tool pinned exactly; the others follow `latest` (§10). The SDK's bundler, which embeds the CLI in the Go binary, isn't used, nor is Homebrew's cask, which updates itself. The SDK checks the CLI's protocol version when it connects, so a mismatch fails at start; Renovate must update the two together. +73. **A fine-grained PAT in the Keychain**: the user's choice. The planner signs in with a fine-grained PAT owned by the work account, with the Copilot Requests permission and read-only access to public repositories, kept in the macOS Keychain and passed to the SDK as `GitHubToken`. The CLI's environment drops every `COPILOT_*` and `GH_*` variable and `GITHUB_TOKEN` (decision 404), so the personal token never reaches it, and its `BaseDirectory` keeps its state apart from the user's own `~/.copilot` (§3). The PAT expires, so the token-expiry reminders cover it (§8.2 `hardening-docs`). +74. **The CLI pinned with the SDK**: the user's choice. mise installs `copilot`, pinned exactly in mise.toml at the CLI version the SDK release names (1.0.105 for v1.0.28), and the service passes its path to the SDK. It's the one mise tool pinned exactly; the others follow `latest` (§10). The SDK's bundler, which embeds the CLI in the Go binary, isn't used, nor is Homebrew's cask, which updates itself. The SDK checks the CLI's protocol version when it connects, so a mismatch fails at start; Renovate must update the two together, and `mise run copilot:check` fails until they match (decision 404). 75. **Plans on claude-opus-5.5, with no credit cap**: the user's choices. The planner asks for claude-opus-5.5, which cost about 8.8 credits (about $0.09) for a short plan, where the default, claude-sonnet-5.5, cost 3.8 (§2). There's no per-session cap: `SessionLimits`, which is experimental, isn't used, and the organisation's budget is the only limit. 76. **Foundation with its first user**: the user's choice. `backend-foundation` is no longer a step of its own. Building SQLite, the Keychain, a scheduler and a cache before anything used them would break AGENTS.md's rule that infrastructure arrives only when a step needs it. Each piece comes with the first step that needs it (§8.2), inside that service, and moves to the root `internal/` once a second service needs it. Configuration stays environment variables (§4.7). The first step after the split is `auth-tailscale`, the first slice of `auth-signin`, which needs none of the pieces. 77. **The Tailscale lock**: the user's choices. The gateway lets in only the login in `BAKHYARD_TAILSCALE_LOGIN`, on the ts.net name in `BAKHYARD_HOST`. Both are personal, so they live in `mise.local.toml`, and the gateway doesn't start without them. Every other request but `/healthz` gets a plain-text 403 and reaches no service (§3 Auth). There's no bypass for local use: the browser goes through `tailscale serve`, even on the host. A process on the host can still forge both headers by calling `127.0.0.1:8080` directly; Google sign-in, the second lock, covers that (§10). @@ -1844,6 +1846,12 @@ Decisions 400 to 499 are the vault session's: `vault-edit-sync`, then `copilot-p - **The laptop first**: the gateway doesn't start without a peer it requires, so `homelab-planner` merges before `planner-gateway`, as decision 200 has it. The homelab PR needs the user's OK and the PAT (decision 73); until then the page's steps go first, and the page says that Copilot isn't set up. - **Credits**: `check` spends none, since its tests use stand-ins. The few live checks with the real CLI, about 8.8 credits each (decision 75), wait for the PAT, and each PR says what it spent. - **Rejected**: the planner after `vault-edit-sync`, as decision 195 had it, which would leave this session idle on the user's answer; a table of plans, which would copy what the CLI keeps; polling the conversation, which would show a plan's text in jumps; the Home's "Needs you" for a plan whose agent waits for an answer, which §8.2 doesn't ask for, though §4.13's demo shows one; it can come later. +404. **The Copilot client** (`copilot-client`): the lead's choices (2026-10-30), for the user to review. The package `services/planner/internal/copilot` starts the CLI through the SDK and works out a plan in a session of its own; nothing calls it until `planner-service`. + - **The CLI's environment**: the SDK gives the CLI the environment it's handed and nothing else, so the client hands it the service's own without every `COPILOT_*` and `GH_*` variable and `GITHUB_TOKEN`, then sets `COPILOT_GH_HOST` to the work host. The CLI 1.0.105 reads more than tokens from them (`copilot help environment`): `COPILOT_ALLOW_ALL` approves every tool, `COPILOT_PROVIDER_*` swaps in another model provider, and `COPILOT_HOME`, `COPILOT_MODEL` and `GH_HOST` would override the service's settings. This widens decision 73, which dropped the three tokens alone. The client also turns off `UseLoggedInUser`, so the CLI never signs in with a login of its own. + - **The session**: plan mode on the SDK's default mode, which the spike tested, with §3's tools. The session skips custom instructions, and its working folder is empty and outside any git repository, so nothing but the prompt steers the agent. The permission handler rejects every request. `exit_only`, the answer to `exit_plan_mode`, ends the turn, and the client keeps the plan's text. When the prompt fails to send, the client leaves the session. + - **Tests**: a stand-in CLI speaks the SDK's JSON-RPC over TCP, which the SDK reaches as an external server (`URIConnection`), so the tests need no CLI and spend no credits. + - **The pin, checked**: mise's `copilot` is lazy: it installs the first time it runs, so CI, which never runs it, doesn't download its 130 MB. `mise run copilot:check`, part of `check`, reads the SDK's version in `go.mod`, fetches the SDK's `nodejs/package.json` at that release, as the SDK's own bundler does, and fails when its `copilotCliVersion` isn't mise's `copilot`. Renovate leaves `copilot` alone, since the CLI's newest release can run ahead of the SDK's, and its PRs for the SDK say to update the pin. + - **Rejected**: the SDK's empty mode, which the spike didn't try with plan mode; the whole environment minus the three tokens; a Renovate group for the SDK and the CLI, which would move the CLI to its newest release rather than the SDK's; a Renovate datasource reading the SDK's `nodejs/package.json` on its default branch, which can run ahead of `go.mod`; the CLI in CI. Decisions 500 on are the orchestrator's, after 199. @@ -1857,7 +1865,7 @@ Decisions 500 on are the orchestrator's, after 199. - **Token expiry**: Jira ≤ 365 d, org PAT lifetime policies, the Copilot PAT (decision 73), the GitHub PAT (≤ 1 year, decision 202) → expiry tracking and reminders. - **The GitHub token's reach**: a classic PAT's `repo` scope is full control of every repository the user can reach, though the `github` service only reads (decision 202); fine-grained PATs, which can be read-only, cover one owner each. - **Rate limits**: Bitbucket 1,000 req/h; GitHub search 30 req/min. -- **Copilot SDK**: the SDK and the `copilot` CLI must move together, as each SDK release names its CLI version and checks the protocol (decision 74). `Usage.GetMetrics` is experimental; `SessionLimits` and the mode RPCs, also experimental, aren't used. The CLI writes the session's `plan.md` without asking the permission handler, and a later CLI could allow more by itself, so the tool allowlist and the empty working directory stay as well (§3). Employer-managed settings can override the local config. +- **Copilot SDK**: the SDK and the `copilot` CLI must move together, as each SDK release names its CLI version and checks the protocol (decision 74); `mise run copilot:check` fails while they differ (decision 404). `Usage.GetMetrics` is experimental; `SessionLimits` and the mode RPCs, also experimental, aren't used. The CLI writes the session's `plan.md` without asking the permission handler, and a later CLI could allow more by itself, so the tool allowlist and the empty working directory stay as well (§3). Employer-managed settings can override the local config. - **Sign-in**: - Any process on the host passes the Tailscale lock by calling `127.0.0.1:8080` with a forged `Tailscale-User-Login` and the ts.net `Host` (tested 2026-10-22). Tailscale's docs warn of the same: listening on the loopback only limits forgery to the host's own processes (decision 77). Since `signin-lock`, such a process also needs a live session's token, which only the browser holds, in an HttpOnly cookie that no service behind the gateway receives. Since `signin-screen` narrowed the lock to `/api`, it can still read the app's files, which hold no data (decision 86). On the laptop, NetworkPolicies let only the operator's proxy pods reach the gateway, but Kubernetes always lets a node reach its own pods, so the laptop's own processes can forge both headers there too (decision 137). - Google's login stays inside the iPhone Home Screen app on iOS 27 (§2). A later iOS may hand it to Safari again, whose cookie jar is separate; then the iPhone would rely on the Tailscale identity alone, or a passkey (decision 63). diff --git a/go.mod b/go.mod index d51f1e6..e984e37 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( connectrpc.com/connect/v2 v2.0.2 github.com/SherClockHolmes/webpush-go v1.4.0 github.com/coreos/go-oidc/v3 v3.21.0 + github.com/github/copilot-sdk/go v1.0.28 github.com/go-chi/chi/v5 v5.5.0 github.com/jackc/pgx/v5 v5.11.0 github.com/modelcontextprotocol/go-sdk v1.8.0 @@ -15,9 +16,15 @@ require ( ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/go-jose/go-jose/v4 v4.1.6 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/jsonschema-go v0.4.3 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.3 // indirect @@ -26,6 +33,11 @@ require ( github.com/segmentio/encoding v0.5.4 // indirect github.com/sethvargo/go-retry v0.5.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.58.0 // indirect golang.org/x/sync v0.24.0 // indirect diff --git a/go.sum b/go.sum index 12490f7..f8a65eb 100644 --- a/go.sum +++ b/go.sum @@ -2,15 +2,28 @@ connectrpc.com/connect/v2 v2.0.2 h1:PoPDmOlhc46elsuCdKCKTxjNBb3HJlKF+s/sP2oucPw= connectrpc.com/connect/v2 v2.0.2/go.mod h1:N32OLrstcPUzkykFynnefFro3+xzSdOrZlK2jHmS/xo= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/github/copilot-sdk/go v1.0.28 h1:p9205uladSUScyM7B+AbdaGLbcQGSTbx3U+bSH1YXZ4= +github.com/github/copilot-sdk/go v1.0.28/go.mod h1:U0STg9Jdv9LDwtkUtcUJCzPQ4KTnKakRfP+1LhEx248= github.com/go-chi/chi/v5 v5.5.0 h1:xdKQ1dJmVJsmgahrXSKhUx8NyVPzpwdlyrrQFpMPJgg= github.com/go-chi/chi/v5 v5.5.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM= github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4= github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= @@ -56,6 +69,16 @@ github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUm github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= diff --git a/mise.toml b/mise.toml index d2ed772..d9e4616 100644 --- a/mise.toml +++ b/mise.toml @@ -14,6 +14,10 @@ jactionlint = "latest" shellcheck = "latest" buf = "latest" process-compose = "latest" +# The planner's Copilot CLI, pinned at the version that the Copilot SDK in go.mod names, which +# copilot:check checks (decision 74). Lazy, it installs when first run, so CI, which never runs +# it, doesn't download it. +copilot = { version = "1.0.105", lazy = true } [env] # process-compose's API server listens on localhost:8080 by default, the gateway's address. @@ -72,6 +76,21 @@ if ! diff -r "${fresh}/internal/gen" internal/gen; then fi ''' +[tasks."copilot:check"] +description = "Fail if mise's copilot isn't the CLI version that the Copilot SDK in go.mod names" +shell = "bash -c" +run = ''' +set -euo pipefail +sdk=$(go list -m -json github.com/github/copilot-sdk/go | jq -er .Version) +named=$(curl -sSf "https://raw.githubusercontent.com/github/copilot-sdk/go/${sdk}/nodejs/package.json" | + jq -er .copilotCliVersion) +pinned=$(mise config get --file mise.toml tools.copilot.version) +if [[ "${pinned}" != "${named}" ]]; then + echo "error: mise.toml pins copilot ${pinned}, but the Copilot SDK ${sdk} names ${named}" >&2 + exit 1 +fi +''' + [tasks."lint:compose"] description = "Check process-compose.yaml (process-compose up --dry-run)" run = "process-compose up --dry-run" @@ -102,6 +121,7 @@ depends = [ "lint:actions", "lint:compose", "gen:check", + "copilot:check", "cover", "vuln", "//apps/web:check", diff --git a/renovate.json b/renovate.json index 433b357..4b55f51 100644 --- a/renovate.json +++ b/renovate.json @@ -16,6 +16,17 @@ "description": ["pnpm refuses npm releases younger than a day, so Renovate waits as long (docs/plan.md §6.4)"], "matchDatasources": ["npm"], "minimumReleaseAge": "1 day" + }, + { + "description": ["The Copilot CLI follows the version that the Copilot SDK names, not its newest release (docs/plan.md decision 74)"], + "matchManagers": ["mise"], + "matchDepNames": ["copilot"], + "enabled": false + }, + { + "description": ["Each Copilot SDK release names its Copilot CLI version, which mise.toml pins (docs/plan.md decision 74)"], + "matchDepNames": ["github.com/github/copilot-sdk/go"], + "prBodyNotes": ["Set `copilot` in `mise.toml` to the `copilotCliVersion` in the SDK's `nodejs/package.json` at this release; `mise run copilot:check` fails until it matches."] } ] } diff --git a/services/planner/internal/copilot/copilot.go b/services/planner/internal/copilot/copilot.go new file mode 100644 index 0000000..021e84d --- /dev/null +++ b/services/planner/internal/copilot/copilot.go @@ -0,0 +1,88 @@ +// Package copilot works out plans with the Copilot CLI, through the Copilot SDK. +package copilot + +import ( + "context" + "fmt" + "slices" + "strings" + + sdk "github.com/github/copilot-sdk/go" +) + +// Settings say where the Copilot CLI is and which account it works for. +type Settings struct { + // CLI is the path of the copilot executable. + CLI string + // Home is the CLI's own folder for its state, the plans' sessions among it. + Home string + // Work is an empty folder, outside any git repository, that the CLI works in. + Work string + // Host is the account's GitHub host. + Host string + // Token is the account's fine-grained token with the Copilot Requests permission. + Token string +} + +// Client works out plans on a Copilot CLI of its own. +type Client struct { + copilot *sdk.Client + work string +} + +// New returns a client for the CLI that settings name. The CLI inherits environ, less the +// variables that set up Copilot or GitHub, so only settings decide whom it works for. +func New(settings Settings, environ []string) *Client { + return newClient(clientOptions(settings, environ), settings.Work) +} + +func newClient(options *sdk.ClientOptions, work string) *Client { + return &Client{copilot: sdk.NewClient(options), work: work} +} + +func clientOptions(settings Settings, environ []string) *sdk.ClientOptions { + return &sdk.ClientOptions{ + Connection: sdk.StdioConnection{ + Path: settings.CLI, + Env: cliEnv(environ, settings.Host), + }, + WorkingDirectory: settings.Work, + BaseDirectory: settings.Home, + GitHubToken: settings.Token, + UseLoggedInUser: new(false), + } +} + +// cliEnv returns environ without the variables that set up Copilot or GitHub, such as another +// account's token, and with host as the CLI's GitHub host. +func cliEnv(environ []string, host string) []string { + env := slices.DeleteFunc(slices.Clone(environ), setsUpCopilot) + + return append(env, "COPILOT_GH_HOST="+host) +} + +// setsUpCopilot reports whether variable, a NAME=value pair, sets up Copilot or GitHub. +func setsUpCopilot(variable string) bool { + name, _, _ := strings.Cut(variable, "=") + + return strings.HasPrefix(name, "COPILOT_") || strings.HasPrefix(name, "GH_") || + name == "GITHUB_TOKEN" +} + +// Start starts the CLI and connects to it. +func (c *Client) Start(ctx context.Context) error { + if err := c.copilot.Start(ctx); err != nil { + return fmt.Errorf("start the copilot CLI: %w", err) + } + + return nil +} + +// Stop leaves the plans' sessions, which the CLI keeps, and stops the CLI. +func (c *Client) Stop() error { + if err := c.copilot.Stop(); err != nil { + return fmt.Errorf("stop the copilot CLI: %w", err) + } + + return nil +} diff --git a/services/planner/internal/copilot/copilot_test.go b/services/planner/internal/copilot/copilot_test.go new file mode 100644 index 0000000..dd4c71e --- /dev/null +++ b/services/planner/internal/copilot/copilot_test.go @@ -0,0 +1,106 @@ +package copilot + +import ( + "errors" + "io/fs" + "path/filepath" + "reflect" + "slices" + "testing" + + sdk "github.com/github/copilot-sdk/go" +) + +func TestClientOptions(t *testing.T) { + settings := Settings{ + CLI: "/tools/copilot", Home: "/planner/home", Work: "/planner/work", + Host: "example.ghe.com", Token: "secret-token", + } + + options := clientOptions(settings, []string{"HOME=/home/user", "GH_TOKEN=personal-token"}) + + want := sdk.StdioConnection{ + Path: "/tools/copilot", Env: []string{"HOME=/home/user", "COPILOT_GH_HOST=example.ghe.com"}, + } + if !reflect.DeepEqual(options.Connection, want) { + t.Errorf("Connection = %#v, want %#v", options.Connection, want) + } + + if options.WorkingDirectory != "/planner/work" || options.BaseDirectory != "/planner/home" { + t.Errorf("folders = %q, %q, want the work and home folders", + options.WorkingDirectory, options.BaseDirectory) + } + + if options.GitHubToken != "secret-token" { + t.Errorf("GitHubToken = %q, want the settings' token", options.GitHubToken) + } + + if options.UseLoggedInUser == nil || *options.UseLoggedInUser { + t.Error("UseLoggedInUser isn't false, so the CLI may sign in as the logged-in user") + } +} + +func TestCLIEnv(t *testing.T) { + tests := []struct { + name string + environ, want []string + }{ + { + name: "keeps the rest", + environ: []string{ + "PATH=/usr/bin", "HOME=/home/user", "GITHUB_ACTIONS=true", "GH=x", "NOT_GH_TOKEN=x", + }, + want: []string{ + "PATH=/usr/bin", "HOME=/home/user", "GITHUB_ACTIONS=true", "GH=x", "NOT_GH_TOKEN=x", + "COPILOT_GH_HOST=example.ghe.com", + }, + }, + { + name: "drops tokens", + environ: []string{ + "COPILOT_GITHUB_TOKEN=a", "PATH=/usr/bin", "GH_TOKEN=b", "GITHUB_TOKEN=c", + "GH_ENTERPRISE_TOKEN=d", + }, + want: []string{"PATH=/usr/bin", "COPILOT_GH_HOST=example.ghe.com"}, + }, + { + name: "drops settings", + environ: []string{ + "COPILOT_ALLOW_ALL=true", + "COPILOT_GH_HOST=github.com", + "GH_HOST=github.com", + "COPILOT_HOME=/home/user/.copilot", + "COPILOT_PROVIDER_BASE_URL=http://x", + "COPILOT_X", + }, + want: []string{"COPILOT_GH_HOST=example.ghe.com"}, + }, + { + name: "from nothing", + want: []string{"COPILOT_GH_HOST=example.ghe.com"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + environ := slices.Clone(test.environ) + + if got := cliEnv(environ, "example.ghe.com"); !slices.Equal(got, test.want) { + t.Errorf("cliEnv = %q, want %q", got, test.want) + } + + if !slices.Equal(environ, test.environ) { + t.Errorf("cliEnv changed its environ to %q", environ) + } + }) + } +} + +func TestStartWithoutCLI(t *testing.T) { + work := t.TempDir() + client := New(Settings{CLI: filepath.Join(work, "copilot"), Work: work, Token: "x"}, nil) + + if err := client.Start(t.Context()); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Start = %v, want an error for the missing CLI", err) + } +} diff --git a/services/planner/internal/copilot/fake_test.go b/services/planner/internal/copilot/fake_test.go new file mode 100644 index 0000000..24a89c3 --- /dev/null +++ b/services/planner/internal/copilot/fake_test.go @@ -0,0 +1,242 @@ +package copilot + +import ( + "bufio" + "encoding/json/jsontext" + "encoding/json/v2" + "fmt" + "io" + "net" + "net/textproto" + "strconv" + "sync" + "testing" +) + +// incoming is a JSON-RPC 2.0 message from the client: a request if it has a method, else an +// answer to the fake's request. +type incoming struct { + ID jsontext.Value `json:"id"` + Method string `json:"method"` + Params jsontext.Value `json:"params"` + Result jsontext.Value `json:"result"` +} + +// outgoing is a JSON-RPC 2.0 message from the fake. +type outgoing struct { + JSONRPC string `json:"jsonrpc"` + ID jsontext.Value `json:"id"` + Method string `json:"method,omitzero"` + Params any `json:"params,omitzero"` + Result any `json:"result,omitzero"` + Error *failure `json:"error,omitzero"` +} + +// internalError is JSON-RPC's code for a server's internal error. +const internalError = -32603 + +// failure is a JSON-RPC 2.0 error. +type failure struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// exitRequest is the request in which the CLI brings the plan. +type exitRequest struct { + SessionID string `json:"sessionId"` + Summary string `json:"summary"` + PlanContent string `json:"planContent"` + Actions []string `json:"actions"` + RecommendedAction string `json:"recommendedAction"` +} + +// session is the part of a request's parameters that names its session. +type session struct { + SessionID string `json:"sessionId"` +} + +// fakeCLI is a stand-in Copilot CLI that a client reaches over TCP. It answers a plan's requests, +// with an error for the method in failing, and notes their parameters. Once it has the prompt, +// it brings plan with exitPlanMode.request, and passes the client's answer to answers. +type fakeCLI struct { + plan, failing string + answers chan jsontext.Value + + mu sync.Mutex + asked map[string]jsontext.Value +} + +func newFakeCLI(plan, failing string) *fakeCLI { + return &fakeCLI{ + plan: plan, failing: failing, + answers: make(chan jsontext.Value, 1), asked: map[string]jsontext.Value{}, + } +} + +// serve starts f on a free port, for one connection, and returns its address. +func (f *fakeCLI) serve(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { _ = listener.Close() }) + + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + + f.talk(conn) + _ = conn.Close() + }() + + return listener.Addr().String() +} + +// talk answers what comes over conn until it closes. +func (f *fakeCLI) talk(conn io.ReadWriter) { + reader := textproto.NewReader(bufio.NewReader(conn)) + + for { + message, err := read(reader) + if err != nil { + return + } + + if message.Method == "" { + f.answers <- message.Result + + continue + } + + if err := f.answer(conn, message); err != nil { + return + } + } +} + +// answer notes request and answers it. After the prompt, it brings the plan. +func (f *fakeCLI) answer(out io.Writer, request incoming) error { + f.mu.Lock() + f.asked[request.Method] = request.Params + f.mu.Unlock() + + named, err := sessionOf(request.Params) + if err != nil { + return err + } + + reply := outgoing{ID: request.ID, Result: result(request.Method, named)} + if request.Method == f.failing { + reply = outgoing{ID: request.ID, Error: &failure{internalError, "failing on purpose"}} + } + + if err := write(out, reply); err != nil || reply.Error != nil { + return err + } + + if request.Method != "session.send" { + return nil + } + + return write(out, f.bring(named.SessionID)) +} + +// bring returns the request that brings the plan to the client of session id. +func (f *fakeCLI) bring(id string) outgoing { + request := exitRequest{ + SessionID: id, Summary: "A plan", PlanContent: f.plan, + Actions: []string{"autopilot", "interactive", "exit_only"}, + RecommendedAction: "autopilot", + } + + return outgoing{ID: jsontext.Value("1"), Method: "exitPlanMode.request", Params: request} +} + +// sessionOf returns the session that params name, if any. +func sessionOf(params jsontext.Value) (session, error) { + var named session + if len(params) == 0 { + return named, nil + } + + err := json.Unmarshal(params, &named) + + return named, err +} + +// result returns the CLI's answer to a request for method in session. +func result(method string, named session) any { + switch method { + case "connect": + return map[string]any{"ok": true, "protocolVersion": 3, "version": "1.0.105"} + case "session.create": + return map[string]string{"sessionId": named.SessionID, "workspacePath": "/workspace"} + case "session.send": + return map[string]string{"messageId": "message-1"} + case "session.detach": + return map[string]bool{"success": true} + default: + return map[string]any{} + } +} + +// read reads a message from its Content-Length frame. +func read(reader *textproto.Reader) (incoming, error) { + var message incoming + + header, err := reader.ReadMIMEHeader() + if err != nil { + return message, err + } + + length, err := strconv.Atoi(header.Get("Content-Length")) + if err != nil { + return message, err + } + + body := make([]byte, length) + if _, err := io.ReadFull(reader.R, body); err != nil { + return message, err + } + + err = json.Unmarshal(body, &message) + + return message, err +} + +// write writes message in a Content-Length frame. +func write(out io.Writer, message outgoing) error { + message.JSONRPC = "2.0" + + body, err := json.Marshal(message) + if err != nil { + return err + } + + _, err = fmt.Fprintf(out, "Content-Length: %d\r\n\r\n%s", len(body), body) + + return err +} + +// asks reports whether the client asked for method, and decodes the parameters it last sent +// into params. +func (f *fakeCLI) asks(t *testing.T, method string, params any) bool { + t.Helper() + + f.mu.Lock() + sent, found := f.asked[method] + f.mu.Unlock() + + if found && params != nil { + if err := json.Unmarshal(sent, params); err != nil { + t.Fatal(err) + } + } + + return found +} diff --git a/services/planner/internal/copilot/plan.go b/services/planner/internal/copilot/plan.go new file mode 100644 index 0000000..e4d3e45 --- /dev/null +++ b/services/planner/internal/copilot/plan.go @@ -0,0 +1,89 @@ +package copilot + +import ( + "context" + "errors" + "fmt" + "sync" + + sdk "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/rpc" +) + +// model works out the plans (decision 75). +const model = "claude-opus-5.5" + +// exitOnly answers exit_plan_mode: plan mode ends, and the agent doesn't start on the plan. +const exitOnly = "exit_only" + +// tools returns the tools that the agent may use. It writes the plan with create and edit, +// without which exit_plan_mode brings no plan. +func tools() []string { + return []string{"ask_user", "view", "glob", "grep", "create", "edit", "exit_plan_mode"} +} + +// Plan is a plan that the agent works out in a session of its own. +type Plan struct { + id string + mu sync.Mutex + content string +} + +// Plan starts a session that works out a plan for prompt, in plan mode. The agent goes on +// working after Plan returns. +func (c *Client) Plan(ctx context.Context, prompt string) (*Plan, error) { + plan := &Plan{} + + session, err := c.copilot.CreateSession(ctx, &sdk.SessionConfig{ + Model: model, + AvailableTools: tools(), + WorkingDirectory: c.work, + SkipCustomInstructions: new(true), + OnPermissionRequest: deny, + OnExitPlanModeRequest: plan.keep, + }) + if err != nil { + return nil, fmt.Errorf("start a session: %w", err) + } + + plan.id = session.SessionID + + _, err = session.Send(ctx, sdk.MessageOptions{Prompt: prompt, AgentMode: sdk.AgentModePlan}) + if err != nil { + return nil, errors.Join(fmt.Errorf("send the prompt: %w", err), session.Disconnect()) + } + + return plan, nil +} + +// ID returns the ID of the plan's session. +func (p *Plan) ID() string { + return p.id +} + +// Content returns the plan that exit_plan_mode brought, or "" while the agent works on it. +func (p *Plan) Content() string { + p.mu.Lock() + defer p.mu.Unlock() + + return p.content +} + +// keep keeps the plan that exit_plan_mode brings, and ends plan mode. +func (p *Plan) keep( + request sdk.ExitPlanModeRequest, + _ sdk.ExitPlanModeInvocation, +) (sdk.ExitPlanModeResult, error) { + p.mu.Lock() + defer p.mu.Unlock() + + p.content = request.PlanContent + + return sdk.ExitPlanModeResult{Approved: true, SelectedAction: exitOnly}, nil +} + +// deny turns down every permission request, so the agent writes only the session's plan, which +// the CLI allows by itself. +func deny(sdk.PermissionRequest, sdk.PermissionInvocation) (rpc.PermissionDecision, error) { + return &rpc.PermissionDecisionReject{}, nil +} diff --git a/services/planner/internal/copilot/plan_test.go b/services/planner/internal/copilot/plan_test.go new file mode 100644 index 0000000..dde87fd --- /dev/null +++ b/services/planner/internal/copilot/plan_test.go @@ -0,0 +1,139 @@ +package copilot + +import ( + "encoding/json/v2" + "reflect" + "testing" + "time" + + sdk "github.com/github/copilot-sdk/go" + "github.com/github/copilot-sdk/go/rpc" +) + +// created is what a client asks for when it creates a plan's session. +type created struct { + SessionID string `json:"sessionId"` + Model string `json:"model"` + AvailableTools []string `json:"availableTools"` + WorkingDirectory string `json:"workingDirectory"` + SkipCustomInstructions bool `json:"skipCustomInstructions"` + RequestPermission bool `json:"requestPermission"` + RequestExitPlanMode bool `json:"requestExitPlanMode"` +} + +// sent is what a client sends with a plan's prompt. +type sent struct { + SessionID string `json:"sessionId"` + Prompt string `json:"prompt"` + AgentMode string `json:"agentMode"` +} + +// started returns a started client of fake, which works in /work. +func started(t *testing.T, fake *fakeCLI) *Client { + t.Helper() + + client := newClient( + &sdk.ClientOptions{Connection: sdk.URIConnection{URL: fake.serve(t)}}, + "/work", + ) + if err := client.Start(t.Context()); err != nil { + t.Fatal(err) + } + + t.Cleanup(func() { + if err := client.Stop(); err != nil { + t.Error(err) + } + }) + + return client +} + +func TestPlan(t *testing.T) { + fake := newFakeCLI("# Plan\n\n1. Pack.\n", "") + + plan, err := started(t, fake).Plan(t.Context(), "Plan a trip") + if err != nil { + t.Fatal(err) + } + + var session created + fake.asks(t, "session.create", &session) + + want := created{ + SessionID: plan.ID(), Model: "claude-opus-5.5", + AvailableTools: []string{ + "ask_user", "view", "glob", "grep", "create", "edit", "exit_plan_mode", + }, + WorkingDirectory: "/work", SkipCustomInstructions: true, + RequestPermission: true, RequestExitPlanMode: true, + } + if plan.ID() == "" || !reflect.DeepEqual(session, want) { + t.Errorf("session.create = %+v, want %+v", session, want) + } + + var prompt sent + fake.asks(t, "session.send", &prompt) + + if want := (sent{plan.ID(), "Plan a trip", "plan"}); prompt != want { + t.Errorf("session.send = %+v, want %+v", prompt, want) + } + + var answer sdk.ExitPlanModeResult + + select { + case got := <-fake.answers: + if err := json.Unmarshal(got, &answer); err != nil { + t.Fatal(err) + } + case <-time.After(10 * time.Second): + t.Fatal("no answer to exitPlanMode.request") + } + + if want := (sdk.ExitPlanModeResult{Approved: true, SelectedAction: "exit_only"}); answer != want { + t.Errorf("answer = %+v, want %+v", answer, want) + } + + if got := plan.Content(); got != "# Plan\n\n1. Pack.\n" { + t.Errorf("Content = %q, want the plan that exit_plan_mode brought", got) + } +} + +func TestPlanFails(t *testing.T) { + tests := []struct { + failing string + left bool + }{ + {failing: "session.create"}, + {failing: "session.send", left: true}, + } + + for _, test := range tests { + t.Run(test.failing, func(t *testing.T) { + fake := newFakeCLI("", test.failing) + + if _, err := started(t, fake).Plan(t.Context(), "Plan a trip"); err == nil { + t.Fatal("Plan succeeded") + } + + if left := fake.asks(t, "session.detach", nil); left != test.left { + t.Errorf("left the session: %t, want %t", left, test.left) + } + }) + } +} + +func TestDeny(t *testing.T) { + requests := []sdk.PermissionRequest{ + &rpc.PermissionRequestWrite{FileName: "/work/notes.md"}, + &rpc.PermissionRequestShell{FullCommandText: "rm -rf /"}, + &rpc.PermissionRequestURL{URL: "https://example.com"}, + } + + for _, request := range requests { + decision, err := deny(request, sdk.PermissionInvocation{SessionID: "session-1"}) + if _, rejected := decision.(*rpc.PermissionDecisionReject); !rejected || err != nil { + t.Errorf("deny(%T) = %#v, %v, want a rejection", request, decision, err) + } + } +}