From 3743a4aa62d00974c5c3887748667e4f8f66ba33 Mon Sep 17 00:00:00 2001 From: Lutz Lange Date: Wed, 30 Sep 2026 18:42:45 +0200 Subject: [PATCH 1/6] feat(agent): manage Copilot CLI and VS Code Copilot Chat Part of #86. Two programs that point Copilot at the local LLM proxy, user mode only: - `programs.copilot` writes the Copilot CLI's BYOK provider registry (`~/.copilot/providers.json`, or `$COPILOT_HOME` / `COPILOT_PROVIDERS_CONFIG`): providers `agentdesktop` (OpenAI-shaped) and `agentdesktop-anthropic` on the proxy's `/copilot-cli` route, with the pairing header; the user's own providers are kept. Discovery reports the CLI and its version from the npm manifest. - `programs.vscode` with `copilotChat: ownModels` (default) writes `chatLanguageModels.json` for Copilot Chat's built-in Custom Endpoint provider on `/vscode-copilot`; the user's entries are kept. - `copilotChat: githubModels` keeps GitHub's own models and the user's Copilot seat: the daemon sets `github.copilot.advanced.debug.overrideCapiUrl` in the user `settings.json` to the proxy's `/vscode-copilot-capi/` route (pairing in the path, since VS Code cannot add a header), forwards to `llmGateway.proxyUrl` with the client's Copilot token moved aside, tunnels the Responses WebSocket for agent mode, forwards `_ping` without a credential, and keeps the override out of Settings Sync. `settings.json` is edited in place (JSONC): only the managed keys change, comments, key order and formatting stay, and removal restores the file byte for byte. Managed files are written 0600 and carry a sidecar that records what the daemon added, so removal takes out exactly that. A file the daemon cannot parse is a conflict and left alone. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 11 + Cargo.toml | 3 +- README.md | 307 +++- crates/agent/Cargo.toml | 5 + crates/agent/src/daemon.rs | 148 ++ crates/agent/src/llm_proxy.rs | 1241 +++++++++++++- crates/agent/src/provider/README.md | 34 +- .../agent/src/provider/copilot/discovery.rs | 77 + .../src/provider/copilot/integration_tests.rs | 197 +++ crates/agent/src/provider/copilot/mod.rs | 81 + .../agent/src/provider/copilot/reconcile.rs | 297 ++++ .../src/provider/copilot/testdata/Dockerfile | 19 + .../copilot/testdata/providers.lab.json | 34 + crates/agent/src/provider/copilot/tests.rs | 1063 ++++++++++++ crates/agent/src/provider/json_merge.rs | 622 ++++++- crates/agent/src/provider/mod.rs | 1 + crates/agent/src/provider/vscode/discovery.rs | 6 +- .../src/provider/vscode/integration_tests.rs | 328 +++- crates/agent/src/provider/vscode/mod.rs | 75 +- crates/agent/src/provider/vscode/reconcile.rs | 267 +++ crates/agent/src/provider/vscode/settings.rs | 664 ++++++++ .../src/provider/vscode/settings_tests.rs | 1431 +++++++++++++++++ .../src/provider/vscode/testdata/Dockerfile | 6 +- .../testdata/chatLanguageModels.lab.json | 20 + crates/agent/src/provider/vscode/tests.rs | 732 +++++++++ crates/agent/src/reconcile/mod.rs | 180 ++- crates/agent/src/reconcile/plan.rs | 21 + crates/agent/tests/common/container.rs | 19 +- crates/agent/tests/common/gateway.rs | 3 +- crates/agent/tests/integration.rs | 3 + crates/core/src/config.rs | 391 +++++ frontend/controller/src/types.ts | 5 +- .../src/views/ConfigurationView.tsx | 41 +- frontend/ui/src/tools.tsx | 6 +- schema/daemon-config.json | 184 +++ schema/daemon-config.md | 19 + 36 files changed, 8448 insertions(+), 93 deletions(-) create mode 100644 crates/agent/src/provider/copilot/discovery.rs create mode 100644 crates/agent/src/provider/copilot/integration_tests.rs create mode 100644 crates/agent/src/provider/copilot/mod.rs create mode 100644 crates/agent/src/provider/copilot/reconcile.rs create mode 100644 crates/agent/src/provider/copilot/testdata/Dockerfile create mode 100644 crates/agent/src/provider/copilot/testdata/providers.lab.json create mode 100644 crates/agent/src/provider/copilot/tests.rs create mode 100644 crates/agent/src/provider/vscode/reconcile.rs create mode 100644 crates/agent/src/provider/vscode/settings.rs create mode 100644 crates/agent/src/provider/vscode/settings_tests.rs create mode 100644 crates/agent/src/provider/vscode/testdata/chatLanguageModels.lab.json create mode 100644 crates/agent/src/provider/vscode/tests.rs diff --git a/Cargo.lock b/Cargo.lock index e4525f6e..2a9b399c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,7 @@ dependencies = [ "hyper-rustls", "hyper-util", "json5", + "jsonc-parser", "keyring", "keyring-core", "libc", @@ -74,6 +75,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha1", "sha2 0.11.0", "similar", "tempfile", @@ -3017,6 +3019,15 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "jsonc-parser" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ecded084f9b9a718d39668a42657c32253663d1f6afb3fbb9134f0305f73f48" +dependencies = [ + "serde_json", +] + [[package]] name = "jsonptr" version = "0.7.1" diff --git a/Cargo.toml b/Cargo.toml index ba899d72..87f884d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ notify = "8.2" notify-debouncer-full = "0.7" jsonwebtoken = { version = "11.0", features = ["aws_lc_rs"] } json5 = "1.3" +jsonc-parser = { version = "0.34", default-features = false, features = ["cst", "serde_json"] } keyring = "4.2" keyring-core = "1" libc = "0.2" @@ -59,7 +60,7 @@ serde_yaml = "0.9" sha2 = "0.11" similar = "3.2" sqlx = { version = "0.9", default-features = false, features = ["any", "macros", "migrate", "postgres", "runtime-tokio", "sqlite", "tls-rustls-aws-lc-rs"] } -tokio = { version = "1", features = ["macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio = { version = "1", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } tokio-stream = "0.1" tonic = { version = "0.14", features = ["transport", "tls-aws-lc"] } tonic-prost = "0.14" diff --git a/README.md b/README.md index 405db1f7..1f37f1c8 100644 --- a/README.md +++ b/README.md @@ -186,9 +186,12 @@ paths on the prefix-less route: | `/copilot-cli/` | `copilot-cli` | gateway identity only | `llmGateway.url` | | `/vscode-copilot/` | `vscode-copilot` | gateway identity only | `llmGateway.url` | | `/vscode-copilot-passthrough/` | `vscode-copilot` | gateway identity in `Authorization`, the client's own bearer token in `x-llm-token` | `llmGateway.proxyUrl` (required) | +| `/vscode-copilot-capi//` | `vscode-copilot` | as the pass-through route; a credential-less `GET /_ping` is forwarded without `x-llm-token`; the only route that tunnels WebSocket upgrades | `llmGateway.proxyUrl` (required) | **Pairing.** Every route requires the per-device pairing value in the -`x-agentdesktop-pairing` header. The daemon creates it in its state directory +`x-agentdesktop-pairing` header, except `/vscode-copilot-capi/`, which takes it +as the first path segment after the prefix (VS Code cannot add a header there) +and ignores the header. The daemon creates it in its state directory (`llm-proxy-pairing`, owner-only) on first use and keeps it across restarts; a manual setup copies it from there. The state directory in `--user` mode is `$XDG_STATE_HOME/agentdesktop`, by default `~/.local/state/agentdesktop` (the @@ -207,7 +210,13 @@ buffered (32 MiB cap) so a request can be sent once more with a fresh credential when the gateway rejects a cached one with 401 (only when no client token was forwarded); responses stream as they arrive, with a 600 s limit on the response headers and no total limit on the body; a non-streaming completion -sends nothing until it is done, so the header limit bounds it too. +sends nothing until it is done, so the header limit bounds it too. A WebSocket +tunnel on `/vscode-copilot-capi/` is outside the header limit: it runs until a +side closes it or the gateway credential used at the upgrade expires, so a +tunnel outlives a revocation or logout for up to that credential's lifetime. +A WebSocket upgrade on any other route is refused with `400 +upgrade_not_supported`; other `Upgrade` tokens are stripped and the request is +forwarded as plain HTTP. Controller-issued credentials are cached in the daemon for at most 60 s per client ID, and are not used within 30 s of their own expiry, nor after a logout or re-enrollment. Concurrent requests that find no cached credential share one @@ -225,8 +234,8 @@ fetch (20 s) and a second header limit. Refused and failed requests return an OpenAI-style JSON body, `{"error": {"message", "type", "code"}}`, so the LLM clients display the message. Codes: `host_not_allowed`, `pairing_invalid`, `browser_not_allowed`, `method_not_allowed`, `path_invalid`, `route_unknown`, -`client_credential_missing`, `body_too_large`, `body_invalid`, -`agentdesktop_credential` (no gateway credential for this device: not enrolled, +`client_credential_missing`, `upgrade_not_supported`, `body_too_large`, +`body_invalid`, `agentdesktop_credential` (no gateway credential for this device: not enrolled, revoked, controller unreachable or slow), `agentdesktop_unavailable`, `gateway_unreachable`, `gateway_timeout`. @@ -262,6 +271,293 @@ device authorization again. Non-expiring App user tokens are also supported. Request bodies remain untouched. The existing Claude credential-helper flow is unchanged; the local proxy always uses the gateway identity in `Authorization`. +### Managed program: GitHub Copilot CLI + +`programs.copilot` makes the daemon write the Copilot CLI's BYOK provider +registry so the CLI talks to the proxy's `/copilot-cli` route with client ID +`copilot-cli`; nothing is edited by hand. User mode only (the file lives in the +user's Copilot directory), and it needs `daemon.llmProxy.listen`, a configured +`llmGateway`, and `copilot-cli` in `allowedClientIds`: + +```yaml +programs: + copilot: + models: + gpt-4.1: # the name the CLI shows: --model agentdesktop/gpt-4.1 + wireModel: gpt-4.1-mini # what is sent to the gateway (defaults to modelId, which defaults to the key) + claude-haiku-4.5: + provider: agentdesktop-anthropic + wireModel: claude-haiku-4-5 +``` + +The file is `providers.json` in `$COPILOT_HOME` (default `~/.copilot`), or the +path in `COPILOT_PROVIDERS_CONFIG` when set. The daemon reads those variables +from its own environment, which for a service-started daemon is not the +user's shell: if the CLI is pointed elsewhere by a shell profile, set +`daemon.copilot.config` in the local startup file to the same path. The daemon +adds two provider entries, `agentdesktop` (OpenAI-compatible, base URL +`http:///copilot-cli/v1`) and `agentdesktop-anthropic` (Anthropic, +base URL `http:///copilot-cli`), both carrying the pairing header, +plus one model entry per configured model (`id` is the map key, `provider` +and `modelId` are typed fields, other keys such as `wireModel` are passed +through to the CLI). Providers and +models the user added themselves are kept, including models of their own +under other providers with the same IDs. The two provider names and the +models under them belong to the daemon once it has written the file: it +replaces them on every apply, so an edit made by hand to one of them is +undone. Before the daemon has written the file, a user provider under one of +those names (without a pairing header), or a model under such a name, is a conflict: +the file is left alone and, as for every managed file, the daemon applies +nothing on the device until the conflict is resolved (a conflict present at +startup stops the daemon from starting, as for the other programs). If the +daemon's sidecar next to the file is gone, entries under the managed names +that carry a pairing header are replaced on the next apply, and those that +carry this daemon's own pairing value are removed when the program goes +away; entries with another pairing value are left alone. The file is written owner-only. Removing +`programs.copilot`, setting `useLlmGateway: false`, or running without the +proxy takes the managed entries out again and restores the user's file with +its previous mode (deleted only if the daemon created it and nothing else is +left). + +Upgrade note: `programs.copilot` is a new field. Upgrade the controller first +(an older controller rejects the configuration), then the daemons (an older +daemon marks a pushed configuration that carries the field as failed); push +the program only to devices that run in user mode, since a system-mode daemon +fails the whole apply for the program it cannot manage. + +Pick the model with `copilot --model agentdesktop/gpt-4.1` or the `model` key +in the CLI's `settings.json` (`/model` in the CLI); the daemon does not write +that key. There is no `--dry-run` preview for this file: the proxy cannot run +in one-shot mode, so `daemon.llmProxy.listen` is refused together with +`--once` or `--dry-run`, and without the listener the daemon has nothing to +point the CLI at. A running CLI session does not pick up a changed `providers.json`; +start a new one. Each device's `providers.json` is tied to that device's +pairing value, so after a re-enrollment the daemon rewrites it on the next +apply. Discovery reports the CLI's version from the `@github/copilot` npm +manifest next to the launcher; a standalone binary shows no version. The +program cannot be applied with `--once`. + +### Managed program: VS Code Copilot Chat on own models + +`programs.vscode` makes the daemon write VS Code's custom language-model file +(`chatLanguageModels.json`, read by Copilot Chat's built-in **Custom +Endpoint** provider) so the model picker offers the configured models through +the proxy's `/vscode-copilot` route with client ID `vscode-copilot`. User mode +only; needs `daemon.llmProxy.listen`, a configured `llmGateway`, and +`vscode-copilot` (not the proxy's default `vscode`) in `allowedClientIds`: + +```yaml +programs: + vscode: + copilotChat: ownModels # or githubModels, see the next section + models: + gpt-4.1-mini: # the model name sent to the gateway + name: GPT-4.1 mini (agentdesktop) # picker label, defaults to " (agentdesktop)" + maxInputTokens: 128000 + maxOutputTokens: 16000 +``` + +The file lives in the VS Code user profile directory: Linux +`~/.config/Code/User`, macOS `~/Library/Application Support/Code/User`, +Windows `\AppData\Roaming\Code\User` (the daemon derives it from the +home directory, not from `APPDATA`); the local startup setting +`daemon.vscode.config` overrides it. Insiders and VSCodium profiles are not +managed. + +The daemon writes one vendor entry named `agentdesktop` (`vendor: +customendpoint`, `apiType: chat-completions`) with one model per configured +entry: `id`, `name`, the `url` on the proxy, `toolCalling` (default true), +`vision` (default false), the token limits when set, and the pairing value in +`requestHeaders`; other keys are passed through. VS Code keeps the file's +`apiKey` in the OS keyring and does not send it reliably, so the placeholder +`apiKey: unused` stays and the header carries the pairing; no secret is +written. + +Other vendor entries in the file are the user's and are kept. The +`agentdesktop` entry belongs to the daemon once it has written the file: it is +replaced as a whole on every apply (a hand edit, or a model added by hand +inside that entry, is undone) and removed by name on removal. Before the +first write, a user entry with that name and no pairing header is a conflict, +as for the Copilot CLI file. The file is written owner-only, plan reports +carry no content, there is no `--dry-run` preview and `--once` refuses the +program, and the upgrade note for `programs.copilot` applies (controller +first, then daemons, user-mode devices only). + +VS Code reads the file at startup: restart it after an apply that changes the +file (first apply, model changes, re-pairing after a re-enrollment). +`chat.defaultModel` is not written by the daemon; set it by policy if a +default is wanted. The file must stay plain JSON: a file with comments or +trailing commas is reported as a conflict rather than rewritten without +them, and a conflict blocks every managed file on the device until it is +resolved (and stops the daemon at startup, as for the other programs); an +empty file is filled. If the daemon's sidecar next to the file is gone, an +`agentdesktop` entry carrying a pairing header is replaced on the next apply, +and one carrying this daemon's own pairing value is removed when the program +goes away. Pass-through +model keys are written as given, so a misspelt key reaches VS Code unchanged. +The daemon's sidecar next to the file (`.chatLanguageModels.json.agentdesktop`, +owner-only) keeps a copy of the file as last written, including the user's +own entries, until the next apply. Non-default VS Code profiles and +`XDG_CONFIG_HOME` are not considered, matching the MCP inventory. + +### Managed program: VS Code Copilot Chat on GitHub's models through the gateway + +With `copilotChat: githubModels`, VS Code keeps GitHub's own models and its own +Copilot sign-in, and the daemon routes Copilot Chat's traffic to GitHub through +the gateway. The daemon writes two keys into the VS Code user `settings.json`: +`github.copilot.advanced.debug.overrideCapiUrl`, pointing Copilot Chat's API +endpoint at the proxy's `/vscode-copilot-capi/` route, and the two +entries `github.copilot.advanced.debug.overrideCapiUrl` and +`github.copilot.internal.capiUrl` in `settingsSync.ignoredSettings`, so +Settings Sync does not carry the override to other machines. The managed +`chatLanguageModels.json` entry is removed under this value; `models` must be +empty. Needs `llmGateway.proxyUrl` (a gateway route that forwards to GitHub with +the client's own token, see the example below), `daemon.llmProxy.listen`, user +mode, and `vscode-copilot` in `allowedClientIds`: + +```yaml +llmGateway: + url: https://gateway.example + proxyUrl: https://gateway.example/copilot-proxy + authentication: + type: controllerJwt + audience: agentgateway + allowedClientIds: [vscode-copilot] +programs: + vscode: + copilotChat: githubModels +``` + +VS Code sends its Copilot session token (`tid=...`) as the bearer token on chat +requests and the raw GitHub OAuth token (`gho_...`) on `/agents/*`; the proxy +moves either into `x-llm-token`, puts the gateway identity into `Authorization` +and forwards to `proxyUrl`. The pairing value travels in the URL path on this +route, because VS Code sends these requests itself and has no setting that +adds a header to them; the segment is checked in constant time and removed +before forwarding, and appears in no log or plan report. Copilot Chat's health +check, `GET /_ping` every few seconds, carries no credential and is forwarded +without `x-llm-token`. Auto and agent mode open a WebSocket (`GET /responses`) +that the proxy tunnels: the gateway identity is checked once, at the upgrade, +and the tunnel stays open until either side closes it or the gateway +credential it was opened with expires (its own expiry, typically the +controller JWT's; a re-enrollment or logout does not cut an open +conversation). Everything else VS Code sends goes to the gateway and on to +GitHub unchanged, including its `Copilot-Session-Token` (the Auto session +token) and its machine and device ids; gateway access logs that record request +headers see them. Quota headers from GitHub pass through, so VS Code's usage +display keeps working. + +The file lives in the VS Code user profile directory next to +`chatLanguageModels.json` (Linux `~/.config/Code/User/settings.json`); the +local startup setting `daemon.vscode.settings` overrides it. The daemon adds +its two keys and keeps every other setting; user entries in +`settingsSync.ignoredSettings` are kept, ours are added by value. A user's own +`overrideCapiUrl` is overwritten while the program is active and restored on +removal; `github.copilot.internal.capiUrl` is the newer alias of the same +setting and is only added to the ignore list, not written. The file is written +owner-only (an existing world-readable file is tightened, since the URL +carries the pairing) and removal keeps the mode it finds; the sidecar is +`.settings.json.agentdesktop`. VS Code itself saves the file with its own +default mode (664 with a new inode when a setting is changed in the UI), so the +URL is readable to the group and others until the daemon's next apply +tightens it again (a config push or a daemon restart); an unchanged file +looser than 0600 is tightened on the next apply without being rewritten +otherwise. The override key is the daemon's while the program is active: a +hand edit of the URL is replaced on the next apply and is not kept as the +user's value on removal (only a value the key had before the first apply +comes back). + +The daemon edits the file in place: the override and the two ignore entries +are the only text it changes (new properties go at the top of the object), +and comments, trailing commas, key order, indentation, line endings and a +leading byte-order mark stay as they are; removal takes out exactly what the apply added, so a file the +daemon only added keys to returns to its earlier content. One exception: a +file written on a single line (`{ "a": 1 }`) is expanded to one property per +line when keys are added and stays expanded after removal; VS Code itself +always writes one property per line. The sidecar holds no +copy of the file, only the override's earlier value and which ignore entries +the daemon added. A conflict (the file is not valid VS Code JSONC, meaning +anything beyond comments and trailing commas such as a missing comma or a +single-quoted string; the top level is not an object; +`settingsSync.ignoredSettings` is not an array; or a managed key appears twice) +leaves the file untouched and blocks every managed file on the device until it +is resolved (and stops the daemon at startup); fix the file in VS Code and +re-apply. Removal (program +absent, `ownModels`, `useLlmGateway: false`, no gateway, no `proxyUrl`, or no +proxy, with a warning) takes the key and the two entries out and deletes the +file only if the daemon created it. Restart VS Code after an apply that changes +the file (first apply, re-pairing after a re-enrollment). There is no +`--dry-run` preview: a dry run has no proxy and plans a removal; `--once` +refuses the program. + +Caveats. `github.copilot.advanced.debug.overrideCapiUrl` is an undocumented +setting of the Copilot Chat extension; if a VS Code release drops it, the +fallback is VS Code's HTTP proxy setting with the gateway as the proxy, which +this program does not manage. While the daemon is not running, the override +stays in `settings.json` and VS Code keeps sending its requests to the +loopback port: they fail, and they carry the user's Copilot session token +(`tid=`), the GitHub OAuth token (`gho_`, on `/agents/*`) and the pairing +value, so another local user who binds that port in the meantime receives +them. Use the program on devices with a single VS Code user, stop VS Code (or +switch the program to `ownModels` and let the daemon apply it) before stopping +or removing the daemon, and delete the key by hand if the daemon is removed +with the override in place: Copilot Chat fails until then. Which of GitHub's +models the picker offers is decided by the organisation's Copilot policy, not +by the daemon. + +Upgrade note: `copilotChat: githubModels` (which requires the existing +`llmGateway.proxyUrl`) and `daemon.vscode.settings` are new. Upgrade the +controller first, then the daemons, then push the value; an older controller +rejects the value. + +A gateway route for `proxyUrl` (Solo Enterprise for AgentGateway standalone), +verified with Business seats against `api.business.githubcopilot.com`; other +plans use another host, which a signed-in VS Code shows in its Copilot Chat log +(the `_ping` requests), so take it from there before writing the route. The +route validates the controller JWT, restores the client's token as the bearer +for GitHub and forwards. The backend must speak HTTP/1.1 to GitHub (`alpn`): +with the default ALPN, GitHub answers the forwarded upgrade with `400 +websocket: the client is not using the websocket protocol` and VS Code falls +back to plain `POST /responses` (turns still answer, without the WebSocket). +Its `requestTimeout` bounds request/response exchanges; an established +WebSocket tunnel is not cut by it. + +```yaml +routes: +- name: copilot-proxy + gateways: gateway + matches: + - path: + pathPrefix: /copilot-proxy + policies: + jwtAuth: + mode: strict + issuer: agentdesktop-controller + audiences: [agentgateway] + jwks: + url: https:///.well-known/jwks.json + urlRewrite: + authority: + full: api.business.githubcopilot.com + path: + prefix: / + timeout: + requestTimeout: 120s + backends: + - host: api.business.githubcopilot.com:443 + policies: + backendTLS: + alpn: [http/1.1] # the WebSocket upgrade cannot travel over HTTP/2 + backendAuth: + passthrough: {} + transformations: + request: + set: + authorization: '"Bearer " + request.headers["x-llm-token"]' + remove: + - x-llm-token +``` + ## Start locally, grow into a fleet Agentdesktop uses the same daemon and tool-native configuration model at every @@ -303,8 +599,9 @@ stage. | Codex | Yes | Yes | MCP and skills | Yes | | Cursor | Yes | — | MCP and skills | — | | OpenCode | Yes | Yes | MCP | — | -| VS Code | Yes | — | MCP and skills | — | +| VS Code | Yes | User mode (Copilot Chat) | MCP and skills | — | | Grok Build | Yes | System mode | MCP and skills | — | +| GitHub Copilot CLI | Yes | User mode | — | — | > **Don't see your tool?** We're actively expanding this list and would love > your help. [Open an integration request](https://github.com/agentdesktop-dev/agentdesktop/issues/new) diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index e3e6846e..b3c1ebe8 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -35,6 +35,7 @@ hyper-rustls.workspace = true hyper-util = { workspace = true, features = ["client-legacy", "http1"] } http-body-util.workspace = true json5.workspace = true +jsonc-parser.workspace = true memchr.workspace = true open.workspace = true rand.workspace = true @@ -55,6 +56,10 @@ url.workspace = true [dev-dependencies] futures-util = "0.3" +# Hand-rolled RFC 6455 handshake for the vscode-copilot-capi WebSocket +# tunnel tests (llm_proxy.rs): Sec-WebSocket-Accept is +# base64(sha1(key + the RFC 6455 GUID)). +sha1 = "0.11" tempfile = "3" testcontainers = "0.28" tracing-subscriber.workspace = true diff --git a/crates/agent/src/daemon.rs b/crates/agent/src/daemon.rs index 1be7c6d7..64b38d31 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -81,6 +81,12 @@ struct ResolvedDaemonArgs { codex: ResolvedToolConfigPath, open_code: ResolvedOpenCodeStartupConfig, grok: ResolvedToolConfigPath, + /// The Copilot CLI providers file; `None` in system mode (user-only program). + copilot_providers: Option, + /// VS Code's `chatLanguageModels.json`; `None` in system mode (user-only program). + vscode_chat_models: Option, + /// VS Code's user `settings.json`; `None` in system mode (user-only program). + vscode_settings: Option, once: bool, dry_run: bool, } @@ -131,6 +137,20 @@ impl DaemonArgs { if llm_proxy_listen.is_some() && !config::valid_client_id(&llm_proxy_client_id) { bail!("invalid daemon.llmProxy.clientId"); } + // VS Code's chatLanguageModels.json and settings.json live in the + // user's own profile, like the Copilot CLI providers file: a system + // daemon has no user file to manage, so an explicit override is + // rejected up front. + if !user + && let Some(field) = [ + ("config", startup.vscode.config.is_some()), + ("settings", startup.vscode.settings.is_some()), + ] + .into_iter() + .find_map(|(field, set)| set.then_some(field)) + { + bail!("daemon.vscode.{field} requires --user (or daemon.user: true)"); + } if !user { return Ok(ResolvedDaemonArgs { user: false, @@ -180,6 +200,14 @@ impl DaemonArgs { .config .unwrap_or_else(reconcile::default_grok_managed_config_path), }, + copilot_providers: { + if startup.copilot.config.is_some() { + bail!("daemon.copilot.config requires --user (or daemon.user: true)"); + } + None + }, + vscode_chat_models: None, + vscode_settings: None, once: self.once || self.dry_run, dry_run: self.dry_run, }); @@ -250,6 +278,22 @@ impl DaemonArgs { .join("managed_config.toml") }), }, + copilot_providers: Some(match startup.copilot.config { + Some(path) => path, + None => reconcile::default_copilot_providers_path()?, + }), + vscode_chat_models: Some( + startup + .vscode + .config + .unwrap_or_else(|| reconcile::default_vscode_chat_models_path(&home)), + ), + vscode_settings: Some( + startup + .vscode + .settings + .unwrap_or_else(|| reconcile::default_vscode_settings_path(&home)), + ), once: self.once || self.dry_run, dry_run: self.dry_run, }) @@ -371,6 +415,9 @@ where args.open_code.config.clone(), args.open_code.plugin.clone(), args.grok.config.clone(), + args.copilot_providers.clone(), + args.vscode_chat_models.clone(), + args.vscode_settings.clone(), agentdesktop_client_executable()?, socket.clone(), ) @@ -856,6 +903,16 @@ fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow if !config.telemetry.events.is_empty() { bail!("--once cannot collect telemetry because hooks require the daemon to remain running"); } + if config.programs.copilot.is_some() { + bail!( + "--once cannot manage the GitHub Copilot CLI providers file because its local proxy requires the daemon to remain running" + ); + } + if config.programs.vscode.is_some() { + bail!( + "--once cannot manage the VS Code chatLanguageModels.json and settings.json files because their local proxy requires the daemon to remain running" + ); + } let authenticated_gateway_is_used = config .llm_gateway .as_ref() @@ -886,6 +943,16 @@ fn validate_one_shot(config: &agentdesktop_core::config::DaemonConfig) -> anyhow .grok .as_ref() .is_some_and(|program| program.use_llm_gateway), + config + .programs + .copilot + .as_ref() + .is_some_and(|program| program.use_llm_gateway), + config + .programs + .vscode + .as_ref() + .is_some_and(|program| program.use_llm_gateway), ] .into_iter() .any(|used| used); @@ -1607,6 +1674,48 @@ telemetry: ); } + #[test] + fn one_shot_rejects_the_copilot_program() { + let copilot = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + copilot: + models: + gpt-4.1: {} +"#, + ) + .unwrap(); + assert!( + validate_one_shot(&copilot) + .unwrap_err() + .to_string() + .contains("Copilot") + ); + } + + #[test] + fn one_shot_rejects_the_vscode_program() { + let vscode = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + vscode: + models: + gpt-4.1-mini: {} +"#, + ) + .unwrap(); + assert!( + validate_one_shot(&vscode) + .unwrap_err() + .to_string() + .contains("VS Code") + ); + } + #[test] fn dry_run_rejects_controller_managed_configuration() { let managed = parse_daemon( @@ -1660,6 +1769,45 @@ programs: startup } + #[test] + fn system_mode_rejects_a_copilot_providers_path() { + let mut startup = config::DaemonStartupConfig::default(); + startup.copilot.config = Some(PathBuf::from("/tmp/providers.json")); + let error = resolve_error(daemon_args(false), startup); + assert!( + error + .to_string() + .contains("daemon.copilot.config requires --user"), + "{error:#}" + ); + } + + #[test] + fn system_mode_rejects_a_vscode_settings_path() { + let mut startup = config::DaemonStartupConfig::default(); + startup.vscode.settings = Some(PathBuf::from("/tmp/settings.json")); + let error = resolve_error(daemon_args(false), startup); + assert!( + error + .to_string() + .contains("daemon.vscode.settings requires --user"), + "{error:#}" + ); + } + + #[test] + fn system_mode_rejects_a_vscode_chat_models_path() { + let mut startup = config::DaemonStartupConfig::default(); + startup.vscode.config = Some(PathBuf::from("/tmp/chatLanguageModels.json")); + let error = resolve_error(daemon_args(false), startup); + assert!( + error + .to_string() + .contains("daemon.vscode.config requires --user"), + "{error:#}" + ); + } + #[test] fn system_mode_rejects_a_configured_llm_proxy() { let error = resolve_error( diff --git a/crates/agent/src/llm_proxy.rs b/crates/agent/src/llm_proxy.rs index 5fff6f92..04beecbe 100644 --- a/crates/agent/src/llm_proxy.rs +++ b/crates/agent/src/llm_proxy.rs @@ -3,7 +3,7 @@ use std::{convert::Infallible, net::SocketAddr, path::Path, sync::Arc, time::Dur use anyhow::Context; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use bytes::Bytes; -use http_body_util::{BodyExt, Full, combinators::BoxBody}; +use http_body_util::{BodyExt, Empty, Full, combinators::BoxBody}; use hyper::{ HeaderMap, Method, Request, Response, StatusCode, Uri, body::Incoming, @@ -74,6 +74,29 @@ struct CachedCredential { /// suspend, the wall clock can jump; neither alone is trusted. valid_until: std::time::Instant, valid_until_unix: u64, + /// The credential's own expiry (the controller JWT `exp`), on both clocks: + /// a tunnel opened with the credential runs until then, not until the + /// cache stops reusing it. + expires: std::time::Instant, + expires_unix: u64, +} + +/// A point in time on both clocks (see `CachedCredential`): a tunnel closes +/// when either says the credential has expired. +#[derive(Clone, Copy, Debug)] +struct Deadline { + monotonic: std::time::Instant, + unix: u64, +} + +impl Deadline { + fn from_unix(expires_unix: u64) -> Self { + Self { + monotonic: std::time::Instant::now() + + Duration::from_secs(expires_unix.saturating_sub(now_unix())), + unix: expires_unix, + } + } } fn now_unix() -> u64 { @@ -97,14 +120,14 @@ impl CredentialCache { /// The cached credential for the key, or one from `fetch`, which is cached /// at once. Single-flight: a caller that finds a fetch in progress waits - /// for it and reads its result. Returns the credential and whether it came - /// from the cache. + /// for it and reads its result. Returns the credential, its own expiry, and + /// whether it came from the cache. async fn get_or_fetch( &self, key: &str, device_id: &str, fetch: F, - ) -> Result<(String, bool), E> + ) -> Result<(String, Deadline, bool), E> where F: FnOnce() -> Fut, Fut: @@ -112,16 +135,26 @@ impl CredentialCache { { let flight = self.flight(key); let _flight = flight.lock().await; - if let Some(credential) = self.get(key, device_id) { - return Ok((credential, true)); + if let Some((credential, deadline)) = self.lookup(key, device_id) { + return Ok((credential, deadline, true)); } let fetched = fetch().await?; self.insert(key, device_id, &fetched); - Ok((fetched.credential, false)) + let deadline = Deadline::from_unix(fetched.expires_at_unix_seconds); + Ok((fetched.credential, deadline, false)) } /// A cached credential for the key and device, if one is still valid. + #[cfg(test)] fn get(&self, key: &str, device_id: &str) -> Option { + self.lookup(key, device_id) + .map(|(credential, _)| credential) + } + + /// A cached credential together with its own expiry, read in one go so a + /// tunnel opened with it always gets the deadline of the credential it + /// was actually opened with. + fn lookup(&self, key: &str, device_id: &str) -> Option<(String, Deadline)> { let entries = self .entries .lock() @@ -133,7 +166,15 @@ impl CredentialCache { && std::time::Instant::now() < entry.valid_until && now_unix() < entry.valid_until_unix }) - .map(|entry| entry.credential.clone()) + .map(|entry| { + ( + entry.credential.clone(), + Deadline { + monotonic: entry.expires, + unix: entry.expires_unix, + }, + ) + }) } /// Remember a freshly fetched credential until the earlier of its expiry @@ -161,6 +202,8 @@ impl CredentialCache { device_id: device_id.to_owned(), valid_until: std::time::Instant::now() + lifetime, valid_until_unix: now + lifetime.as_secs(), + expires: std::time::Instant::now() + remaining, + expires_unix: fetched.expires_at_unix_seconds, }, ); } else { @@ -223,8 +266,23 @@ pub(crate) struct ProxyRoute { pub client_id: &'static str, pub credential: RouteCredential, pub upstream: Upstream, + /// The pairing is the first path segment after the prefix (the header is + /// ignored): for a client that cannot add a header to its requests. + pub pairing_in_path: bool, + /// WebSocket upgrades are tunnelled instead of refused. + pub tunnels_upgrades: bool, + /// A credential-less `GET /_ping` is forwarded without `x-llm-token`. + pub credential_less_ping: bool, } +/// VS Code Copilot Chat's CAPI pass-through route (the target of +/// `github.copilot.advanced.debug.overrideCapiUrl`): the pairing travels as +/// the first path segment after the prefix instead of in `PAIRING_HEADER`, +/// because VS Code sends these requests itself and has no setting that adds a +/// header to them. Also the only route a WebSocket upgrade is tunnelled on +/// (the `GET /responses` Auto/agent conversation). +pub(crate) const CAPI_ROUTE: &str = "/vscode-copilot-capi"; + /// Routes for the Copilot programs. A prefix matches only at a `/` boundary, so /// `/vscode-copilot-passthrough/...` never matches `/vscode-copilot`. pub(crate) const ROUTES: &[ProxyRoute] = &[ @@ -233,21 +291,123 @@ pub(crate) const ROUTES: &[ProxyRoute] = &[ client_id: "vscode-copilot", credential: RouteCredential::Passthrough, upstream: Upstream::ProxyUrl, + pairing_in_path: false, + tunnels_upgrades: false, + credential_less_ping: false, + }, + ProxyRoute { + prefix: CAPI_ROUTE, + client_id: "vscode-copilot", + credential: RouteCredential::Passthrough, + upstream: Upstream::ProxyUrl, + pairing_in_path: true, + tunnels_upgrades: true, + credential_less_ping: true, }, ProxyRoute { prefix: "/vscode-copilot", client_id: "vscode-copilot", credential: RouteCredential::Gateway, upstream: Upstream::Url, + pairing_in_path: false, + tunnels_upgrades: false, + credential_less_ping: false, }, ProxyRoute { prefix: "/copilot-cli", client_id: "copilot-cli", credential: RouteCredential::Gateway, upstream: Upstream::Url, + pairing_in_path: false, + tunnels_upgrades: false, + credential_less_ping: false, }, ]; +/// Splits `rest` (the path remainder after `CAPI_ROUTE`'s prefix, always +/// starting with `/`) into the first non-empty path segment (the pairing +/// value, still percent-encoded as sent) and the remainder starting with `/` +/// (or `/` itself when nothing follows), so the pairing segment never reaches +/// `match_route`'s caller, the forwarded upstream path, logs, or plan +/// reports. `None` when the first segment is missing or empty (bare prefix, +/// `/`, or `//...`). +/// +/// Called from `forward` for `CAPI_ROUTE` only, in place of the `PAIRING_HEADER` +/// check; the segment is compared in constant time and stripped before the +/// request is forwarded. +pub(crate) fn split_path_pairing(rest: &str) -> Option<(&str, &str)> { + let without_slash = rest.strip_prefix('/')?; + let (segment, remainder) = match without_slash.find('/') { + Some(index) => (&without_slash[..index], &without_slash[index..]), + None => (without_slash, "/"), + }; + (!segment.is_empty()).then_some((segment, remainder)) +} + +/// Whether the request asks for a WebSocket upgrade: `Connection` lists the +/// `upgrade` token and `Upgrade` lists `websocket`, both case-insensitive. +fn is_websocket_upgrade(headers: &HeaderMap) -> bool { + let lists = |name: &str, token: &str| { + headers + .get_all(name) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|item| item.trim().eq_ignore_ascii_case(token)) + }; + lists(CONNECTION.as_str(), "upgrade") && lists("upgrade", "websocket") +} + +/// The tunnel an upgraded request leaves behind. The connection task awaits it +/// after hyper hands the connection over, so every tunnel runs under the +/// server's JoinSet and dropping the server closes it. +type PendingTunnel = + Arc + Send>>>>>; + +/// Relay bytes between the two upgraded connections until one side closes or +/// the gateway credential the tunnel was opened with reaches its deadline. +async fn run_tunnel( + inbound: hyper::upgrade::OnUpgrade, + outbound: hyper::upgrade::OnUpgrade, + deadline: Option, +) { + let (inbound, outbound) = match tokio::try_join!(inbound, outbound) { + Ok(streams) => streams, + Err(error) => { + tracing::debug!(%error, "LLM proxy upgrade did not complete"); + return; + } + }; + let mut inbound = TokioIo::new(inbound); + let mut outbound = TokioIo::new(outbound); + let relay = tokio::io::copy_bidirectional(&mut inbound, &mut outbound); + let expiry = async move { + let Some(deadline) = deadline else { + std::future::pending::<()>().await; + return; + }; + // The monotonic clock does not advance across a suspend, so the wall + // clock is checked as well, at most every 10 s. + loop { + let now = std::time::Instant::now(); + if now >= deadline.monotonic || now_unix() >= deadline.unix { + return; + } + tokio::time::sleep((deadline.monotonic - now).min(Duration::from_secs(10))).await; + } + }; + tokio::select! { + result = relay => { + if let Err(error) = result { + tracing::debug!(%error, "LLM proxy tunnel closed with an error"); + } + } + () = expiry => { + tracing::info!("LLM proxy tunnel closed: the gateway credential it was opened with expired"); + } + } +} + /// Runtime settings of the proxy listener. #[derive(Clone)] pub(crate) struct ProxyConfig { @@ -383,13 +543,16 @@ pub(crate) async fn serve_with_cache( let config = config.clone(); let cache = cache.clone(); connections.spawn(async move { + let pending: PendingTunnel = Arc::default(); + let slot = pending.clone(); let service = service_fn(move |request| { let client = client.clone(); let state = state.clone(); let config = config.clone(); let cache = cache.clone(); + let slot = slot.clone(); async move { - let response = match forward(request, &client, &state, &config, &cache).await { + let response = match forward(request, &client, &state, &config, &cache, &slot).await { Ok(response) => response, Err(error) => { tracing::warn!(status = %error.status, code = error.code, message = %error.message, "LLM proxy request failed"); @@ -400,10 +563,21 @@ pub(crate) async fn serve_with_cache( } }); if let Err(error) = hyper::server::conn::http1::Builder::new() - .serve_connection(TokioIo::new(stream), service).await + .serve_connection(TokioIo::new(stream), service) + .with_upgrades() + .await { tracing::debug!(%error, "LLM proxy connection closed"); } + // An upgraded connection leaves its tunnel here; awaiting it + // in this task keeps it under the server's JoinSet. + let tunnel = pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(tunnel) = tunnel { + tunnel.await; + } }); } Some(result) = connections.join_next(), if !connections.is_empty() => { @@ -425,6 +599,14 @@ pub(crate) struct ProxyError { } impl ProxyError { + fn pairing_invalid() -> Self { + Self::new( + StatusCode::FORBIDDEN, + "pairing_invalid", + "agentdesktop proxy: pairing value missing or wrong; re-apply the managed configuration", + ) + } + fn new(status: StatusCode, code: &'static str, message: impl Into) -> Self { Self { status, @@ -594,11 +776,12 @@ fn upstream_base(gateway: &LlmGatewayConfig, upstream: Upstream) -> &url::Url { } async fn forward( - request: Request, + mut request: Request, client: &ProxyClient, state: &AppState, config: &ProxyConfig, cache: &Arc, + tunnel_slot: &PendingTunnel, ) -> Result, ProxyError> { if !host_is_loopback(request.headers()) { return Err(ProxyError::new( @@ -622,33 +805,58 @@ async fn forward( "agentdesktop proxy: OPTIONS is not supported", )); } - // The pairing value is required on every route and travels only in its own - // header: Authorization keeps one meaning (the client's own token, used by the - // pass-through shapes) and the pairing is never mistaken for a credential. - // Checked before anything that reads configuration, so an unpaired caller - // learns nothing about this device and costs it no work. - let offered = request - .headers() - .get(PAIRING_HEADER) - .and_then(|value| value.to_str().ok()); - if !offered - .is_some_and(|offered| constant_time_eq(offered.as_bytes(), config.pairing.as_bytes())) - { - return Err(ProxyError::new( - StatusCode::FORBIDDEN, - "pairing_invalid", - "agentdesktop proxy: pairing value missing or wrong; re-apply the managed configuration", - )); - } - // Pure string checks on the path run before any configuration is read. + // Pure string work on the path first; no configuration is read yet. let path_and_query = request .uri() .path_and_query() - .map_or("/", |path| path.as_str()); + .map_or("/", |path| path.as_str()) + .to_owned(); let (path, query) = match path_and_query.split_once('?') { Some((path, query)) => (path, Some(query)), - None => (path_and_query, None), + None => (path_and_query.as_str(), None), + }; + // The pairing value is required on every route. It travels in its own + // header, so Authorization keeps one meaning (the client's own token on the + // pass-through shapes) and the pairing is never mistaken for a credential. + // The CAPI route's client (VS Code) cannot add a header, so there the + // pairing is the first path segment and the header is ignored. Checked + // before anything that reads configuration, so an unpaired caller learns + // nothing about this device and costs it no work. + let pairing_ok = + |offered: &str| constant_time_eq(offered.as_bytes(), config.pairing.as_bytes()); + // Matched once; the route decides pairing placement, upgrade handling, + // the ping exemption, client id, credential mode and upstream. + let matched = match_route(path); + let matched_route = matched.map(|(route, _)| route); + let capi_rest = match matched { + Some((route, rest)) if route.pairing_in_path => { + let Some((segment, remainder)) = split_path_pairing(rest) else { + return Err(ProxyError::pairing_invalid()); + }; + if !pairing_ok(segment) { + return Err(ProxyError::pairing_invalid()); + } + Some(remainder.to_owned()) + } + _ => { + let offered = request + .headers() + .get(PAIRING_HEADER) + .and_then(|value| value.to_str().ok()); + if !offered.is_some_and(pairing_ok) { + return Err(ProxyError::pairing_invalid()); + } + None + } }; + let websocket = is_websocket_upgrade(request.headers()); + if websocket && !matched_route.is_some_and(|route| route.tunnels_upgrades) { + return Err(ProxyError::new( + StatusCode::BAD_REQUEST, + "upgrade_not_supported", + "agentdesktop proxy: WebSocket upgrades are supported on the /vscode-copilot-capi route only", + )); + } if path_escapes_base(path) { return Err(ProxyError::new( StatusCode::BAD_REQUEST, @@ -656,14 +864,14 @@ async fn forward( "agentdesktop proxy: path must not contain '..' segments", )); } - if match_route(path).is_none() && resembles_route(path) { + if matched.is_none() && resembles_route(path) { // A first segment that looks like a route name but is not one (case, // suffix, doubled slash) is a mistyped client file, not a request for // the hand-configured route with its different identity and credential mode. return Err(ProxyError::new( StatusCode::NOT_FOUND, "route_unknown", - "agentdesktop proxy: unknown route; the managed prefixes are /copilot-cli, /vscode-copilot and /vscode-copilot-passthrough", + "agentdesktop proxy: unknown route; the managed prefixes are /copilot-cli, /vscode-copilot, /vscode-copilot-passthrough and /vscode-copilot-capi", )); } let effective = @@ -687,7 +895,7 @@ async fn forward( })?; // Which route, which client id, which credential mode, which upstream. - let (client_id, credential_mode, base, rest) = match match_route(path) { + let (client_id, credential_mode, base, rest) = match matched { Some((route, rest)) => { if route.upstream == Upstream::ProxyUrl && gateway.proxy_url.is_none() { // The pass-through shape needs the gateway route that restores the @@ -703,7 +911,7 @@ async fn forward( route.client_id.to_owned(), Some(route.credential), upstream_base(gateway, route.upstream), - rest, + capi_rest.as_deref().unwrap_or(rest), ) } // No prefix: the original hand-configured shape. Credential mode comes @@ -740,6 +948,14 @@ async fn forward( // both places. let client_credential = bearer_or_header(request.headers(), "x-llm-token") .filter(|value| !constant_time_eq(value.as_bytes(), config.pairing.as_bytes())); + // GitHub's health ping carries no credential and needs none: on the CAPI + // route a GET without one is forwarded as is instead of being refused. + let credential_less_ping = matched_route.is_some_and(|route| route.credential_less_ping) + && capi_rest.as_deref() == Some("/_ping") + && request.method() == Method::GET + && client_credential.is_none(); + // The inbound upgrade handle is taken before the body is consumed. + let inbound_upgrade = websocket.then(|| hyper::upgrade::on(&mut request)); // Buffer the body (capped) so the request can be retried once with a fresh // credential; the response is streamed as it arrives. @@ -772,6 +988,16 @@ async fn forward( let mut request = Request::from_parts(parts, ()); strip_hop_headers(request.headers_mut()); + if websocket { + // The upgrade is the point of the request: put back the two hop headers + // that carry it (the Sec-WebSocket-* headers are end-to-end and stay). + request + .headers_mut() + .insert(CONNECTION, HeaderValue::from_static("Upgrade")); + request + .headers_mut() + .insert("upgrade", HeaderValue::from_static("websocket")); + } for header in [ AUTHORIZATION.as_str(), "x-api-key", @@ -786,6 +1012,7 @@ async fn forward( // so the prefix is added in exactly one place. let upstream_token = match credential_mode { Some(RouteCredential::Gateway) => None, + Some(RouteCredential::Passthrough) if credential_less_ping => None, Some(RouteCredential::Passthrough) => Some(client_credential.ok_or_else(|| { ProxyError::new( StatusCode::UNAUTHORIZED, @@ -889,13 +1116,17 @@ async fn forward( // and dropped again if the gateway rejects it. let mut fetched_now = false; let mut attempt_credential: Option = None; + // The expiry of the credential used, read together with it: a later + // lookup could miss an entry that expired or was invalidated meanwhile. + let mut credential_deadline: Option = None; if authenticated { let cached = cache_key .as_ref() - .and_then(|(key, device_id)| cache.get(key, device_id)); + .and_then(|(key, device_id)| cache.lookup(key, device_id)); let credential = match cached { - Some(credential) => { + Some((credential, deadline)) => { from_cache = true; + credential_deadline = Some(deadline); credential } None => { @@ -919,7 +1150,11 @@ async fn forward( Some((key, device_id)) => { cache.get_or_fetch(key, device_id, fetch).await } - None => fetch().await.map(|fetched| (fetched.credential, false)), + None => fetch().await.map(|fetched| { + let deadline = + Deadline::from_unix(fetched.expires_at_unix_seconds); + (fetched.credential, deadline, false) + }), } }) }; @@ -943,9 +1178,10 @@ async fn forward( )) })? .map_err(ProxyError::credential)?; - let (credential, cached) = fetched; + let (credential, deadline, cached) = fetched; from_cache = cached; fetched_now = !cached; + credential_deadline = Some(deadline); credential } }; @@ -1007,6 +1243,45 @@ async fn forward( cache.invalidate_if(key, rejected); } let mut response = response; + if websocket && response.status() == StatusCode::SWITCHING_PROTOCOLS { + // The upstream accepted the upgrade. The 101 goes back with the + // hop headers stripped except the two that carry the upgrade + // (the Sec-WebSocket-* headers are end-to-end and stay), and the + // tunnel between the two upgraded connections is handed to the + // connection task. It closes when either side closes or when the + // gateway credential it was opened with expires (its own expiry, + // not the cache's reuse window). Without a gateway credential + // (no authentication configured) there is no deadline. + let deadline = credential_deadline; + let upgrade_value = response.headers().get("upgrade").cloned(); + strip_hop_headers(response.headers_mut()); + response + .headers_mut() + .insert(CONNECTION, HeaderValue::from_static("Upgrade")); + if let Some(value) = upgrade_value { + response.headers_mut().insert("upgrade", value); + } + let outbound_upgrade = hyper::upgrade::on(&mut response); + let inbound_upgrade = inbound_upgrade.ok_or_else(|| { + ProxyError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "agentdesktop_unavailable", + "agentdesktop proxy: upgrade handle missing", + ) + })?; + *tunnel_slot + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Box::pin(run_tunnel( + inbound_upgrade, + outbound_upgrade, + deadline, + ))); + return Ok(response.map(|_| { + Empty::::new() + .map_err(|never| match never {}) + .boxed() + })); + } strip_hop_headers(response.headers_mut()); return Ok(response.map(BodyExt::boxed)); } @@ -1678,6 +1953,12 @@ mod tests { RouteCredential::Passthrough, Upstream::ProxyUrl, ), + ( + "/vscode-copilot-capi/PAIRING/responses", + "vscode-copilot", + RouteCredential::Passthrough, + Upstream::ProxyUrl, + ), ]; for (path, client_id, credential, upstream) in expected { let (route, _) = match_route(path).unwrap_or_else(|| panic!("{path}")); @@ -1685,7 +1966,8 @@ mod tests { assert_eq!(route.credential, credential, "{path}"); assert_eq!(route.upstream, upstream, "{path}"); } - assert_eq!(ROUTES.len(), 3); + assert_eq!(ROUTES.len(), 4); + assert_eq!(CAPI_ROUTE, "/vscode-copilot-capi"); } #[test] @@ -2203,7 +2485,7 @@ mod tests { }) }) .await - .map(|(credential, _)| (key, credential)) + .map(|(credential, _, _)| (key, credential)) }); } let mut results = Vec::new(); @@ -2225,6 +2507,31 @@ mod tests { assert!(cache.get("a", "device-1").is_some()); } + #[test] + fn credential_cache_deadline_is_the_credential_expiry_not_the_reuse_window() { + let cache = CredentialCache::default(); + let expires = now_unix() + 3600; + cache.insert( + "k", + "device-1", + &agentdesktop_core::model::LlmGatewayCredential { + credential: "jwt".to_owned(), + expires_at_unix_seconds: expires, + }, + ); + let (credential, deadline) = cache.lookup("k", "device-1").expect("cached"); + assert_eq!(credential, "jwt"); + assert_eq!(deadline.unix, expires); + let remaining = deadline.monotonic - std::time::Instant::now(); + assert!( + remaining > CREDENTIAL_CACHE_TTL && remaining <= Duration::from_secs(3600), + "tunnel deadline must follow the credential's expiry, got {remaining:?}" + ); + // The reuse window is still the short one. + let entry = cache.entries.lock().unwrap(); + assert!(entry["k"].valid_until <= std::time::Instant::now() + CREDENTIAL_CACHE_TTL); + } + #[test] fn credential_cache_honours_device_ttl_and_invalidation() { let cache = CredentialCache::default(); @@ -2303,4 +2610,854 @@ mod tests { }; (dir, state) } + + // --- vscode-copilot-capi: path pairing, /_ping and the WebSocket tunnel. + + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpStream, + }; + + /// `request_source_state` (OIDC identity "identity", `githubOAuth: source: + /// request`) with `llmGateway.proxyUrl` pointed at `upstream`, matching + /// the CAPI route's Passthrough/ProxyUrl shape used by most tests below. + async fn capi_state(upstream: std::net::SocketAddr) -> (tempfile::TempDir, AppState) { + let (dir, mut state) = request_source_state(upstream).await; + state.config.llm_gateway.as_mut().unwrap().proxy_url = + Some(format!("http://{upstream}").parse().unwrap()); + (dir, state) + } + + /// Controller-JWT state (cacheable credential, unlike OIDC) with + /// `llmGateway.proxyUrl` pointed at `upstream`, for the credential-expiry + /// tunnel test, which needs a credential it can seed into the cache with a + /// controlled deadline. + async fn capi_controller_jwt_state( + upstream: std::net::SocketAddr, + ) -> (tempfile::TempDir, AppState) { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("identity.json"), + serde_json::json!({ + "deviceId": "device-test", + "clientCertificatePem": "", + "clientCertificateExpiresAtUnixSeconds": 4_000_000_000u64, + "oauthTokenEndpoint": "https://controller.example/token", + "oauthClientId": "device", + }) + .to_string(), + ) + .unwrap(); + let config = parse_daemon(&format!( + "llmGateway:\n url: http://{upstream}\n proxyUrl: http://{upstream}\n authentication:\n type: controllerJwt\n audience: agentgateway\n allowedClientIds: [vscode-copilot]\n" + )) + .unwrap(); + let (_, discovery) = watch::channel(Arc::new(Discovery { + agents: vec![], + model_runtimes: vec![], + })); + let state = AppState { + controller_status: None, + config, + daemon_info: DaemonInfo { + version: "test".into(), + scope: DaemonScope::User, + config_path: String::new(), + state_directory: String::new(), + inventory_interval: Duration::from_secs(60), + controller: None, + llm_proxy: None, + }, + discovery, + enrollment: EnrollmentState::new(false), + state_dir: dir.path().to_owned(), + oidc_callback_listen: None, + telemetry: None, + logout: None, + }; + (dir, state) + } + + /// Whether no connection reached `listener` within a short window: proof + /// that a refused request was never forwarded upstream. + async fn no_connection_arrives(listener: &TcpListener) -> bool { + tokio::time::timeout(Duration::from_millis(200), listener.accept()) + .await + .is_err() + } + + /// Reads a raw HTTP/1.1 request head (request line + headers, byte at a + /// time up to the blank line) from a hand-rolled test upstream: good + /// enough for a WebSocket upgrade request, which has no body. Header + /// names are folded to lowercase for case-insensitive lookup. + async fn read_http_head( + stream: &mut TcpStream, + ) -> (String, String, std::collections::HashMap) { + let mut buffer = Vec::new(); + let mut byte = [0u8; 1]; + while !buffer.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).await.unwrap(); + buffer.push(byte[0]); + } + let text = String::from_utf8(buffer).unwrap(); + let mut lines = text.split("\r\n"); + let mut parts = lines.next().unwrap().split_whitespace(); + let method = parts.next().unwrap().to_owned(); + let path = parts.next().unwrap().to_owned(); + let mut headers = std::collections::HashMap::new(); + for line in lines { + if let Some((name, value)) = line.split_once(':') { + headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_owned()); + } + } + (method, path, headers) + } + + /// RFC 6455's fixed GUID: concatenated with the client's + /// `Sec-WebSocket-Key` and SHA-1'd (then base64'd) to produce + /// `Sec-WebSocket-Accept`. + const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + + fn ws_accept(key: &str) -> String { + use base64::engine::general_purpose::STANDARD; + use sha1::{Digest, Sha1}; + STANDARD.encode(Sha1::digest(format!("{key}{WS_GUID}").as_bytes())) + } + + /// Encodes a single, unfragmented text frame (opcode `0x1`). Client + /// frames must be masked (`mask: true`, a fixed key: the test payloads + /// are not attacker-controlled), server frames must not be. + fn ws_frame(payload: &[u8], mask: bool) -> Vec { + let mut frame = vec![0x81u8]; + let mask_bit = if mask { 0x80 } else { 0x00 }; + let len = payload.len(); + if len < 126 { + frame.push(mask_bit | len as u8); + } else { + frame.push(mask_bit | 126); + frame.extend_from_slice(&(len as u16).to_be_bytes()); + } + if mask { + let key = [0x11u8, 0x22, 0x33, 0x44]; + frame.extend_from_slice(&key); + frame.extend(payload.iter().enumerate().map(|(i, b)| b ^ key[i % 4])); + } else { + frame.extend_from_slice(payload); + } + frame + } + + /// Reads exactly one, unfragmented, non-huge frame and returns its opcode + /// and unmasked payload. + async fn ws_read_frame(stream: &mut TcpStream) -> (u8, Vec) { + let mut header = [0u8; 2]; + stream.read_exact(&mut header).await.unwrap(); + let opcode = header[0] & 0x0f; + let masked = header[1] & 0x80 != 0; + let mut len = (header[1] & 0x7f) as usize; + if len == 126 { + let mut extended = [0u8; 2]; + stream.read_exact(&mut extended).await.unwrap(); + len = u16::from_be_bytes(extended) as usize; + } + let mask_key = if masked { + let mut key = [0u8; 4]; + stream.read_exact(&mut key).await.unwrap(); + Some(key) + } else { + None + }; + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload).await.unwrap(); + if let Some(key) = mask_key { + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= key[index % 4]; + } + } + (opcode, payload) + } + + /// A fixed, valid `Sec-WebSocket-Key` (the RFC 6455 example key): its + /// `Sec-WebSocket-Accept` is computed by `ws_accept`, never hand-checked + /// against a literal. + const SEC_WEBSOCKET_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + + fn upgrade_request(uri: String) -> Request> { + Request::builder() + .method(Method::GET) + .uri(uri) + .header(CONNECTION, "Upgrade") + .header("upgrade", "websocket") + .header("sec-websocket-version", "13") + .header("sec-websocket-key", SEC_WEBSOCKET_KEY) + .body(Full::new(Bytes::new())) + .unwrap() + } + + #[tokio::test] + async fn capi_route_accepts_and_strips_path_pairing_never_forwarding_it() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (socket, _) = upstream.accept().await.unwrap(); + hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| async move { + // The pairing segment must never reach the upstream path. + assert_eq!(request.uri(), "/chat/completions"); + assert!(!request.headers().contains_key(PAIRING_HEADER)); + Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"ok")))) + }), + ) + .await + .unwrap(); + }); + let (dir, state) = capi_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}{CAPI_ROUTE}/{PAIRING}/chat/completions" + )) + .header(AUTHORIZATION, "Bearer tid=from-client") + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "path pairing must be accepted on {CAPI_ROUTE}" + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn capi_route_rejects_missing_empty_or_wrong_pairing_segment_even_with_a_valid_header() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + // Dropped rather than accepted from: today none of these four + // paths are rejected before forwarding (path-pairing extraction + // does not exist yet), so leaving the listener up would have + // every case hang until the gateway timeout instead of failing + // fast with a clean (if wrong) status. + drop(upstream); + let (dir, state) = capi_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + for path in [ + CAPI_ROUTE.to_owned(), // no segment at all + format!("{CAPI_ROUTE}/"), // no segment at all + format!("{CAPI_ROUTE}//chat/completions"), // empty segment + format!("{CAPI_ROUTE}/wrong-pairing/chat/completions"), // wrong segment + ] { + let request = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}{path}")) + // A valid header must not substitute for the path segment + // on this route: the header is ignored here. + .header(PAIRING_HEADER, PAIRING) + .header(AUTHORIZATION, "Bearer tid=from-client") + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + error_code(client.request(request).await.unwrap()).await, + (StatusCode::FORBIDDEN, "pairing_invalid".to_owned()), + "{path}" + ); + } + proxy.abort(); + let _ = proxy.await; + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn capi_route_rejects_a_double_dot_segment_after_the_pairing() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let (dir, state) = capi_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}{CAPI_ROUTE}/{PAIRING}/../admin" + )) + .header(AUTHORIZATION, "Bearer tid=from-client") + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + error_code(client.request(request).await.unwrap()).await, + (StatusCode::BAD_REQUEST, "path_invalid".to_owned()) + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn capi_route_moves_the_client_token_to_x_llm_token() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (socket, _) = upstream.accept().await.unwrap(); + hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| async move { + assert_eq!(request.uri(), "/chat/completions"); + assert_eq!(request.headers()["x-llm-token"], "tid=from-client"); + assert_eq!(request.headers()[AUTHORIZATION], "Bearer identity"); + Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"ok")))) + }), + ) + .await + .unwrap(); + }); + let (dir, state) = capi_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}{CAPI_ROUTE}/{PAIRING}/chat/completions" + )) + .header(AUTHORIZATION, "Bearer tid=from-client") + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + proxy.abort(); + let _ = proxy.await; + drop(client); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn capi_route_ping_without_credential_is_forwarded_without_x_llm_token() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (socket, _) = upstream.accept().await.unwrap(); + hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| async move { + assert_eq!(request.uri(), "/_ping"); + assert!(!request.headers().contains_key("x-llm-token")); + Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"ok")))) + }), + ) + .await + .unwrap(); + }); + let (dir, state) = capi_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let request = Request::builder() + .method(Method::GET) + .uri(format!( + "http://{proxy_address}{CAPI_ROUTE}/{PAIRING}/_ping" + )) + .body(Full::new(Bytes::new())) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + proxy.abort(); + let _ = proxy.await; + drop(client); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn capi_route_ping_post_without_credential_is_401() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let (dir, state) = capi_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}{CAPI_ROUTE}/{PAIRING}/_ping" + )) + .body(Full::new(Bytes::new())) + .unwrap(); + assert_eq!( + error_code(client.request(request).await.unwrap()).await, + ( + StatusCode::UNAUTHORIZED, + "client_credential_missing".to_owned() + ) + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn capi_route_ping_without_pairing_is_403() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let (dir, state) = capi_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let request = Request::builder() + .method(Method::GET) + .uri(format!("http://{proxy_address}{CAPI_ROUTE}/wrong/_ping")) + .body(Full::new(Bytes::new())) + .unwrap(); + assert_eq!( + error_code(client.request(request).await.unwrap()).await, + (StatusCode::FORBIDDEN, "pairing_invalid".to_owned()) + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn capi_route_upgrade_without_pairing_is_403_and_not_forwarded() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let (dir, state) = capi_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let request = upgrade_request(format!( + "http://{proxy_address}{CAPI_ROUTE}/wrong/responses" + )); + assert_eq!( + error_code(client.request(request).await.unwrap()).await, + (StatusCode::FORBIDDEN, "pairing_invalid".to_owned()) + ); + assert!( + no_connection_arrives(&upstream).await, + "an upgrade refused for its pairing must not be forwarded upstream" + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn upgrade_on_another_route_is_400_but_other_upgrade_tokens_still_forward() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + loop { + let (socket, _) = match upstream.accept().await { + Ok(accepted) => accepted, + Err(_) => break, + }; + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(|_request: Request| async move { + Ok::<_, Infallible>(Response::new(Full::new( + Bytes::from_static(b"ok"), + ))) + }), + ) + .await; + }); + } + }); + let (dir, state) = request_source_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + // Only /vscode-copilot-capi tunnels an upgrade; on any other + // route Upgrade: websocket is refused outright. + let websocket_upgrade = Request::builder() + .method(Method::GET) + .uri(format!("http://{proxy_address}/copilot-cli/v1/models")) + .header(PAIRING_HEADER, PAIRING) + .header(AUTHORIZATION, "Bearer tid=from-client") + .header(CONNECTION, "upgrade") + .header("upgrade", "websocket") + .body(Full::new(Bytes::new())) + .unwrap(); + // Checked as a plain status first (not via the shared + // `error_code` helper, which assumes a JSON error body): today + // this request is not refused at all and is forwarded as an + // ordinary request, so the response has no content-type header + // for `error_code` to read. + let response = client.request(websocket_upgrade).await.unwrap(); + assert_eq!( + response.status(), + StatusCode::BAD_REQUEST, + "an Upgrade: websocket request on a non-CAPI route must be refused, not forwarded" + ); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["error"]["code"], "upgrade_not_supported"); + // Only the websocket token is refused; another Upgrade token on + // the same route is stripped (as a hop-by-hop header) and + // forwarded as an ordinary request, as before this feature. + let other_upgrade = Request::builder() + .method(Method::GET) + .uri(format!("http://{proxy_address}/copilot-cli/v1/models")) + .header(PAIRING_HEADER, PAIRING) + .header(AUTHORIZATION, "Bearer tid=from-client") + .header(CONNECTION, "upgrade") + .header("upgrade", "h2c") + .body(Full::new(Bytes::new())) + .unwrap(); + let response = client.request(other_upgrade).await.unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "a non-websocket Upgrade token must still be forwarded" + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn capi_route_non_101_upstream_response_passes_through() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (socket, _) = upstream.accept().await.unwrap(); + hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(|_request: Request| async move { + Ok::<_, Infallible>( + Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Full::new(Bytes::from_static(b"nope"))) + .unwrap(), + ) + }), + ) + .await + .unwrap(); + }); + let (dir, state) = capi_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let mut request = upgrade_request(format!( + "http://{proxy_address}{CAPI_ROUTE}/{PAIRING}/responses" + )); + request + .headers_mut() + .insert(AUTHORIZATION, "Bearer tid=from-client".parse().unwrap()); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, Bytes::from_static(b"nope")); + proxy.abort(); + let _ = proxy.await; + drop(client); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn capi_route_websocket_tunnel_relays_the_101_and_one_frame_each_way_with_gateway_credentials() + { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (mut socket, _) = upstream.accept().await.unwrap(); + let (method, path, headers) = read_http_head(&mut socket).await; + assert_eq!(method, "GET"); + assert_eq!(path, "/responses"); + assert_eq!( + headers.get("authorization").map(String::as_str), + Some("Bearer identity"), + "the gateway identity must reach the upstream on the handshake" + ); + assert_eq!( + headers.get("x-llm-token").map(String::as_str), + Some("tid=from-client"), + "the client's own token must reach the upstream on the handshake" + ); + let accept = + ws_accept(headers.get("sec-websocket-key").map(String::as_str).unwrap_or("")); + socket + .write_all( + format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + let (opcode, payload) = ws_read_frame(&mut socket).await; + assert_eq!(opcode, 0x1); + assert_eq!(payload, b"hello from vs code"); + socket + .write_all(&ws_frame(b"hello from github", false)) + .await + .unwrap(); + }); + let (dir, state) = capi_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let mut request = upgrade_request(format!( + "http://{proxy_address}{CAPI_ROUTE}/{PAIRING}/responses" + )); + request + .headers_mut() + .insert(AUTHORIZATION, "Bearer tid=from-client".parse().unwrap()); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + assert_eq!(response.headers()["upgrade"], "websocket"); + assert!( + response.headers()["connection"] + .to_str() + .unwrap() + .eq_ignore_ascii_case("upgrade") + ); + assert_eq!( + response.headers()["sec-websocket-accept"], + ws_accept(SEC_WEBSOCKET_KEY) + ); + let upgraded = hyper::upgrade::on(response).await.unwrap(); + let mut io = TokioIo::new(upgraded); + io.write_all(&ws_frame(b"hello from vs code", true)) + .await + .unwrap(); + let mut header = [0u8; 2]; + io.read_exact(&mut header).await.unwrap(); + assert_eq!(header[0] & 0x0f, 0x1, "expected a text frame back"); + let len = (header[1] & 0x7f) as usize; + assert!(header[1] & 0x80 == 0, "a server frame must not be masked"); + let mut payload = vec![0u8; len]; + io.read_exact(&mut payload).await.unwrap(); + assert_eq!(payload, b"hello from github"); + proxy.abort(); + let _ = proxy.await; + drop(client); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn dropping_the_proxy_closes_an_open_capi_tunnel() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (mut socket, _) = upstream.accept().await.unwrap(); + let (_, _, headers) = read_http_head(&mut socket).await; + let accept = + ws_accept(headers.get("sec-websocket-key").map(String::as_str).unwrap_or("")); + socket + .write_all( + format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + // Held open until the client side sees it close. + let mut buffer = [0u8; 1]; + let _ = socket.read(&mut buffer).await; + }); + let (dir, state) = capi_state(address).await; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve(listener, state, default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let mut request = upgrade_request(format!( + "http://{proxy_address}{CAPI_ROUTE}/{PAIRING}/responses" + )); + request + .headers_mut() + .insert(AUTHORIZATION, "Bearer tid=from-client".parse().unwrap()); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + let upgraded = hyper::upgrade::on(response).await.unwrap(); + let mut io = TokioIo::new(upgraded); + proxy.abort(); + let _ = proxy.await; + // The tunnel task is owned by the connection's JoinSet: dropping + // the server must close it, and the client sees EOF. + let mut buffer = [0u8; 1]; + let read = tokio::time::timeout(Duration::from_secs(5), io.read(&mut buffer)) + .await + .expect("dropping the proxy must close the open tunnel promptly") + .unwrap(); + assert_eq!(read, 0, "expected EOF once the tunnel closes"); + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn capi_tunnel_closes_when_the_cached_credential_expires() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = upstream.local_addr().unwrap(); + let upstream_task = tokio::spawn(async move { + let (mut socket, _) = upstream.accept().await.unwrap(); + let (_, _, headers) = read_http_head(&mut socket).await; + let accept = + ws_accept(headers.get("sec-websocket-key").map(String::as_str).unwrap_or("")); + socket + .write_all( + format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + let mut buffer = [0u8; 1]; + let _ = socket.read(&mut buffer).await; + }); + let (dir, state) = capi_controller_jwt_state(address).await; + let cache = Arc::new(CredentialCache::default()); + let key = format!("vscode-copilot\u{0}http://{address}/\u{0}agentgateway"); + // Inserted directly (bypassing `insert`'s expiry-margin floor, + // which would otherwise refuse to cache anything this close to + // expiry) so the tunnel has time to open before the deadline. + cache.entries.lock().unwrap().insert( + key, + CachedCredential { + credential: "short-lived".to_owned(), + device_id: "device-test".to_owned(), + valid_until: std::time::Instant::now() + Duration::from_millis(300), + valid_until_unix: now_unix() + 60, + expires: std::time::Instant::now() + Duration::from_millis(300), + expires_unix: now_unix() + 60, + }, + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(serve_with_cache( + listener, + state, + default_config(), + cache, + )); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let mut request = upgrade_request(format!( + "http://{proxy_address}{CAPI_ROUTE}/{PAIRING}/responses" + )); + request + .headers_mut() + .insert(AUTHORIZATION, "Bearer tid=from-client".parse().unwrap()); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + let upgraded = hyper::upgrade::on(response).await.unwrap(); + let mut io = TokioIo::new(upgraded); + // Once the cached credential's deadline passes, the tunnel closes + // on its own, with the proxy still running. + let mut buffer = [0u8; 1]; + let read = tokio::time::timeout(Duration::from_secs(5), io.read(&mut buffer)) + .await + .expect("the tunnel must close once its credential expires") + .unwrap(); + assert_eq!(read, 0, "expected EOF once the tunnel closes"); + proxy.abort(); + let _ = proxy.await; + upstream_task.abort(); + drop(client); + drop(dir); + }) + .await + .unwrap(); + } } diff --git a/crates/agent/src/provider/README.md b/crates/agent/src/provider/README.md index e2417f50..7049dde6 100644 --- a/crates/agent/src/provider/README.md +++ b/crates/agent/src/provider/README.md @@ -2,6 +2,12 @@ Features currently implemented by Agentdesktop. +VS Code managed configuration (Copilot Chat custom models in the user's +`chatLanguageModels.json`, or the Copilot Chat API override in the user's +`settings.json` under `copilotChat: githubModels`) and GitHub Copilot CLI +managed configuration (the user's `providers.json`) require user mode; all of +these files point at the daemon's loopback LLM proxy. The Copilot CLI's MCP and skills inventory under +`~/.copilot` is read by the VS Code adapter. Grok Build managed configuration requires system mode. Linux and macOS use `/etc/grok/managed_config.toml`; Windows uses the system drive's `\etc\grok\managed_config.toml`. `--user` rejects `programs.grok` because @@ -10,23 +16,25 @@ file. ✅ Implemented · ◯ Not implemented · — Not applicable -| Feature | [Claude Code](claude_code/) | [Claude Desktop](claude_desktop/) | [Codex](codex/) | [OpenCode](opencode/) | [VS Code](vscode/) | [Ollama](ollama/) | [Cursor](cursor/) | [Grok Build](grok/) | -| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| Installation and version discovery | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | -| Local model discovery | — | — | — | — | — | ✅ | — | — | -| MCP server discovery | ✅ | ✅ | ✅ | ◯ | ✅ | — | ✅ | ✅ | -| Skill discovery | ✅ | ◯ | ✅ | ◯ | ✅ | — | ✅ | ✅ | -| Managed configuration | ✅ | ✅ | ✅ | ✅ | ◯ | ◯ | ◯ | ✅ | -| LLM gateway routing | ✅ | ✅ | ✅ | ✅ | ◯ | — | ◯ | ✅ | -| Gateway credentials | ✅ | ✅ | ✅ | ✅ | ◯ | — | ◯ | ✅ | -| Sandbox configuration | ✅ | ◯ | ✅ | ◯ | ◯ | — | ◯ | ◯ | -| Tool-use telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | -| Session-start telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | +| Feature | [Claude Code](claude_code/) | [Claude Desktop](claude_desktop/) | [Codex](codex/) | [OpenCode](opencode/) | [VS Code](vscode/) | [Ollama](ollama/) | [Cursor](cursor/) | [Grok Build](grok/) | [GitHub Copilot CLI](copilot/) | +| --- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| Installation and version discovery | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | +| Local model discovery | — | — | — | — | — | ✅ | — | — | — | +| MCP server discovery | ✅ | ✅ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | +| Skill discovery | ✅ | ◯ | ✅ | ◯ | ✅ | — | ✅ | ✅ | — | +| Managed configuration | ✅ | ✅ | ✅ | ✅ | ✅ | ◯ | ◯ | ✅ | ✅ | +| LLM gateway routing | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | +| Gateway credentials | ✅ | ✅ | ✅ | ✅ | ✅ | — | ◯ | ✅ | ✅ | +| Sandbox configuration | ✅ | ◯ | ✅ | ◯ | ◯ | — | ◯ | ◯ | ◯ | +| Tool-use telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | +| Session-start telemetry | ✅ | ◯ | ◯ | ◯ | ◯ | — | ◯ | ◯ | ◯ | - ◯ describes a gap in Agentdesktop; it does not mean the upstream provider cannot support the feature. - Managed configuration includes dry runs and removal of Agentdesktop-owned - settings. Claude Code also supports merging into user settings; Claude Desktop + settings (no dry run for the GitHub Copilot CLI providers file and the VS + Code chat language models and settings files: their loopback proxy cannot + run in one-shot mode). Claude Code also supports merging into user settings; Claude Desktop requires system-managed settings. - Discovery is best effort; versions may be unavailable. Ollama discovers models through its running local API. Grok Build versions require its optional diff --git a/crates/agent/src/provider/copilot/discovery.rs b/crates/agent/src/provider/copilot/discovery.rs new file mode 100644 index 00000000..7e6a4e26 --- /dev/null +++ b/crates/agent/src/provider/copilot/discovery.rs @@ -0,0 +1,77 @@ +//! GitHub Copilot CLI discovery: the `copilot` executable on `PATH` (or in the +//! usual npm global locations) and its version from the npm package manifest +//! of `@github/copilot`. Nothing is executed: discovery also runs in a +//! system daemon, and a file found in a user's home must never run as root. +//! The MCP and skills inventory under `~/.copilot` stays with the VS Code +//! adapter, which already reads it. + +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, +}; + +use agentdesktop_core::model::Agent; + +use super::Copilot; +use crate::provider::metadata; + +const PACKAGE: &str = "@github/copilot"; + +pub(super) fn discover() -> Option { + let executable = metadata::find_executable("copilot", executable_candidates())?; + Some(Agent { + version: npm_version(&executable), + executable, + kind: Copilot::ID.to_owned(), + mcp_servers: Vec::new(), + skills: Vec::new(), + }) +} + +fn executable_candidates() -> Vec { + let mut candidates = BTreeSet::new(); + for home in metadata::user_home_dirs() { + candidates.insert(home.join(".npm-global/bin/copilot")); + candidates.insert(home.join(".local/bin/copilot")); + #[cfg(windows)] + candidates.insert(home.join("AppData/Roaming/npm/copilot.cmd")); + } + #[cfg(target_os = "macos")] + candidates.extend([ + PathBuf::from("/opt/homebrew/bin/copilot"), + PathBuf::from("/usr/local/bin/copilot"), + ]); + candidates.into_iter().collect() +} + +/// The version from the `@github/copilot` manifest next to the launcher: npm +/// links `/bin/copilot` into `/lib/node_modules/@github/copilot`, +/// so the manifest sits in an ancestor of the resolved launcher or in a +/// `node_modules` directory next to one. A standalone binary has no manifest +/// and reports no version. +pub(super) fn npm_version(executable: &Path) -> Option { + manifest_candidates(executable) + .into_iter() + .find_map(|path| metadata::json_package_version(&path, PACKAGE)) +} + +pub(super) fn manifest_candidates(executable: &Path) -> Vec { + let mut candidates = BTreeSet::new(); + for executable in [ + Some(executable.to_path_buf()), + executable.canonicalize().ok(), + ] + .into_iter() + .flatten() + { + let Some(parent) = executable.parent() else { + continue; + }; + for directory in parent.ancestors().take(4) { + candidates.insert(directory.join("package.json")); + candidates.insert(directory.join(format!("node_modules/{PACKAGE}/package.json"))); + candidates.insert(directory.join(format!("lib/node_modules/{PACKAGE}/package.json"))); + } + } + candidates.into_iter().collect() +} diff --git a/crates/agent/src/provider/copilot/integration_tests.rs b/crates/agent/src/provider/copilot/integration_tests.rs new file mode 100644 index 00000000..dc858808 --- /dev/null +++ b/crates/agent/src/provider/copilot/integration_tests.rs @@ -0,0 +1,197 @@ +use std::time::Duration; + +use anyhow::ensure; +use serde_json::{Value, json}; +use tracing::info; + +use crate::common::{Container, Gateway}; + +const CONFIG: &str = "/tmp/agentdesktop.yaml"; +const PROVIDERS: &str = "/home/tester/.copilot/providers.json"; +const SIDECAR: &str = "/home/tester/.copilot/.providers.json.agentdesktop"; +const PAIRING: &str = "/home/tester/.local/state/agentdesktop/llm-proxy-pairing"; +const SOCKET: &str = "/run/user/1000/agentdesktop.sock"; +const LISTEN: &str = "127.0.0.1:18095"; +const VERSION: &str = "1.0.88"; + +/// The daemon runs as the user (`--user`, started through `runuser` because +/// the program manages a file in the user's home, unlike the system-mode +/// scenarios of the other providers), writes the user's providers file +/// pointed at its loopback proxy, and a client using that file reaches the +/// gateway through the proxy. Removing the program takes the entries out again. +#[tokio::test] +async fn user_mode_providers_lifecycle() -> anyhow::Result<()> { + Container::run( + "copilot", + "crates/agent/src/provider/copilot/testdata/Dockerfile", + async |container| { + let gateway = Gateway::start().await?; + let user_entry = json!({ + "providers": [{"name": "my-own", "type": "openai", "baseUrl": "https://example.invalid/v1", "apiKey": "sk-user"}], + "models": [{"provider": "my-own", "id": "gpt-mine"}], + }); + container + .write(PROVIDERS, &serde_json::to_string_pretty(&user_entry)?) + .await?; + container + .exec(&["chown", "-R", "tester:tester", "/home/tester/.copilot"]) + .await?; + container.exec(&["chmod", "600", PROVIDERS]).await?; + let config = |with_program: bool| { + let mut document = json!({ + "daemon": { "user": true, "llmProxy": { "listen": LISTEN } }, + "llmGateway": { "url": gateway.url }, + "programs": {}, + }); + if with_program { + document["programs"]["copilot"] = + json!({ "models": { "gpt-4.1": { "wireModel": "gpt-4.1-mini" } } }); + } + serde_json::to_string_pretty(&document).unwrap() + }; + container.write(CONFIG, &config(true)).await?; + container.exec(&["chown", "tester:tester", CONFIG]).await?; + + // The proxy cannot run in one-shot mode, so a dry run with the + // listener configured is refused rather than previewed. + info!("Checking that dry run refuses the listener"); + let refusal = "daemon.llmProxy.listen cannot be combined with --once or --dry-run"; + let refused = container + .exec_as( + "tester", + &["agentdesktop", "daemon", "--config", CONFIG, "--dry-run"], + Duration::from_secs(15), + ) + .await + .map(|output| output.contains(refusal)) + .unwrap_or_else(|error| format!("{error:#}").contains(refusal)); + ensure!(refused, "dry run with llmProxy.listen must be refused with the documented message"); + container.exec(&["test", "!", "-e", SIDECAR]).await?; + + container + .start_process( + "daemon", + &[ + "runuser", + "-u", + "tester", + "--", + "agentdesktop", + "daemon", + "--config", + CONFIG, + ], + ) + .await?; + container + .wait_ready(&["agentdesktop", "--socket", SOCKET, "status"]) + .await?; + + info!("Checking discovery and the written providers file"); + let discovered = container + .exec(&["agentdesktop", "--socket", SOCKET, "discover"]) + .await?; + ensure!( + discovered.contains(&format!("copilot\t{VERSION}\t")), + "Copilot CLI discovery failed: {discovered}" + ); + let pairing = container.exec(&["cat", PAIRING]).await?; + let pairing = pairing.trim(); + ensure!(pairing.len() >= 32, "pairing not created: {pairing:?}"); + let written: Value = serde_json::from_str(&container.exec(&["cat", PROVIDERS]).await?)?; + let providers = written["providers"].as_array().cloned().unwrap_or_default(); + ensure!( + providers.iter().any(|provider| provider["name"] == "my-own"), + "user provider lost: {written}" + ); + let ours = providers + .iter() + .find(|provider| provider["name"] == "agentdesktop") + .cloned() + .unwrap_or_default(); + ensure!( + ours["baseUrl"] == format!("http://{LISTEN}/copilot-cli/v1"), + "unexpected managed provider: {written}" + ); + ensure!( + ours["headers"]["x-agentdesktop-pairing"] == pairing, + "pairing header does not match the daemon's pairing: {written}" + ); + ensure!(ours.get("apiKey").is_none(), "no apiKey may be written"); + let mode = container.exec(&["stat", "-c", "%a", PROVIDERS]).await?; + ensure!(mode.trim() == "600", "providers file mode {mode}"); + container.exec(&["test", "-e", SIDECAR]).await?; + + info!("Checking that a client using the file reaches the gateway through the proxy"); + let status = container + .exec_as( + "tester", + &[ + "curl", + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-H", + &format!("x-agentdesktop-pairing: {pairing}"), + "-H", + "content-type: application/json", + "-X", + "POST", + &format!("http://{LISTEN}/copilot-cli/v1/chat/completions"), + "-d", + "{\"model\":\"gpt-4.1-mini\",\"messages\":[]}", + ], + Duration::from_secs(15), + ) + .await?; + // The stub gateway wants its own token, which no gateway identity is + // configured to supply here; the request still has to arrive. + ensure!( + gateway + .requests() + .iter() + .any(|request| request["path"] == "/v1/chat/completions"), + "no request reached the gateway (client saw {status}): {:?}", + gateway.requests() + ); + + info!("Removing the program"); + container.write(CONFIG, &config(false)).await?; + container.stop_process("daemon").await?; + container + .start_process( + "daemon", + &[ + "runuser", + "-u", + "tester", + "--", + "agentdesktop", + "daemon", + "--config", + CONFIG, + ], + ) + .await?; + container + .wait_ready(&["agentdesktop", "--socket", SOCKET, "status"]) + .await?; + let remaining: Value = serde_json::from_str(&container.exec(&["cat", PROVIDERS]).await?)?; + let providers = remaining["providers"].as_array().cloned().unwrap_or_default(); + ensure!( + providers.len() == 1 && providers[0]["name"] == "my-own", + "managed entries not removed: {remaining}" + ); + let mode = container.exec(&["stat", "-c", "%a", PROVIDERS]).await?; + ensure!( + mode.trim() == "600", + "removal must keep the user's file owner-only, got {mode}" + ); + container.exec(&["test", "!", "-e", SIDECAR]).await?; + Ok(()) + }, + ) + .await +} diff --git a/crates/agent/src/provider/copilot/mod.rs b/crates/agent/src/provider/copilot/mod.rs new file mode 100644 index 00000000..1b31f2a7 --- /dev/null +++ b/crates/agent/src/provider/copilot/mod.rs @@ -0,0 +1,81 @@ +use std::path::PathBuf; + +use agentdesktop_core::config::DaemonConfig; +use agentdesktop_core::model::Discovery; + +use super::{Provider, ReconcileContext}; +use crate::reconcile::ReconcilePlan; + +pub(super) mod discovery; +pub(crate) mod reconcile; + +#[cfg(test)] +mod tests; + +/// Display name used in reconcile-plan reporting (dry-run output, conflict +/// and error messages). +pub(super) const DISPLAY_NAME: &str = "GitHub Copilot CLI"; +/// Provider name for the OpenAI-shaped route (`/copilot-cli/v1`). +pub(super) const PROVIDER_OPENAI: &str = agentdesktop_core::config::CopilotConfig::PROVIDER_OPENAI; +/// Provider name for the Anthropic-shaped route (`/copilot-cli`). +pub(super) const PROVIDER_ANTHROPIC: &str = + agentdesktop_core::config::CopilotConfig::PROVIDER_ANTHROPIC; + +/// GitHub Copilot CLI: points the user's `providers.json` at the loopback LLM +/// proxy's `/copilot-cli` route. User mode only; `providers_path` is `None` +/// for a system daemon, which has no user file to manage. +pub struct Copilot { + pub providers_path: Option, +} + +impl Copilot { + pub const ID: &'static str = "copilot"; +} + +/// The user's `providers.json` from the daemon's environment +/// (`COPILOT_PROVIDERS_CONFIG`, `COPILOT_HOME`, then the home directory). +pub fn default_copilot_providers_path() -> anyhow::Result { + reconcile::providers_path(&|name| std::env::var_os(name)) +} + +#[async_trait::async_trait] +impl Provider for Copilot { + async fn discover(&self) -> Discovery { + Discovery { + agents: discovery::discover().into_iter().collect(), + model_runtimes: Vec::new(), + } + } + + fn plan(&self, ctx: &ReconcileContext, config: &DaemonConfig) -> anyhow::Result { + let configured = config.programs.copilot.as_ref().map(|program| { + let gateway = config + .llm_gateway + .as_ref() + .filter(|_| program.use_llm_gateway); + (program, gateway) + }); + let proxy = ctx + .llm_proxy + .as_ref() + .map(|proxy| (proxy.address, &*proxy.pairing)); + let plan = ReconcilePlan::default(); + // The inverse of Grok's check: the file lives in the user's home, so a + // system daemon has no providers path and rejects the program before + // any provider writes. + let Some(path) = self + .providers_path + .as_deref() + .filter(|_| ctx.merge_user_settings) + else { + if configured.is_some() { + anyhow::bail!( + "{DISPLAY_NAME} reads its providers from the user's own Copilot directory; remove programs.copilot or run agentdesktop with --user (daemon.user: true) so it can manage that file" + ); + } + return Ok(plan); + }; + reconcile::plan(path, proxy, configured, &plan)?; + Ok(plan) + } +} diff --git a/crates/agent/src/provider/copilot/reconcile.rs b/crates/agent/src/provider/copilot/reconcile.rs new file mode 100644 index 00000000..f39730d9 --- /dev/null +++ b/crates/agent/src/provider/copilot/reconcile.rs @@ -0,0 +1,297 @@ +use super::{DISPLAY_NAME, PROVIDER_ANTHROPIC, PROVIDER_OPENAI}; + +use std::{ffi::OsString, net::SocketAddr, path::Path, path::PathBuf}; + +use agentdesktop_core::config::{CopilotConfig, LlmGatewayConfig}; +use anyhow::Context; +use serde_json::{Value, json}; +use tracing::warn; + +use crate::provider::json_merge; +use crate::reconcile::ReconcilePlan; + +/// What the plan report calls the managed part of the file. +const DESCRIPTION: &str = "providers"; +/// The file carries the pairing value, so a file we write is owner-only. +const FILE_MODE: u32 = 0o600; + +/// Resolves the `providers.json` path: `COPILOT_PROVIDERS_CONFIG` if set, else +/// `$COPILOT_HOME/providers.json` if `COPILOT_HOME` is set, else +/// `/.copilot/providers.json` with home read from `HOME`, then +/// `USERPROFILE`. Empty values count as unset. Errors when no home resolves. +pub(crate) fn providers_path(env: &dyn Fn(&str) -> Option) -> anyhow::Result { + let get = |name: &str| env(name).filter(|value| !value.is_empty()); + if let Some(explicit) = get("COPILOT_PROVIDERS_CONFIG") { + return Ok(PathBuf::from(explicit)); + } + if let Some(copilot_home) = get("COPILOT_HOME") { + return Ok(PathBuf::from(copilot_home).join("providers.json")); + } + let home = get("HOME") + .or_else(|| get("USERPROFILE")) + .context("the GitHub Copilot CLI providers file needs HOME, USERPROFILE or COPILOT_HOME")?; + Ok(PathBuf::from(home).join(".copilot").join("providers.json")) +} + +/// The JSON object merged into `providers.json`: two provider entries pointed +/// at the loopback proxy's `/copilot-cli` route with the pairing header, plus +/// one model entry per configured model. No `apiKey` is written: the proxy +/// adds the gateway credential itself. +pub(super) fn managed_document( + config: &CopilotConfig, + listen: SocketAddr, + pairing: &str, +) -> anyhow::Result { + let headers = json!({ crate::llm_proxy::PAIRING_HEADER: pairing }); + Ok(json!({ + "providers": [ + { + "name": PROVIDER_OPENAI, + "type": "openai", + "baseUrl": format!("http://{listen}/copilot-cli/v1"), + "headers": headers, + }, + { + "name": PROVIDER_ANTHROPIC, + "type": "anthropic", + "baseUrl": format!("http://{listen}/copilot-cli"), + "headers": headers, + }, + ], + "models": config.model_documents(), + })) +} + +/// The two arrays agentdesktop manages by key: providers by `name`, models by +/// `provider` and `id` (the CLI addresses a model as `/`). +const KEYED: &[json_merge::KeyedArray] = &[ + json_merge::KeyedArray { + field: "providers", + keys: &["name"], + }, + json_merge::KeyedArray { + field: "models", + keys: &["provider", "id"], + }, +]; + +fn options() -> json_merge::MergeOptions { + json_merge::MergeOptions { + mode: FILE_MODE, + keyed_arrays: KEYED, + // The file holds the pairing and possibly the user's own API keys. + redact_diff: true, + } +} + +/// Plans the create/update/remove/conflict for `providers.json`. +/// +/// With the program absent, the gateway off for it (`useLlmGateway: false` or +/// no top-level `llmGateway`), or no loopback proxy, the managed entries are +/// removed and the user's own entries stay. Otherwise the managed document is +/// merged in. Entries under the managed keys (provider names `agentdesktop` +/// and `agentdesktop-anthropic`, models under those providers) belong to +/// agentdesktop once it has written the file; before that, a user entry under +/// one of those keys is a conflict, unless it carries a pairing header (then +/// it was written by an agentdesktop daemon whose sidecar is gone, and it is +/// replaced). +pub(super) fn plan( + path: &Path, + proxy: Option<(SocketAddr, &str)>, + configured: Option<(&CopilotConfig, Option<&LlmGatewayConfig>)>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + let state_path = json_merge::state_path(path); + let pairing = proxy.map(|(_, pairing)| pairing); + let Some((config, Some(_gateway))) = configured else { + remove(path, &state_path, pairing, plan)?; + return Ok(()); + }; + let Some((listen, pairing)) = proxy else { + warn!( + path = %path.display(), + "programs.copilot is configured but the local LLM proxy is not available, so the Copilot CLI is not pointed at the gateway; the reason is llmProxy.error in daemon-info (or daemon.llmProxy.listen is unset); removing the managed Copilot CLI providers" + ); + remove(path, &state_path, None, plan)?; + return Ok(()); + }; + let managed = managed_document(config, listen, pairing)?; + if let Some(reason) = foreign_managed_entry(path, &state_path, &managed, plan)? { + warn!(path = %path.display(), reason, "refusing to change the Copilot CLI providers file"); + plan.record( + DISPLAY_NAME, + &format!("{DESCRIPTION} ({reason})"), + "conflict", + path, + ); + return Ok(()); + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + && !parent.exists() + { + // A Copilot directory we create holds the pairing: owner-only, like the + // file. An existing directory keeps the user's mode. + plan.ensure_private_dir(parent); + } + json_merge::plan_merge_with( + path, + &state_path, + managed, + false, + DESCRIPTION, + DISPLAY_NAME, + options(), + plan, + ) +} + +/// Managed entries are removed through the sidecar when there is one, and by +/// their pairing header when there is none. +fn remove( + path: &Path, + state_path: &Path, + pairing: Option<&str>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + if !json_merge::plan_remove_with(path, state_path, DESCRIPTION, DISPLAY_NAME, options(), plan)? + && let Some(pairing) = pairing + { + plan_remove_orphaned(path, pairing, plan)?; + } + Ok(()) +} + +/// Before agentdesktop has written the file (no sidecar), an entry under a +/// managed key is the user's: a provider with one of the managed names that +/// carries no pairing header, or a model under a managed provider name whose +/// provider entry was not written by an agentdesktop daemon (absent, or +/// without a pairing header). Once the sidecar exists, the managed keys are +/// agentdesktop's and their entries are replaced in place. +fn foreign_managed_entry( + path: &Path, + state_path: &Path, + managed: &Value, + plan: &ReconcilePlan, +) -> anyhow::Result> { + if json_merge::managed_after(state_path, DISPLAY_NAME, plan)?.is_some() { + return Ok(None); + } + let Some(current) = read_object(path, plan)? else { + return Ok(None); + }; + let managed_providers: Vec<&str> = managed["providers"] + .as_array() + .into_iter() + .flatten() + .filter_map(|provider| provider["name"].as_str()) + .collect(); + let written_by_agentdesktop: Vec<&str> = providers(¤t) + .filter(|provider| !provider["headers"][crate::llm_proxy::PAIRING_HEADER].is_null()) + .filter_map(|provider| provider["name"].as_str()) + .collect(); + for provider in providers(¤t) { + let Some(name) = provider["name"].as_str() else { + continue; + }; + if managed_providers.contains(&name) && !written_by_agentdesktop.contains(&name) { + return Ok(Some(format!( + "provider {name} exists but was not written by agentdesktop" + ))); + } + } + for model in current + .get("models") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let (Some(provider), Some(id)) = (model["provider"].as_str(), model["id"].as_str()) + && managed_providers.contains(&provider) + && !written_by_agentdesktop.contains(&provider) + { + return Ok(Some(format!( + "model {provider}/{id} exists under a managed provider name that was not written by agentdesktop" + ))); + } + } + Ok(None) +} + +/// Removal without a sidecar: entries under the managed names that carry +/// this daemon's own pairing value were written for this daemon (before its +/// sidecar was deleted), so they and the models under them are taken out by +/// key; the file keeps its mode and is never deleted here. Entries with +/// another pairing value, or none, are left alone, and a file that cannot be +/// read is skipped with a warning rather than failing the whole apply. +fn plan_remove_orphaned(path: &Path, pairing: &str, plan: &ReconcilePlan) -> anyhow::Result<()> { + let current = match read_object(path, plan) { + Ok(Some(current)) => current, + Ok(None) => return Ok(()), + Err(error) => { + warn!(error = %format!("{error:#}"), "skipping the Copilot CLI providers file"); + return Ok(()); + } + }; + let orphaned: Vec = providers(¤t) + .filter(|provider| provider["headers"][crate::llm_proxy::PAIRING_HEADER] == pairing) + .filter_map(|provider| provider["name"].as_str().map(str::to_owned)) + .filter(|name| name == PROVIDER_OPENAI || name == PROVIDER_ANTHROPIC) + .collect(); + if orphaned.is_empty() { + return Ok(()); + } + let mut remaining = Value::Object(current); + if let Some(entries) = remaining["providers"].as_array_mut() { + entries.retain(|provider| { + !provider["name"] + .as_str() + .is_some_and(|name| orphaned.iter().any(|own| own == name)) + }); + } + if let Some(entries) = remaining["models"].as_array_mut() { + entries.retain(|model| { + !model["provider"] + .as_str() + .is_some_and(|name| orphaned.iter().any(|own| own == name)) + }); + } + let existing = plan.read(path)?; + let mut contents = serde_json::to_vec_pretty(&remaining) + .with_context(|| format!("serialize {DISPLAY_NAME} providers"))?; + contents.push(b'\n'); + if contents == existing { + return Ok(()); + } + plan.record_diff(DISPLAY_NAME, DESCRIPTION, "update", path, None, None); + let mode = json_merge::current_mode(path).unwrap_or(FILE_MODE); + plan.write_file(path, &contents, mode) +} + +fn read_object( + path: &Path, + plan: &ReconcilePlan, +) -> anyhow::Result>> { + let existing = match plan.read(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("read {DISPLAY_NAME} providers from {}", path.display())); + } + }; + // Anything that is not a JSON object is left to the merge, which reports it. + Ok(match serde_json::from_slice::(&existing) { + Ok(Value::Object(object)) => Some(object), + _ => None, + }) +} + +fn providers(current: &serde_json::Map) -> impl Iterator { + current + .get("providers") + .and_then(Value::as_array) + .into_iter() + .flatten() +} diff --git a/crates/agent/src/provider/copilot/testdata/Dockerfile b/crates/agent/src/provider/copilot/testdata/Dockerfile new file mode 100644 index 00000000..1bb6e175 --- /dev/null +++ b/crates/agent/src/provider/copilot/testdata/Dockerfile @@ -0,0 +1,19 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +# The real CLI is an npm package that needs Node. Discovery reads the +# `@github/copilot` manifest next to the launcher and never runs the tool, so +# an npm-shaped install of a stand-in launcher exercises the same code path: +# `/bin/copilot` -> `/lib/node_modules/@github/copilot/bin/copilot`. +ARG COPILOT_VERSION=1.0.88 +RUN groupadd --system agentdesktop \ + && useradd --create-home --user-group --groups agentdesktop tester \ + && mkdir -p /usr/local/lib/node_modules/@github/copilot/bin \ + && printf '{"name":"@github/copilot","version":"%s"}\n' "${COPILOT_VERSION}" > /usr/local/lib/node_modules/@github/copilot/package.json \ + && printf '#!/bin/sh\necho "GitHub Copilot CLI %s."\n' "${COPILOT_VERSION}" > /usr/local/lib/node_modules/@github/copilot/bin/copilot \ + && chmod 0755 /usr/local/lib/node_modules/@github/copilot/bin/copilot \ + && ln -s ../lib/node_modules/@github/copilot/bin/copilot /usr/local/bin/copilot \ + && mkdir -p /run/user/1000 && chown tester:tester /run/user/1000 +ENV HOME=/home/tester \ + XDG_RUNTIME_DIR=/run/user/1000 +WORKDIR /home/tester +CMD ["sleep", "infinity"] diff --git a/crates/agent/src/provider/copilot/testdata/providers.lab.json b/crates/agent/src/provider/copilot/testdata/providers.lab.json new file mode 100644 index 00000000..dc71489d --- /dev/null +++ b/crates/agent/src/provider/copilot/testdata/providers.lab.json @@ -0,0 +1,34 @@ +{ + "providers": [ + { + "name": "agentdesktop", + "type": "openai", + "baseUrl": "http://127.0.0.1:18095/copilot-cli/v1", + "headers": { + "x-agentdesktop-pairing": "PAIRING-FIXTURE" + } + }, + { + "name": "agentdesktop-anthropic", + "type": "anthropic", + "baseUrl": "http://127.0.0.1:18095/copilot-cli", + "headers": { + "x-agentdesktop-pairing": "PAIRING-FIXTURE" + } + } + ], + "models": [ + { + "provider": "agentdesktop-anthropic", + "id": "claude-haiku-4.5", + "modelId": "claude-haiku-4.5", + "wireModel": "claude-haiku-4-5" + }, + { + "provider": "agentdesktop", + "id": "gpt-4.1", + "modelId": "gpt-4.1", + "wireModel": "gpt-4.1-mini" + } + ] +} diff --git a/crates/agent/src/provider/copilot/tests.rs b/crates/agent/src/provider/copilot/tests.rs new file mode 100644 index 00000000..79719934 --- /dev/null +++ b/crates/agent/src/provider/copilot/tests.rs @@ -0,0 +1,1063 @@ +//! Tests for the GitHub Copilot CLI provider. + +use std::{ + collections::{BTreeMap, HashMap}, + ffi::OsString, + fs, + net::SocketAddr, +}; + +use agentdesktop_core::config::{CopilotConfig, CopilotModel, CopilotProvider, LlmGatewayConfig}; +use serde_json::{Value, json}; + +use super::discovery; +use super::reconcile::{managed_document, plan, providers_path}; +use crate::reconcile::ReconcilePlan; + +fn fixture() -> Value { + serde_json::from_str(include_str!("testdata/providers.lab.json")) + .expect("fixture is valid JSON") +} + +fn sample_config() -> CopilotConfig { + let mut models = BTreeMap::new(); + models.insert( + "gpt-4.1".to_owned(), + CopilotModel { + provider: CopilotProvider::Agentdesktop, + model_id: None, + extra: BTreeMap::from([("wireModel".to_owned(), json!("gpt-4.1-mini"))]), + }, + ); + models.insert( + "claude-haiku-4.5".to_owned(), + CopilotModel { + provider: CopilotProvider::AgentdesktopAnthropic, + model_id: None, + extra: BTreeMap::from([("wireModel".to_owned(), json!("claude-haiku-4-5"))]), + }, + ); + CopilotConfig { + use_llm_gateway: true, + models, + } +} + +fn sample_gateway() -> LlmGatewayConfig { + LlmGatewayConfig { + url: "https://gateway.example.com".parse().unwrap(), + authentication: None, + proxy_url: None, + github_oauth: None, + } +} + +const LISTEN: &str = "127.0.0.1:18095"; +const PAIRING: &str = "PAIRING-FIXTURE"; + +fn listen_addr() -> SocketAddr { + LISTEN.parse().unwrap() +} + +fn find_provider<'a>(document: &'a Value, name: &str) -> &'a Value { + document["providers"] + .as_array() + .unwrap_or_else(|| panic!("providers must be an array in {document}")) + .iter() + .find(|provider| provider["name"] == name) + .unwrap_or_else(|| panic!("provider {name} not found in {document}")) +} + +fn assert_user_entries_present(document: &Value) { + assert!( + document["providers"] + .as_array() + .unwrap() + .iter() + .any(|provider| provider["name"] == "my-own-openai"), + "user provider missing from {document}" + ); + assert!( + document["models"] + .as_array() + .unwrap() + .iter() + .any(|model| model["id"] == "gpt-mine"), + "user model missing from {document}" + ); +} + +fn our_provider_count(document: &Value) -> usize { + document["providers"] + .as_array() + .unwrap() + .iter() + .filter(|provider| { + matches!( + provider["name"].as_str(), + Some("agentdesktop") | Some("agentdesktop-anthropic") + ) + }) + .count() +} + +fn write_user_owned_document(path: &std::path::Path) { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .unwrap(); + } + fs::write( + path, + serde_json::to_vec_pretty(&json!({ + "providers": [{ + "name": "my-own-openai", + "type": "openai", + "baseUrl": "https://api.example.com/v1", + "apiKey": "sk-user", + }], + "models": [{"provider": "my-own-openai", "id": "gpt-mine"}], + })) + .unwrap(), + ) + .unwrap(); +} + +// --- Config-level model validation, at parse time --------------------------- + +fn parse_programs(yaml: &str) -> anyhow::Result { + agentdesktop_core::config::parse_daemon(yaml) +} + +#[test] +fn model_documents_default_provider_and_model_id() { + let config = parse_programs( + "llmGateway:\n url: https://gateway.example\nprograms:\n copilot:\n models:\n gpt-4.1:\n wireModel: gpt-4.1-mini\n", + ) + .unwrap(); + let models = config.programs.copilot.unwrap().model_documents(); + assert_eq!(models[0]["id"], "gpt-4.1"); + assert_eq!(models[0]["provider"], "agentdesktop"); + assert_eq!(models[0]["modelId"], "gpt-4.1"); + assert_eq!(models[0]["wireModel"], "gpt-4.1-mini"); +} + +#[test] +fn config_rejects_an_unknown_provider() { + let error = parse_programs( + "programs:\n copilot:\n models:\n x:\n provider: not-ours\n", + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("provider"), "{error:#}"); +} + +#[test] +fn config_rejects_a_reserved_or_secret_pass_through_key() { + for (key, needle) in [("id", "set by agentdesktop"), ("apiKey", "not allowed")] { + let error = parse_programs(&format!( + "programs:\n copilot:\n models:\n x:\n {key}: y\n" + )) + .unwrap_err(); + assert!(format!("{error:#}").contains(needle), "{key}: {error:#}"); + } +} + +#[test] +fn config_requires_a_model_with_a_gateway() { + let error = + parse_programs("llmGateway:\n url: https://gateway.example\nprograms:\n copilot: {}\n") + .unwrap_err(); + assert!(format!("{error:#}").contains("at least one"), "{error:#}"); + parse_programs("programs:\n copilot: {}\n").expect("no gateway, no model needed"); +} + +#[test] +fn config_rejects_sandbox_with_copilot() { + let error = + parse_programs("sandbox:\n filesystem: {}\nprograms:\n copilot: {}\n").unwrap_err(); + assert!(format!("{error:#}").contains("sandbox"), "{error:#}"); +} + +// --- Version discovery ------------------------------------------------------ + +#[test] +fn npm_version_reads_the_package_manifest_next_to_the_launcher() { + let prefix = tempfile::tempdir().unwrap(); + let package = prefix.path().join("lib/node_modules/@github/copilot"); + fs::create_dir_all(package.join("bin")).unwrap(); + fs::write( + package.join("package.json"), + r#"{"name":"@github/copilot","version":"1.0.88"}"#, + ) + .unwrap(); + fs::write(package.join("bin/copilot"), "#!/bin/sh\n").unwrap(); + fs::create_dir_all(prefix.path().join("bin")).unwrap(); + let launcher = prefix.path().join("bin/copilot"); + #[cfg(unix)] + std::os::unix::fs::symlink(package.join("bin/copilot"), &launcher).unwrap(); + #[cfg(not(unix))] + fs::copy(package.join("bin/copilot"), &launcher).unwrap(); + assert_eq!(discovery::npm_version(&launcher).as_deref(), Some("1.0.88")); +} + +#[test] +fn npm_version_ignores_a_manifest_of_another_package() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("package.json"), + r#"{"name":"@openai/codex","version":"9.9.9"}"#, + ) + .unwrap(); + fs::write(dir.path().join("copilot"), "#!/bin/sh\n").unwrap(); + assert_eq!(discovery::npm_version(&dir.path().join("copilot")), None); +} + +#[test] +fn npm_version_is_none_for_a_standalone_binary() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("copilot"), "#!/bin/sh\n").unwrap(); + assert_eq!(discovery::npm_version(&dir.path().join("copilot")), None); +} + +// --- File location precedence ----------------------------------------------- + +#[test] +fn providers_path_prefers_explicit_config_env_var() { + let mut vars = HashMap::new(); + vars.insert("COPILOT_PROVIDERS_CONFIG", "/explicit/providers.json"); + vars.insert("COPILOT_HOME", "/copilot-home"); + vars.insert("HOME", "/home/user"); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert_eq!( + providers_path(&lookup).unwrap(), + std::path::PathBuf::from("/explicit/providers.json") + ); +} + +#[test] +fn providers_path_falls_back_to_copilot_home() { + let mut vars = HashMap::new(); + vars.insert("COPILOT_HOME", "/copilot-home"); + vars.insert("HOME", "/home/user"); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert_eq!( + providers_path(&lookup).unwrap(), + std::path::PathBuf::from("/copilot-home/providers.json") + ); +} + +#[test] +fn providers_path_falls_back_to_home_dot_copilot() { + let mut vars = HashMap::new(); + vars.insert("HOME", "/home/user"); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert_eq!( + providers_path(&lookup).unwrap(), + std::path::PathBuf::from("/home/user/.copilot/providers.json") + ); +} + +#[test] +fn providers_path_falls_back_to_userprofile_when_home_is_unset() { + let mut vars = HashMap::new(); + vars.insert("USERPROFILE", r"C:\Users\user"); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert_eq!( + providers_path(&lookup).unwrap(), + std::path::PathBuf::from(r"C:\Users\user").join(".copilot/providers.json") + ); +} + +#[test] +fn providers_path_treats_empty_values_as_unset() { + let mut vars = HashMap::new(); + vars.insert("COPILOT_PROVIDERS_CONFIG", ""); + vars.insert("COPILOT_HOME", ""); + vars.insert("HOME", ""); + vars.insert("USERPROFILE", "/profile"); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert_eq!( + providers_path(&lookup).unwrap(), + std::path::PathBuf::from("/profile/.copilot/providers.json") + ); +} + +#[test] +fn providers_path_errors_without_any_home() { + let vars: HashMap<&str, &str> = HashMap::new(); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert!(providers_path(&lookup).is_err()); +} + +// --- managed_document shape ------------------------------------------------- + +#[test] +fn managed_document_matches_the_lab_fixture_shape() { + let config = sample_config(); + let document = managed_document(&config, listen_addr(), PAIRING).expect("managed document"); + let fixture = fixture(); + assert_eq!(document["providers"], fixture["providers"]); + assert_eq!(document["models"], fixture["models"]); +} + +// --- plan(): fresh create --------------------------------------------------- + +#[test] +fn fresh_file_is_created_matching_the_fixture_and_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .expect("plan succeeds"); + assert!(!changes.has_conflicts()); + changes.apply().expect("apply succeeds"); + + let written: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + let fixture = fixture(); + assert_eq!(written["providers"], fixture["providers"]); + assert_eq!(written["models"], fixture["models"]); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "a file we create must be owner-only" + ); + } + assert!( + super::super::json_merge::state_path(&path).exists(), + "a merge sidecar must be written alongside the created file" + ); +} + +// A missing Copilot directory is created owner-only along with the file; an +// existing directory keeps its mode. +#[test] +fn fresh_directory_is_created_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".copilot").join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + assert!(path.is_file()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + let existing = tempfile::tempdir().unwrap(); + let open_dir = existing.path().join("open"); + fs::create_dir(&open_dir).unwrap(); + fs::set_permissions(&open_dir, fs::Permissions::from_mode(0o755)).unwrap(); + let path = open_dir.join("providers.json"); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + assert_eq!( + fs::metadata(&open_dir).unwrap().permissions().mode() & 0o777, + 0o755, + "an existing directory keeps its mode" + ); + } +} + +// --- plan(): user entries survive, re-pairing replaces without duplicating --- + +#[test] +fn user_provider_and_model_survive_reapply_and_repairing_without_duplicates() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + // A user hand-adds their own BYOK provider and model. + let mut document: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + document["providers"].as_array_mut().unwrap().push(json!({ + "name": "my-own-openai", + "type": "openai", + "baseUrl": "https://api.example.com/v1", + "apiKey": "sk-user", + })); + document["models"] + .as_array_mut() + .unwrap() + .push(json!({"provider": "my-own-openai", "id": "gpt-mine"})); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + + // Re-apply unchanged: both the user's and our entries stay, none duplicated. + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + let after_reapply: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_user_entries_present(&after_reapply); + assert_eq!(our_provider_count(&after_reapply), 2); + + // Re-pairing (new listen address and pairing value, e.g. FRESH=1 + // re-enrollment): our entries are replaced in place, not duplicated; the + // user's entries are untouched. + let new_listen: SocketAddr = "127.0.0.1:18099".parse().unwrap(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((new_listen, "PAIRING-NEW")), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + let after_repair: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_user_entries_present(&after_repair); + assert_eq!( + our_provider_count(&after_repair), + 2, + "re-pairing must replace our providers, not duplicate them" + ); + let openai = find_provider(&after_repair, "agentdesktop"); + assert_eq!(openai["baseUrl"], "http://127.0.0.1:18099/copilot-cli/v1"); + assert_eq!(openai["headers"]["x-agentdesktop-pairing"], "PAIRING-NEW"); +} + +// --- plan(): removal -------------------------------------------------------- + +#[test] +fn removal_deletes_the_file_only_when_we_created_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + assert!(path.exists()); + + // programs.copilot absent from the pushed config. + let changes = ReconcilePlan::default(); + plan(&path, Some((listen_addr(), PAIRING)), None, &changes).unwrap(); + changes.apply().unwrap(); + + assert!( + !path.exists(), + "a file we created must be removed once nothing else remains" + ); + assert!(!super::super::json_merge::state_path(&path).exists()); +} + +#[test] +fn removal_keeps_a_preexisting_user_owned_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + write_user_owned_document(&path); + + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + let changes = ReconcilePlan::default(); + plan(&path, Some((listen_addr(), PAIRING)), None, &changes).unwrap(); + changes.apply().unwrap(); + + assert!( + path.exists(), + "a file we did not create must not be deleted" + ); + let remaining: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(remaining["providers"].as_array().unwrap().len(), 1); + assert_eq!(remaining["providers"][0]["name"], "my-own-openai"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "removal must not loosen the mode of a file that holds the user's keys" + ); + } +} + +#[test] +fn removal_when_use_llm_gateway_is_effectively_false() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + // `useLlmGateway: false`, or no top-level `llmGateway`: the caller passes + // the program's config with the gateway filtered to `None`. + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, None)), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + assert!(!path.exists()); +} + +#[test] +fn removal_when_the_loopback_proxy_is_unavailable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + // `ctx.llm_proxy` is `None` (bind failed, proxy off, no pairing) even + // though the program is still configured with `useLlmGateway: true`. + let changes = ReconcilePlan::default(); + plan(&path, None, Some((&config, Some(&gateway))), &changes).unwrap(); + changes.apply().unwrap(); + + assert!( + !path.exists(), + "our entries must be removed when the proxy is unavailable, even if programs.copilot is still configured" + ); +} + +// --- plan(): conflicts ------------------------------------------------------ + +#[test] +fn conflict_on_a_foreign_provider_named_agentdesktop() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "providers": [{ + "name": "agentdesktop", + "type": "openai", + "baseUrl": "https://not-ours.example.com/v1", + "apiKey": "sk-user", + }], + "models": [], + })) + .unwrap(), + ) + .unwrap(); + let before = fs::read(&path).unwrap(); + + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + + assert!(changes.has_conflicts()); + assert!(changes.render().contains("CONFLICT")); + assert!(changes.apply().is_err()); + assert_eq!( + fs::read(&path).unwrap(), + before, + "a conflict must write nothing" + ); +} + +#[test] +fn conflict_on_a_foreign_provider_named_agentdesktop_that_carries_a_model() { + // The user's own provider happens to be called "agentdesktop" (no pairing + // header) and carries a model: both are the user's, so the merge is refused. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "providers": [{"name": "agentdesktop", "type": "openai", "baseUrl": "https://example.invalid/v1"}], + "models": [{"provider": "agentdesktop", "id": "gpt-4.1"}], + })) + .unwrap(), + ) + .unwrap(); + let before = fs::read(&path).unwrap(); + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!(changes.has_conflicts()); + assert!( + changes.render().contains("provider agentdesktop exists"), + "{}", + changes.render() + ); + assert!(changes.apply().is_err()); + assert_eq!(fs::read(&path).unwrap(), before); +} + +#[test] +fn conflict_on_a_non_object_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + fs::write(&path, b"[]\n").unwrap(); + + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + + assert!(changes.has_conflicts()); + assert!(changes.apply().is_err()); + assert_eq!(fs::read(&path).unwrap(), b"[]\n"); +} + +// --- Provider::plan: system-mode rejection ---------------------------------- + +#[test] +fn system_mode_rejects_a_configured_program_before_writing_anything() { + use super::Copilot; + use crate::provider::{Provider, ReconcileContext}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = parse_programs( + "llmGateway:\n url: https://gateway.example\nprograms:\n copilot:\n models:\n gpt-4.1: {}\n", + ) + .unwrap(); + let context = |merge_user_settings: bool| ReconcileContext { + merge_user_settings, + credential_helper: dir.path().join("agentdesktop"), + socket: dir.path().join("agentdesktop.sock"), + llm_proxy: None, + }; + // System mode: no providers path, or a path but no user settings. + for provider in [ + Copilot { + providers_path: None, + }, + Copilot { + providers_path: Some(path.clone()), + }, + ] { + let error = match provider.plan(&context(false), &config) { + Ok(_) => panic!("system mode with programs.copilot configured must fail"), + Err(error) => error, + }; + assert!(error.to_string().contains("--user"), "{error}"); + assert!(error.to_string().contains("GitHub Copilot CLI"), "{error}"); + } + assert!(!path.exists(), "preflight failure must not write any files"); + // System mode with the program absent does nothing. + let absent = parse_programs("llmGateway:\n url: https://gateway.example\n").unwrap(); + let plan = Copilot { + providers_path: None, + } + .plan(&context(false), &absent) + .unwrap(); + assert!( + plan.render().trim().ends_with("changed"), + "{}", + plan.render() + ); + // User mode without the proxy: a removal is planned, nothing to remove here. + Copilot { + providers_path: Some(path.clone()), + } + .plan(&context(true), &config) + .unwrap() + .apply() + .unwrap(); + assert!(!path.exists()); +} + +// --- keyed ownership, created flag, redaction ------------------------------ + +fn apply_managed(path: &std::path::Path, listen: SocketAddr, pairing: &str) { + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + path, + Some((listen, pairing)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!(!changes.has_conflicts(), "{}", changes.render()); + changes.apply().unwrap(); +} + +fn apply_removal(path: &std::path::Path) -> ReconcilePlan { + let changes = ReconcilePlan::default(); + plan(path, Some((listen_addr(), PAIRING)), None, &changes).unwrap(); + changes +} + +fn read(path: &std::path::Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +#[test] +fn an_edited_managed_entry_is_replaced_and_removed_by_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + apply_managed(&path, listen_addr(), PAIRING); + // The user edits our model and our provider in place. + let mut document = read(&path); + for model in document["models"].as_array_mut().unwrap() { + if model["id"] == "gpt-4.1" { + model["wireModel"] = json!("gpt-4.1-edited"); + } + } + for provider in document["providers"].as_array_mut().unwrap() { + if provider["name"] == "agentdesktop" { + provider["baseUrl"] = json!("http://127.0.0.1:1/edited"); + } + } + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + // Re-apply with a new pairing: ours are replaced, not duplicated, edits gone. + apply_managed(&path, listen_addr(), "PAIRING-NEW"); + let after = read(&path); + assert_eq!(our_provider_count(&after), 2); + let openai = find_provider(&after, "agentdesktop"); + assert_eq!(openai["baseUrl"], "http://127.0.0.1:18095/copilot-cli/v1"); + assert_eq!(openai["headers"]["x-agentdesktop-pairing"], "PAIRING-NEW"); + let models = after["models"].as_array().unwrap(); + assert_eq!(models.iter().filter(|m| m["id"] == "gpt-4.1").count(), 1); + assert_eq!( + models.iter().find(|m| m["id"] == "gpt-4.1").unwrap()["wireModel"], + "gpt-4.1-mini" + ); + // Edit again, then remove the program: the edited entries go too. + let mut document = read(&path); + document["models"][0]["wireModel"] = json!("edited-again"); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + apply_removal(&path).apply().unwrap(); + assert!(!path.exists(), "nothing of ours may remain"); +} + +#[test] +fn a_user_model_with_a_managed_id_under_another_provider_is_not_a_conflict() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "providers": [{"name": "my-own", "type": "openai", "baseUrl": "https://example.invalid/v1"}], + "models": [{"provider": "my-own", "id": "gpt-4.1"}], + })) + .unwrap(), + ) + .unwrap(); + apply_managed(&path, listen_addr(), PAIRING); + let after = read(&path); + let gpt = after["models"] + .as_array() + .unwrap() + .iter() + .filter(|m| m["id"] == "gpt-4.1") + .count(); + assert_eq!( + gpt, 2, + "the user's my-own/gpt-4.1 and our agentdesktop/gpt-4.1 coexist" + ); + // A user model under OUR provider with another id is kept across re-apply. + let mut document = read(&path); + document["models"] + .as_array_mut() + .unwrap() + .push(json!({"provider": "agentdesktop", "id": "gpt-user"})); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + apply_managed(&path, listen_addr(), PAIRING); + assert!( + read(&path)["models"] + .as_array() + .unwrap() + .iter() + .any(|m| m["id"] == "gpt-user") + ); +} + +#[test] +fn an_orphaned_managed_provider_with_a_pairing_header_is_replaced_not_a_conflict() { + // The sidecar is gone (deleted, or the file came from another device), but + // the entry carries a pairing header: an agentdesktop wrote it, so it is + // replaced and owned again. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + apply_managed(&path, listen_addr(), "PAIRING-OLD"); + fs::remove_file(super::super::json_merge::state_path(&path)).unwrap(); + apply_managed(&path, listen_addr(), PAIRING); + let after = read(&path); + assert_eq!(our_provider_count(&after), 2); + assert_eq!( + find_provider(&after, "agentdesktop")["headers"]["x-agentdesktop-pairing"], + PAIRING + ); + assert!(super::super::json_merge::state_path(&path).exists()); +} + +#[test] +fn a_file_recreated_by_the_daemon_after_the_user_deleted_theirs_is_removed_whole() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + write_user_owned_document(&path); + apply_managed(&path, listen_addr(), PAIRING); + fs::remove_file(&path).unwrap(); + apply_managed(&path, listen_addr(), PAIRING); + apply_removal(&path).apply().unwrap(); + assert!( + !path.exists(), + "the daemon created this file, so removal deletes it" + ); +} + +#[test] +fn the_plan_report_never_shows_the_pairing_or_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + write_user_owned_document(&path); + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + let report = changes.render(); + assert!(report.contains("UPDATE"), "{report}"); + assert!( + !report.contains(PAIRING) && !report.contains("sk-user"), + "{report}" + ); + changes.apply().unwrap(); + let removal = apply_removal(&path); + let report = removal.render(); + assert!( + !report.contains(PAIRING) && !report.contains("sk-user"), + "{report}" + ); +} + +#[test] +fn a_managed_model_dropped_from_the_config_is_removed_even_if_edited() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + apply_managed(&path, listen_addr(), PAIRING); + let mut document = read(&path); + for model in document["models"].as_array_mut().unwrap() { + if model["id"] == "claude-haiku-4.5" { + model["wireModel"] = json!("edited"); + } + } + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + // The admin drops the anthropic model from the config. + let mut config = sample_config(); + config.models.remove("claude-haiku-4.5"); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + let after = read(&path); + assert!( + !after["models"] + .as_array() + .unwrap() + .iter() + .any(|m| m["id"] == "claude-haiku-4.5"), + "a previously managed model must not linger: {after}" + ); + apply_removal(&path).apply().unwrap(); + assert!(!path.exists()); +} + +#[test] +fn a_user_model_under_a_managed_provider_name_without_that_provider_is_a_conflict() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "providers": [], + "models": [{"provider": "agentdesktop", "id": "gpt-4.1"}], + })) + .unwrap(), + ) + .unwrap(); + let before = fs::read(&path).unwrap(); + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!(changes.has_conflicts()); + assert!( + changes.render().contains("model agentdesktop/gpt-4.1"), + "{}", + changes.render() + ); + assert_eq!(fs::read(&path).unwrap(), before); +} + +#[test] +fn removal_without_a_sidecar_strips_entries_that_carry_a_pairing_header() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + write_user_owned_document(&path); + apply_managed(&path, listen_addr(), PAIRING); + fs::remove_file(super::super::json_merge::state_path(&path)).unwrap(); + // Another device's (or a hand-written) entry with a different pairing is + // not this daemon's and stays. + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), "PAIRING-OTHER")), + None, + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + assert_eq!(our_provider_count(&read(&path)), 2); + let removal = apply_removal(&path); + assert!(!removal.render().contains(PAIRING)); + removal.apply().unwrap(); + let after = read(&path); + assert_eq!(our_provider_count(&after), 0, "{after}"); + assert!( + after["models"] + .as_array() + .unwrap() + .iter() + .all(|m| m["provider"] == "my-own-openai") + ); + assert_user_entries_present(&after); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } +} + +#[test] +fn config_rejects_a_non_object_entry_and_names_the_entry() { + let error = + parse_programs("programs:\n copilot:\n models:\n gpt-4.1: not-an-object\n") + .unwrap_err(); + assert!(format!("{error:#}").contains("gpt-4.1"), "{error:#}"); + let error = + parse_programs("programs:\n copilot:\n models:\n gpt-4.1:\n apiKey: x\n") + .unwrap_err(); + assert!( + format!("{error:#}").contains("models.gpt-4.1.apiKey"), + "{error:#}" + ); +} diff --git a/crates/agent/src/provider/json_merge.rs b/crates/agent/src/provider/json_merge.rs index d9e39c35..bcbe6eb1 100644 --- a/crates/agent/src/provider/json_merge.rs +++ b/crates/agent/src/provider/json_merge.rs @@ -25,7 +25,41 @@ pub(super) fn state_path(path: &Path) -> PathBuf { path.with_file_name(format!(".{name}.agentdesktop")) } -#[allow(clippy::too_many_arguments)] +/// An array in the managed document whose elements are identified by a key +/// (for example `providers[].name`), so a managed element replaces the element +/// with the same key rather than sitting next to it, and removal takes the +/// element out even if it was edited in place. `field: ""` names the document +/// root, for a file whose top level is an array. +#[derive(Clone, Copy)] +pub(super) struct KeyedArray { + pub(super) field: &'static str, + pub(super) keys: &'static [&'static str], +} + +/// How a managed document is written into, and removed from, a user file. +#[derive(Clone, Copy)] +pub(super) struct MergeOptions { + /// Mode of the file when the merge writes it (`plan_remove` keeps the + /// file's current mode). + pub(super) mode: u32, + /// Arrays whose elements are identified by key. Elements of arrays not + /// listed here are matched by full equality. + pub(super) keyed_arrays: &'static [KeyedArray], + /// Record actions without file content, for a file that holds secrets: + /// the dry-run report then shows the action and the path only. + pub(super) redact_diff: bool, +} + +impl Default for MergeOptions { + fn default() -> Self { + Self { + mode: 0o644, + keyed_arrays: &[], + redact_diff: false, + } + } +} + pub(super) fn plan_merge( path: &Path, state_path: &Path, @@ -34,6 +68,40 @@ pub(super) fn plan_merge( description: &str, display_name: &str, plan: &ReconcilePlan, +) -> anyhow::Result<()> { + plan_merge_with( + path, + state_path, + managed, + legacy_owned, + description, + display_name, + MergeOptions::default(), + plan, + ) +} + +/// The managed values the previous merge left in the file (the sidecar's +/// `after` snapshot, the whole merged document as last written), so a +/// provider can tell whether an entry was written by it. +pub(super) fn managed_after( + state_path: &Path, + display_name: &str, + plan: &ReconcilePlan, +) -> anyhow::Result> { + Ok(read_state(state_path, display_name, plan)?.map(|state| state.after)) +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn plan_merge_with( + path: &Path, + state_path: &Path, + managed: Value, + legacy_owned: bool, + description: &str, + display_name: &str, + options: MergeOptions, + plan: &ReconcilePlan, ) -> anyhow::Result<()> { let existing = match plan.read(path) { Ok(contents) => Some(contents), @@ -44,26 +112,57 @@ pub(super) fn plan_merge( } }; let previous = read_state(state_path, display_name, plan)?; - let created = previous - .as_ref() - .map(|state| state.created) - .unwrap_or(existing.is_none() || legacy_owned); + // A file that does not exist now is created by this merge, whatever an + // older sidecar says: the user's earlier file is gone. + let created = existing.is_none() + || previous + .as_ref() + .map(|state| state.created) + .unwrap_or(legacy_owned); + // The file must have the managed document's shape at the root: an object, + // or an array when the managed document is one. + let empty_root = || { + if managed.is_array() { + json!([]) + } else { + json!({}) + } + }; + // An empty (or whitespace-only) file holds nothing to keep and is not a + // conflict; the merge fills it. Comments or trailing commas are: the file + // is rewritten as plain JSON, so they would be lost silently. let mut combined = match existing.as_deref() { + Some(contents) if contents.iter().all(u8::is_ascii_whitespace) => empty_root(), Some(contents) => match serde_json::from_slice::(contents) { - Ok(Value::Object(object)) => Value::Object(object), + Ok(value) + if value.is_object() == managed.is_object() + && value.is_array() == managed.is_array() => + { + value + } Ok(_) | Err(_) => { plan.record(display_name, description, "conflict", path); return Ok(()); } }, - None => json!({}), + None => empty_root(), }; if let Some(previous) = previous.as_ref() { combined = rollback_overlay(&combined, &previous.before, &previous.after); } else if legacy_owned { - combined = json!({}); + combined = empty_root(); + } + // Keyed elements are owned by key: whatever the file holds under a managed + // key (edited or not) gives way to the managed element, and an element the + // previous merge added under a key that is no longer managed goes away. + for keyed in options.keyed_arrays { + if let Some(previous) = previous.as_ref() { + let added = keyed_added(keyed, &previous.before, &previous.after); + remove_keyed(&mut combined, keyed, &added); + } + remove_keyed(&mut combined, keyed, &managed); } let before = combined.clone(); merge_overlay(&mut combined, managed); @@ -76,17 +175,19 @@ pub(super) fn plan_merge( Some(_) => "update", None => "create", }; - plan.record_diff( + record( + plan, display_name, description, action, path, existing.as_deref(), Some(&contents), + options.redact_diff, ); if action != "unchanged" { - plan.write_file(path, &contents, 0o644)?; + plan.write_file(path, &contents, options.mode)?; } let mut state = serde_json::to_vec_pretty(&MergeState { created, @@ -106,6 +207,26 @@ pub(super) fn plan_remove( description: &str, display_name: &str, plan: &ReconcilePlan, +) -> anyhow::Result { + plan_remove_with( + path, + state_path, + description, + display_name, + MergeOptions::default(), + plan, + ) +} + +/// `plan_remove` with keyed arrays and diff redaction; the file keeps its +/// current mode when it is rewritten. +pub(super) fn plan_remove_with( + path: &Path, + state_path: &Path, + description: &str, + display_name: &str, + options: MergeOptions, + plan: &ReconcilePlan, ) -> anyhow::Result { let Some(state) = read_state(state_path, display_name, plan)? else { return Ok(false); @@ -122,15 +243,55 @@ pub(super) fn plan_remove( .with_context(|| format!("read {display_name} from {}", path.display())); } }; + // The file must still have the shape the merge left (object or array); an + // emptied file holds nothing of ours any more. + if existing.iter().all(u8::is_ascii_whitespace) { + if state.created { + plan.record(display_name, description, "remove", path); + plan.remove_file(path) + .with_context(|| format!("remove {display_name} at {}", path.display()))?; + } else { + plan.record(display_name, description, "unchanged", path); + } + remove_file(state_path, display_name, plan)?; + return Ok(true); + } let settings = match serde_json::from_slice::(&existing) { - Ok(Value::Object(object)) => Value::Object(object), + Ok(value) + if value.is_object() == state.after.is_object() + && value.is_array() == state.after.is_array() => + { + value + } Ok(_) | Err(_) => { plan.record(display_name, description, "conflict", path); return Ok(true); } }; - let settings = rollback_overlay(&settings, &state.before, &state.after); - let empty = settings.as_object().is_some_and(serde_json::Map::is_empty); + let mut settings = rollback_overlay(&settings, &state.before, &state.after); + // Keyed elements the last merge added are removed by key, so an element + // edited in place after the merge goes too. + for keyed in options.keyed_arrays { + let added = keyed_added(keyed, &state.before, &state.after); + remove_keyed(&mut settings, keyed, &added); + // An array the merge introduced (absent or empty before) and that is + // now empty goes with it. + if !keyed.field.is_empty() + && state + .before + .get(keyed.field) + .is_none_or(|value| value.as_array().is_some_and(Vec::is_empty)) + && settings + .get(keyed.field) + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + && let Some(object) = settings.as_object_mut() + { + object.remove(keyed.field); + } + } + let empty = settings.as_object().is_some_and(serde_json::Map::is_empty) + || settings.as_array().is_some_and(Vec::is_empty); let proposed = if state.created && empty { None } else { @@ -144,13 +305,15 @@ pub(super) fn plan_remove( Some(contents) if contents == existing => "unchanged", Some(_) => "update", }; - plan.record_diff( + record( + plan, display_name, description, action, path, Some(&existing), proposed.as_deref(), + options.redact_diff, ); if action == "remove" { plan.remove_file(path) @@ -158,7 +321,9 @@ pub(super) fn plan_remove( } else if let Some(contents) = proposed && action == "update" { - plan.write_file(path, &contents, 0o644)?; + // The user's remaining content keeps the file's current mode: a file + // that was owner-only (it may hold the user's own keys) stays so. + plan.write_file(path, &contents, current_mode(path).unwrap_or(0o644))?; } remove_file(state_path, display_name, plan)?; debug!(provider = display_name, action, path = %path.display(), "planned removal of managed values from user settings"); @@ -283,3 +448,430 @@ fn rollback_value( Some(current.clone()) } + +/// The mode bits of an existing file, on platforms that have them. +pub(super) fn current_mode(path: &Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(path) + .ok() + .map(|metadata| metadata.permissions().mode() & 0o777) + } + #[cfg(not(unix))] + { + let _ = path; + None + } +} + +#[allow(clippy::too_many_arguments)] +fn record( + plan: &ReconcilePlan, + display_name: &str, + description: &str, + action: &str, + path: &Path, + before: Option<&[u8]>, + after: Option<&[u8]>, + redact: bool, +) { + if redact { + plan.record_diff(display_name, description, action, path, None, None); + } else { + plan.record_diff(display_name, description, action, path, before, after); + } +} + +/// The key tuple of an array element, `None` when a key field is missing or +/// not a string (such an element is never matched by key). +fn element_key(keyed: &KeyedArray, element: &Value) -> Option> { + keyed + .keys + .iter() + .map(|key| element.get(*key)?.as_str().map(str::to_owned)) + .collect() +} + +/// The keyed array inside `document`: the field, or the document itself for +/// `field: ""`. +fn keyed_array<'a>(document: &'a Value, keyed: &KeyedArray) -> Option<&'a Vec> { + if keyed.field.is_empty() { + document.as_array() + } else { + document.get(keyed.field).and_then(Value::as_array) + } +} + +fn keyed_array_mut<'a>(document: &'a mut Value, keyed: &KeyedArray) -> Option<&'a mut Vec> { + if keyed.field.is_empty() { + document.as_array_mut() + } else { + document.get_mut(keyed.field).and_then(Value::as_array_mut) + } +} + +/// Remove from the keyed array of `document` every element whose key equals +/// the key of an element in the keyed array of `managed`. +fn remove_keyed(document: &mut Value, keyed: &KeyedArray, managed: &Value) { + let managed_keys: Vec> = keyed_array(managed, keyed) + .into_iter() + .flatten() + .filter_map(|element| element_key(keyed, element)) + .collect(); + if managed_keys.is_empty() { + return; + } + if let Some(elements) = keyed_array_mut(document, keyed) { + elements.retain(|element| { + element_key(keyed, element).is_none_or(|key| !managed_keys.contains(&key)) + }); + } +} + +/// The document holding only the keyed elements the last merge added +/// (`after` minus `before`, by key). +fn keyed_added(keyed: &KeyedArray, before: &Value, after: &Value) -> Value { + let before_keys: Vec> = keyed_array(before, keyed) + .into_iter() + .flatten() + .filter_map(|element| element_key(keyed, element)) + .collect(); + let added: Vec = keyed_array(after, keyed) + .into_iter() + .flatten() + .filter(|element| { + element_key(keyed, element).is_some_and(|key| !before_keys.contains(&key)) + }) + .cloned() + .collect(); + if keyed.field.is_empty() { + Value::Array(added) + } else { + json!({ keyed.field: added }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn managed() -> Value { + json!({"env": {"MANAGED": "1"}}) + } + + #[test] + fn an_empty_file_is_filled_not_a_conflict_and_a_comment_is_a_conflict() { + let dir = tempfile::tempdir().unwrap(); + for contents in ["", " \n"] { + let path = dir.path().join("empty.json"); + let state = state_path(&path); + std::fs::write(&path, contents).unwrap(); + let plan = ReconcilePlan::default(); + plan_merge(&path, &state, managed(), false, "settings", "Test", &plan).unwrap(); + assert!(!plan.has_conflicts(), "{}", plan.render()); + plan.apply().unwrap(); + let written: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(written, managed()); + // Emptied by hand afterwards: removal drops the sidecar and leaves + // the user's (pre-existing) file. + std::fs::write(&path, "").unwrap(); + let plan = ReconcilePlan::default(); + assert!(plan_remove(&path, &state, "settings", "Test", &plan).unwrap()); + plan.apply().unwrap(); + assert!(!state.exists()); + assert!(path.exists()); + std::fs::remove_file(&path).unwrap(); + } + // A file the merge created and the user then emptied is removed whole. + let path = dir.path().join("created.json"); + let state = state_path(&path); + let plan = ReconcilePlan::default(); + plan_merge(&path, &state, managed(), false, "settings", "Test", &plan).unwrap(); + plan.apply().unwrap(); + std::fs::write(&path, "").unwrap(); + let plan = ReconcilePlan::default(); + assert!(plan_remove(&path, &state, "settings", "Test", &plan).unwrap()); + plan.apply().unwrap(); + assert!(!path.exists() && !state.exists()); + let path = dir.path().join("commented.json"); + std::fs::write(&path, "{\n // a comment\n \"env\": {}\n}\n").unwrap(); + let plan = ReconcilePlan::default(); + plan_merge( + &path, + &state_path(&path), + managed(), + false, + "settings", + "Test", + &plan, + ) + .unwrap(); + assert!(plan.has_conflicts(), "comments are not rewritten silently"); + } + + #[test] + fn removal_refuses_a_file_whose_shape_changed_since_the_merge() { + let dir = tempfile::tempdir().unwrap(); + for (managed, replaced) in [ + (json!({"env": {"MANAGED": "1"}}), "[]\n"), + (json!([{"name": "agentdesktop"}]), "{}\n"), + ] { + let path = dir.path().join("shape.json"); + let state = state_path(&path); + let plan = ReconcilePlan::default(); + let options = MergeOptions { + keyed_arrays: &[KeyedArray { + field: "", + keys: &["name"], + }], + ..MergeOptions::default() + }; + plan_merge_with(&path, &state, managed, false, "x", "Test", options, &plan).unwrap(); + plan.apply().unwrap(); + std::fs::write(&path, replaced).unwrap(); + let plan = ReconcilePlan::default(); + assert!(plan_remove_with(&path, &state, "x", "Test", options, &plan).unwrap()); + assert!(plan.has_conflicts(), "{}", plan.render()); + assert!(plan.apply().is_err()); + assert!(state.exists(), "a conflict keeps the sidecar"); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_file(&state).unwrap(); + } + } + + // Default options, as Claude Code and Claude Desktop use them. + #[test] + fn removal_keeps_the_file_mode_and_a_recreated_file_counts_as_created() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let state = state_path(&path); + // A user file that is owner-only stays owner-only after the managed + // values are removed. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::write(&path, b"{\"env\":{\"USER\":\"keep\"}}\n").unwrap(); + let plan = ReconcilePlan::default(); + plan_merge(&path, &state, managed(), false, "settings", "Test", &plan).unwrap(); + plan.apply().unwrap(); + // The user tightens the mode after the merge (the default merge + // writes 0644, as before); removal keeps what it finds. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let plan = ReconcilePlan::default(); + assert!(plan_remove(&path, &state, "settings", "Test", &plan).unwrap()); + plan.apply().unwrap(); + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let remaining: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(remaining, json!({"env": {"USER": "keep"}})); + } + // The user's file existed at the first merge (not created by us); it + // is then deleted and the next merge recreates it: removal deletes it. + std::fs::write(&path, b"{\"env\":{\"USER\":\"keep\"}}\n").unwrap(); + let plan = ReconcilePlan::default(); + plan_merge(&path, &state, managed(), false, "settings", "Test", &plan).unwrap(); + plan.apply().unwrap(); + std::fs::remove_file(&path).unwrap(); + let plan = ReconcilePlan::default(); + plan_merge(&path, &state, managed(), false, "settings", "Test", &plan).unwrap(); + plan.apply().unwrap(); + let plan = ReconcilePlan::default(); + assert!(plan_remove(&path, &state, "settings", "Test", &plan).unwrap()); + plan.apply().unwrap(); + assert!( + !path.exists(), + "a file the merge recreated is removed whole" + ); + } + + // --- Root-array support ------------------------------------------------- + // + // A managed document that is itself a top-level array, keyed by `name` at + // the document root (`field: ""`), used by the VS Code provider's + // `chatLanguageModels.json`. Object-root callers (above) must keep + // working unchanged. + + const ROOT_KEYED: &[KeyedArray] = &[KeyedArray { + field: "", + keys: &["name"], + }]; + + fn root_array_options() -> MergeOptions { + MergeOptions { + mode: 0o600, + keyed_arrays: ROOT_KEYED, + redact_diff: true, + } + } + + #[test] + fn root_array_merge_keeps_user_entries_and_replaces_managed_entry_by_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let state = state_path(&path); + let options = root_array_options(); + + let managed = json!([{"name": "agentdesktop", "vendor": "customendpoint"}]); + let plan = ReconcilePlan::default(); + plan_merge_with( + &path, + &state, + managed, + false, + "chat models", + "Test", + options, + &plan, + ) + .unwrap(); + plan.apply().unwrap(); + + let mut document: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert!(document.is_array(), "managed document must stay an array"); + // The user hand-adds their own vendor entry. + document + .as_array_mut() + .unwrap() + .push(json!({"name": "user-vendor"})); + std::fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + + // Re-apply with an edited managed entry: replaced by key, not + // duplicated; the user's entry survives. + let managed = json!([{"name": "agentdesktop", "vendor": "customendpoint", "apiType": "chat-completions"}]); + let plan = ReconcilePlan::default(); + plan_merge_with( + &path, + &state, + managed, + false, + "chat models", + "Test", + options, + &plan, + ) + .unwrap(); + plan.apply().unwrap(); + + let after: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let array = after.as_array().unwrap(); + assert_eq!(array.len(), 2, "{after}"); + assert!(array.iter().any(|entry| entry["name"] == "user-vendor")); + let ours = array + .iter() + .find(|entry| entry["name"] == "agentdesktop") + .unwrap(); + assert_eq!(ours["apiType"], "chat-completions"); + } + + #[test] + fn root_array_removal_strips_the_managed_entry_by_key_and_deletes_when_empty_and_created() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let state = state_path(&path); + let options = root_array_options(); + + let managed = json!([{"name": "agentdesktop"}]); + let plan = ReconcilePlan::default(); + plan_merge_with( + &path, + &state, + managed, + false, + "chat models", + "Test", + options, + &plan, + ) + .unwrap(); + plan.apply().unwrap(); + assert!(path.exists()); + + let plan = ReconcilePlan::default(); + assert!(plan_remove_with(&path, &state, "chat models", "Test", options, &plan).unwrap()); + plan.apply().unwrap(); + assert!( + !path.exists(), + "an empty root array we created must be deleted" + ); + } + + #[test] + fn root_array_removal_keeps_a_user_entry_and_the_files_mode() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let state = state_path(&path); + let options = root_array_options(); + + std::fs::write(&path, b"[{\"name\":\"user-vendor\"}]\n").unwrap(); + let plan = ReconcilePlan::default(); + plan_merge_with( + &path, + &state, + json!([{"name": "agentdesktop"}]), + false, + "chat models", + "Test", + options, + &plan, + ) + .unwrap(); + plan.apply().unwrap(); + + let plan = ReconcilePlan::default(); + assert!(plan_remove_with(&path, &state, "chat models", "Test", options, &plan).unwrap()); + plan.apply().unwrap(); + + assert!( + path.exists(), + "a file we did not create must not be deleted" + ); + let remaining: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(remaining, json!([{"name": "user-vendor"}])); + } + + #[test] + fn object_root_callers_are_unaffected_by_root_array_support() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let state = state_path(&path); + + let plan = ReconcilePlan::default(); + plan_merge(&path, &state, managed(), false, "settings", "Test", &plan).unwrap(); + plan.apply().unwrap(); + let document: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert!(document.is_object(), "{document}"); + assert_eq!(document, json!({"env": {"MANAGED": "1"}})); + + let plan = ReconcilePlan::default(); + assert!(plan_remove(&path, &state, "settings", "Test", &plan).unwrap()); + plan.apply().unwrap(); + assert!(!path.exists()); + } + + #[test] + fn a_non_array_file_is_a_conflict_when_the_managed_document_is_an_array() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let state = state_path(&path); + std::fs::write(&path, b"42\n").unwrap(); + + let plan = ReconcilePlan::default(); + plan_merge_with( + &path, + &state, + json!([{"name": "agentdesktop"}]), + false, + "chat models", + "Test", + root_array_options(), + &plan, + ) + .unwrap(); + assert!(plan.has_conflicts(), "{}", plan.render()); + assert!(plan.apply().is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"42\n"); + } +} diff --git a/crates/agent/src/provider/mod.rs b/crates/agent/src/provider/mod.rs index b7629da5..465f8cf3 100644 --- a/crates/agent/src/provider/mod.rs +++ b/crates/agent/src/provider/mod.rs @@ -11,6 +11,7 @@ use crate::reconcile::ReconcilePlan; pub mod claude_code; pub mod claude_desktop; pub mod codex; +pub mod copilot; pub mod cursor; pub mod grok; mod json_merge; diff --git a/crates/agent/src/provider/vscode/discovery.rs b/crates/agent/src/provider/vscode/discovery.rs index 0751f701..a92a7add 100644 --- a/crates/agent/src/provider/vscode/discovery.rs +++ b/crates/agent/src/provider/vscode/discovery.rs @@ -84,17 +84,17 @@ fn mcp_config_paths() -> Vec { } #[cfg(target_os = "macos")] -fn user_profile_root(home: &Path) -> PathBuf { +pub(super) fn user_profile_root(home: &Path) -> PathBuf { home.join("Library/Application Support/Code/User") } #[cfg(target_os = "linux")] -fn user_profile_root(home: &Path) -> PathBuf { +pub(super) fn user_profile_root(home: &Path) -> PathBuf { home.join(".config/Code/User") } #[cfg(windows)] -fn user_profile_root(home: &Path) -> PathBuf { +pub(super) fn user_profile_root(home: &Path) -> PathBuf { home.join("AppData/Roaming/Code/User") } diff --git a/crates/agent/src/provider/vscode/integration_tests.rs b/crates/agent/src/provider/vscode/integration_tests.rs index 56464c86..f3694e20 100644 --- a/crates/agent/src/provider/vscode/integration_tests.rs +++ b/crates/agent/src/provider/vscode/integration_tests.rs @@ -4,7 +4,7 @@ use agentdesktop_core::{DEFAULT_SOCKET_PATH, model::Discovery}; use anyhow::{Context, ensure}; use tracing::info; -use crate::common::Container; +use crate::common::{Container, Gateway}; const VERSION: &str = "1.136.2"; const USER_MCP: &str = "/home/tester/.config/Code/User/mcp.json"; @@ -69,3 +69,329 @@ async fn headless_discovery() -> anyhow::Result<()> { Ok(()) }).await } + +const CONFIG: &str = "/tmp/agentdesktop-chat.yaml"; +const CHAT_MODELS: &str = "/home/tester/.config/Code/User/chatLanguageModels.json"; +const CHAT_SIDECAR: &str = "/home/tester/.config/Code/User/.chatLanguageModels.json.agentdesktop"; +const PAIRING: &str = "/home/tester/.local/state/agentdesktop/llm-proxy-pairing"; +const SOCKET: &str = "/run/user/1000/agentdesktop.sock"; +const LISTEN: &str = "127.0.0.1:18096"; + +/// The daemon runs as the user (`--user` through `runuser`, since the file +/// lives in the user's VS Code profile), writes `chatLanguageModels.json` +/// pointed at its loopback proxy, a client using the file's `url` and +/// `requestHeaders` reaches the gateway through the proxy, and removing the +/// program leaves the user's own vendor entry at the file's mode. +#[tokio::test] +async fn managed_chat_models_lifecycle() -> anyhow::Result<()> { + Container::run("vscode", "crates/agent/src/provider/vscode/testdata/Dockerfile", async |container| { + let gateway = Gateway::start().await?; + let user_vendor = serde_json::json!([{ + "name": "my-own", "vendor": "customendpoint", "apiKey": "sk-user", "apiType": "chat-completions", + "models": [{"id": "gpt-mine", "name": "mine", "url": "https://example.invalid/v1/chat/completions"}], + }]); + container.write(CHAT_MODELS, &serde_json::to_string_pretty(&user_vendor)?).await?; + container.exec(&["chown", "-R", "tester:tester", "/home/tester/.config"]).await?; + // A mode the daemon does not write itself, so "mode kept" can fail. + container.exec(&["chmod", "640", CHAT_MODELS]).await?; + let config = |with_program: bool| { + let mut document = serde_json::json!({ + "daemon": { "user": true, "llmProxy": { "listen": LISTEN } }, + "llmGateway": { "url": gateway.url }, + "programs": {}, + }); + if with_program { + document["programs"]["vscode"] = + serde_json::json!({ "models": { "gpt-4.1-mini": { "maxInputTokens": 128000 } } }); + } + serde_json::to_string_pretty(&document).unwrap() + }; + container.write(CONFIG, &config(true)).await?; + container.exec(&["chown", "tester:tester", CONFIG]).await?; + let daemon = ["runuser", "-u", "tester", "--", "agentdesktop", "daemon", "--config", CONFIG]; + container.start_process("daemon", &daemon).await?; + container.wait_ready(&["agentdesktop", "--socket", SOCKET, "status"]).await?; + + info!("Checking the written chat language models file"); + let pairing = container.exec(&["cat", PAIRING]).await?; + let pairing = pairing.trim().to_owned(); + let written: serde_json::Value = serde_json::from_str(&container.exec(&["cat", CHAT_MODELS]).await?)?; + let vendors = written.as_array().cloned().context("chatLanguageModels.json is not an array")?; + ensure!(vendors.iter().any(|vendor| vendor["name"] == "my-own"), "user vendor entry lost: {written}"); + let ours = vendors.iter().find(|vendor| vendor["name"] == "agentdesktop").cloned().context("managed vendor entry missing")?; + let model = &ours["models"][0]; + ensure!(model["id"] == "gpt-4.1-mini" && model["url"] == format!("http://{LISTEN}/vscode-copilot/v1/chat/completions"), "unexpected model: {ours}"); + ensure!(model["requestHeaders"]["x-agentdesktop-pairing"] == pairing, "pairing header mismatch: {ours}"); + ensure!(ours["apiKey"] == "unused", "no secret may be written as apiKey: {ours}"); + let mode = container.exec(&["stat", "-c", "%a", CHAT_MODELS]).await?; + ensure!(mode.trim() == "600", "a file the daemon writes is owner-only, got {mode}"); + container.exec(&["test", "-e", CHAT_SIDECAR]).await?; + + info!("Checking that a client using the file reaches the gateway through the proxy"); + let url = model["url"].as_str().context("model url")?.to_owned(); + let status = container.exec_as("tester", &["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "-H", &format!("x-agentdesktop-pairing: {pairing}"), "-H", "authorization: Bearer client-token", "-H", "x-api-key: client-key", "-H", "content-type: application/json", "-X", "POST", &url, "-d", "{\"model\":\"gpt-4.1-mini\",\"messages\":[]}"], Duration::from_secs(15)).await?; + // The stub knows no /v1/chat/completions route and answers 404, which + // the proxy passes through; what matters is that the request arrived + // upstream with the client's own Authorization and x-api-key stripped + // (the curl sends both) and, with no gateway authentication configured + // here, no Authorization added. + let requests = gateway.requests(); + let upstream = requests.iter().find(|request| request["path"] == "/v1/chat/completions").with_context(|| format!("no request reached the gateway (client saw {status}): {requests:?}"))?; + ensure!(upstream["authorization"].is_null() && upstream["apiKey"].is_null(), "client headers must not reach the gateway: {upstream}"); + + info!("Removing the program"); + // The daemon wrote the file 600 while managed; the user then set 640 + // by hand, which removal must keep. + container.exec(&["chmod", "640", CHAT_MODELS]).await?; + container.write(CONFIG, &config(false)).await?; + container.stop_process("daemon").await?; + container.start_process("daemon", &daemon).await?; + container.wait_ready(&["agentdesktop", "--socket", SOCKET, "status"]).await?; + let remaining: serde_json::Value = serde_json::from_str(&container.exec(&["cat", CHAT_MODELS]).await?)?; + let vendors = remaining.as_array().cloned().unwrap_or_default(); + ensure!(vendors.len() == 1 && vendors[0]["name"] == "my-own", "managed entry not removed: {remaining}"); + let mode = container.exec(&["stat", "-c", "%a", CHAT_MODELS]).await?; + ensure!(mode.trim() == "640", "removal must keep the user's file mode, got {mode}"); + container.exec(&["test", "!", "-e", CHAT_SIDECAR]).await?; + Ok(()) + }) + .await +} + +const SETTINGS: &str = "/home/tester/.config/Code/User/settings.json"; +const SETTINGS_SIDECAR: &str = "/home/tester/.config/Code/User/.settings.json.agentdesktop"; +const SETTINGS_LISTEN: &str = "127.0.0.1:18097"; +const OVERRIDE_KEY: &str = "github.copilot.advanced.debug.overrideCapiUrl"; +const CAPI_ALIAS_KEY: &str = "github.copilot.internal.capiUrl"; +const IGNORED_KEY: &str = "settingsSync.ignoredSettings"; + +/// A hand-rolled RFC 6455 echo on the host loopback (the container shares the +/// host network): answers the handshake, echoes the first frame unmasked, then +/// waits for the client to close. The request head is reported on the channel. +async fn websocket_echo() -> anyhow::Result<(String, tokio::sync::mpsc::UnboundedReceiver)> +{ + use base64::Engine; + use sha1::Digest; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let url = format!("http://{}/", listener.local_addr()?); + let (heads, seen) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let heads = heads.clone(); + tokio::spawn(async move { + let mut buffer = Vec::new(); + let mut byte = [0u8; 1]; + while !buffer.ends_with(b"\r\n\r\n") { + if socket + .read(&mut byte) + .await + .ok() + .filter(|read| *read == 1) + .is_none() + { + return; + } + buffer.push(byte[0]); + } + let head = String::from_utf8_lossy(&buffer).to_string(); + let key = head + .lines() + .find_map(|line| { + line.split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("sec-websocket-key")) + }) + .map(|(_, value)| value.trim().to_owned()) + .unwrap_or_default(); + let _ = heads.send(head); + let accept = base64::engine::general_purpose::STANDARD.encode(sha1::Sha1::digest( + format!("{key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11").as_bytes(), + )); + let response = format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n" + ); + if socket.write_all(response.as_bytes()).await.is_err() { + return; + } + // One masked text frame with a payload under 126 bytes. + let mut header = [0u8; 2]; + if socket.read_exact(&mut header).await.is_err() { + return; + } + let length = usize::from(header[1] & 0x7f); + let mut mask = [0u8; 4]; + if header[1] & 0x80 != 0 && socket.read_exact(&mut mask).await.is_err() { + return; + } + let mut payload = vec![0u8; length]; + if socket.read_exact(&mut payload).await.is_err() { + return; + } + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % 4]; + } + let mut frame = vec![0x81, length as u8]; + frame.extend(payload); + let _ = socket.write_all(&frame).await; + let _ = socket.read(&mut byte).await; + }); + } + }); + Ok((url, seen)) +} + +/// Opens a WebSocket through the proxy from the host (same loopback as the +/// container) and returns the response head and the first echoed payload. +async fn websocket_through_proxy(url: &str) -> anyhow::Result<(String, String)> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let uri: hyper::Uri = url.parse()?; + let authority = uri.authority().context("proxy authority")?.to_string(); + let mut socket = tokio::net::TcpStream::connect(&authority).await?; + let request = format!( + "GET {} HTTP/1.1\r\nHost: {authority}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nAuthorization: Bearer tid=x\r\n\r\n", + uri.path() + ); + socket.write_all(request.as_bytes()).await?; + let mut head = Vec::new(); + let mut byte = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + socket + .read_exact(&mut byte) + .await + .context("response head")?; + head.push(byte[0]); + } + let head = String::from_utf8_lossy(&head).to_string(); + ensure!( + head.starts_with("HTTP/1.1 101"), + "no 101 from the proxy: {head}" + ); + // "hi", masked with a zero key (legal, keeps the bytes readable). + socket + .write_all(&[0x81, 0x82, 0, 0, 0, 0, b'h', b'i']) + .await?; + let mut frame = [0u8; 4]; + tokio::time::timeout(Duration::from_secs(10), socket.read_exact(&mut frame)) + .await + .context("echo frame")??; + ensure!( + frame[0] == 0x81 && frame[1] == 2, + "unexpected frame header {:?}", + &frame[..2] + ); + Ok((head, String::from_utf8_lossy(&frame[2..]).to_string())) +} + +#[tokio::test] +async fn managed_settings_github_models_lifecycle() -> anyhow::Result<()> { + Container::run("vscode", "crates/agent/src/provider/vscode/testdata/Dockerfile", async |container| { + let gateway = Gateway::start().await?; + let (echo_url, mut echo_heads) = websocket_echo().await?; + // A commented file with a trailing comma, as VS Code users write it: + // the daemon edits it in place and the comment must survive. + let user_settings = format!( + "{{\n // keep this comment\n \"editor.fontSize\": 14,\n \"{IGNORED_KEY}\": [\"workbench.colorTheme\"],\n}}\n" + ); + container.write(SETTINGS, &user_settings).await?; + container.exec(&["chown", "-R", "tester:tester", "/home/tester/.config"]).await?; + // VS Code's own default for a settings file it created. + container.exec(&["chmod", "644", SETTINGS]).await?; + let config = |with_program: bool, proxy_url: &str| { + let mut document = serde_json::json!({ + "daemon": { "user": true, "llmProxy": { "listen": SETTINGS_LISTEN } }, + "llmGateway": { "url": gateway.url, "proxyUrl": proxy_url }, + "programs": {}, + }); + if with_program { + document["programs"]["vscode"] = serde_json::json!({ "copilotChat": "githubModels" }); + } + serde_json::to_string_pretty(&document).unwrap() + }; + container.write(CONFIG, &config(true, &gateway.url)).await?; + container.exec(&["chown", "tester:tester", CONFIG]).await?; + let daemon = ["runuser", "-u", "tester", "--", "agentdesktop", "daemon", "--config", CONFIG]; + container.start_process("daemon", &daemon).await?; + container.wait_ready(&["agentdesktop", "--socket", SOCKET, "status"]).await?; + + info!("Checking the written settings file"); + let pairing = container.exec(&["cat", PAIRING]).await?; + let pairing = pairing.trim().to_owned(); + let written_text = container.exec(&["cat", SETTINGS]).await?; + ensure!(written_text.contains("// keep this comment"), "comment lost on apply: {written_text}"); + let written = jsonc(&written_text)?; + let override_url = format!("http://{SETTINGS_LISTEN}/vscode-copilot-capi/{pairing}"); + ensure!(written[OVERRIDE_KEY] == override_url, "override URL: {written}"); + let ignored = written[IGNORED_KEY].as_array().cloned().context("ignoredSettings")?; + for entry in ["workbench.colorTheme", OVERRIDE_KEY, CAPI_ALIAS_KEY] { + ensure!(ignored.iter().any(|value| value == entry), "{entry} missing from {ignored:?}"); + } + ensure!(written["editor.fontSize"] == 14, "user setting lost: {written}"); + let mode = container.exec(&["stat", "-c", "%a", SETTINGS]).await?; + ensure!(mode.trim() == "600", "a file carrying the pairing is tightened to owner-only, got {mode}"); + container.exec(&["test", "-e", SETTINGS_SIDECAR]).await?; + + info!("Checking that a request with the URL's pairing segment reaches the gateway with the client token moved"); + let url = format!("{override_url}/v1/messages"); + let status = container.exec_as("tester", &["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "-H", "authorization: Bearer tid=x", "-H", "content-type: application/json", "-X", "POST", &url, "-d", "{\"model\":\"gpt-4.1\"}"], Duration::from_secs(15)).await?; + // The stub rejects any token but its own with 401, which the proxy + // passes through (a client token was forwarded, so no retry). + ensure!(status.trim() == "401", "expected the stub's 401 passed through, got {status}"); + let requests = gateway.requests(); + let upstream = requests.iter().find(|request| request["path"] == "/v1/messages").with_context(|| format!("no request reached the gateway (client saw {status}): {requests:?}"))?; + ensure!(upstream["authorization"].is_null(), "the client's Authorization must not reach the gateway as is: {upstream}"); + ensure!(upstream["llmToken"] == "tid=x", "the client token goes to x-llm-token: {upstream}"); + ensure!(requests.iter().all(|request| !request["path"].as_str().unwrap_or_default().contains(&pairing)), "the pairing must never be forwarded: {requests:?}"); + + info!("Checking the WebSocket tunnel against a host-side echo behind proxyUrl"); + container.write(CONFIG, &config(true, &echo_url)).await?; + container.stop_process("daemon").await?; + container.start_process("daemon", &daemon).await?; + container.wait_ready(&["agentdesktop", "--socket", SOCKET, "status"]).await?; + let (head, echoed) = websocket_through_proxy(&format!("{override_url}/responses")).await?; + ensure!(echoed == "hi", "echo payload: {echoed:?}"); + ensure!(head.to_ascii_lowercase().contains("sec-websocket-accept:"), "101 without the accept header: {head}"); + let seen = tokio::time::timeout(Duration::from_secs(5), echo_heads.recv()).await.context("echo saw no request")?.context("echo closed")?; + let seen_lower = seen.to_ascii_lowercase(); + ensure!(seen_lower.contains("x-llm-token: tid=x"), "client token not moved on the upgrade: {seen}"); + ensure!(!seen.contains(&pairing), "pairing forwarded on the upgrade: {seen}"); + + info!("Removing the program"); + // A mode the daemon does not write itself, so "mode kept" can fail. + container.exec(&["chmod", "640", SETTINGS]).await?; + container.write(CONFIG, &config(false, &gateway.url)).await?; + container.stop_process("daemon").await?; + container.start_process("daemon", &daemon).await?; + container.wait_ready(&["agentdesktop", "--socket", SOCKET, "status"]).await?; + let remaining_text = container.exec(&["cat", SETTINGS]).await?; + ensure!(remaining_text.contains("// keep this comment"), "comment lost on removal: {remaining_text}"); + let remaining = jsonc(&remaining_text)?; + ensure!(remaining.get(OVERRIDE_KEY).is_none(), "override not removed: {remaining}"); + ensure!(remaining[IGNORED_KEY] == serde_json::json!(["workbench.colorTheme"]), "user ignoredSettings entry not kept alone: {remaining}"); + ensure!(remaining["editor.fontSize"] == 14, "user setting lost on removal: {remaining}"); + let mode = container.exec(&["stat", "-c", "%a", SETTINGS]).await?; + ensure!(mode.trim() == "640", "removal must keep the user's file mode, got {mode}"); + container.exec(&["test", "!", "-e", SETTINGS_SIDECAR]).await?; + Ok(()) + }) + .await +} + +/// Parses VS Code's JSONC (comments, trailing commas) into a value. +fn jsonc(text: &str) -> anyhow::Result { + let options = jsonc_parser::ParseOptions { + allow_comments: true, + allow_trailing_commas: true, + allow_loose_object_property_names: false, + allow_missing_commas: false, + allow_single_quoted_strings: false, + allow_hexadecimal_numbers: false, + allow_unary_plus_numbers: false, + allow_bare_decimal_point_numbers: false, + allow_non_finite_numbers: false, + allow_extended_string_escapes: false, + }; + let root = jsonc_parser::cst::CstRootNode::parse(text, &options)?; + root.value() + .and_then(|value| value.to_serde_value()) + .context("settings.json holds no JSON value") +} diff --git a/crates/agent/src/provider/vscode/mod.rs b/crates/agent/src/provider/vscode/mod.rs index 6ed0d5d9..f9c08557 100644 --- a/crates/agent/src/provider/vscode/mod.rs +++ b/crates/agent/src/provider/vscode/mod.rs @@ -1,15 +1,47 @@ +use std::path::PathBuf; + +use agentdesktop_core::config::{DaemonConfig, VsCodeCopilotChat}; use agentdesktop_core::model::Discovery; -use super::Provider; +use super::{Provider, ReconcileContext}; +use crate::reconcile::ReconcilePlan; pub(super) mod discovery; +pub(crate) mod reconcile; +pub(super) mod settings; + +#[cfg(test)] +mod settings_tests; +#[cfg(test)] +mod tests; -pub struct VsCode; +/// VS Code: points Copilot Chat at the loopback LLM proxy, either through the +/// "Custom Endpoint" model provider (`chatLanguageModels.json`, own models on +/// the `/vscode-copilot` route) or through the Copilot Chat CAPI override in +/// the user `settings.json` (GitHub's models on the `/vscode-copilot-capi` +/// route). User mode only; both paths are `None` for a system daemon, which +/// has no user profile to manage. +pub struct VsCode { + pub chat_models_path: Option, + pub settings_path: Option, +} impl VsCode { pub const ID: &'static str = "vscode"; pub const DISPLAY_NAME: &'static str = "VS Code"; } +/// The user's `chatLanguageModels.json` for the resolved home directory +/// (per-OS VS Code user profile root). +pub fn default_vscode_chat_models_path(home: &std::path::Path) -> PathBuf { + reconcile::chat_models_path(home) +} + +/// The user's `settings.json` for the resolved home directory (per-OS VS Code +/// user profile root). +pub fn default_vscode_settings_path(home: &std::path::Path) -> PathBuf { + settings::settings_path(home) +} + #[async_trait::async_trait] impl Provider for VsCode { async fn discover(&self) -> Discovery { @@ -18,4 +50,43 @@ impl Provider for VsCode { model_runtimes: Vec::new(), } } + + fn plan(&self, ctx: &ReconcileContext, config: &DaemonConfig) -> anyhow::Result { + let configured = config.programs.vscode.as_ref().map(|program| { + let gateway = config + .llm_gateway + .as_ref() + .filter(|_| program.use_llm_gateway); + (program, gateway) + }); + let proxy = ctx + .llm_proxy + .as_ref() + .map(|proxy| (proxy.address, &*proxy.pairing)); + let plan = ReconcilePlan::default(); + // Both files live in the user's VS Code profile, so a system daemon has + // no paths and rejects the program before any provider writes. + let paths = match (&self.chat_models_path, &self.settings_path) { + (Some(chat_models), Some(settings)) if ctx.merge_user_settings => { + Some((chat_models.as_path(), settings.as_path())) + } + _ => None, + }; + let Some((chat_models_path, settings_path)) = paths else { + if configured.is_some() { + anyhow::bail!( + "{} Copilot Chat reads its custom models (chatLanguageModels.json) and the gateway override (settings.json) from the user's own VS Code profile; remove programs.vscode or run agentdesktop with --user (daemon.user: true) so it can manage those files", + Self::DISPLAY_NAME + ); + } + return Ok(plan); + }; + // The two variants are exclusive: under `githubModels` the custom-models + // entry is removed, under `ownModels` the settings override is removed. + let own_models = + configured.filter(|(program, _)| program.copilot_chat == VsCodeCopilotChat::OwnModels); + reconcile::plan(chat_models_path, proxy, own_models, &plan)?; + settings::plan(settings_path, proxy, configured, &plan)?; + Ok(plan) + } } diff --git a/crates/agent/src/provider/vscode/reconcile.rs b/crates/agent/src/provider/vscode/reconcile.rs new file mode 100644 index 00000000..bbb1f317 --- /dev/null +++ b/crates/agent/src/provider/vscode/reconcile.rs @@ -0,0 +1,267 @@ +use std::{ + net::SocketAddr, + path::{Path, PathBuf}, +}; + +use agentdesktop_core::config::{LlmGatewayConfig, VsCodeConfig}; +use anyhow::Context; +use serde_json::{Value, json}; +use tracing::warn; + +use super::{VsCode, discovery}; +use crate::provider::json_merge; +use crate::reconcile::ReconcilePlan; + +/// What the plan report calls the managed part of the file. +const DESCRIPTION: &str = "chat language models"; +/// The file carries the pairing value, so a file we write is owner-only. +const FILE_MODE: u32 = 0o600; + +/// `chatLanguageModels.json` inside the VS Code user profile directory for +/// `home` (the per-OS root VS Code itself reads, shared with MCP discovery). +pub(super) fn chat_models_path(home: &Path) -> PathBuf { + discovery::user_profile_root(home).join("chatLanguageModels.json") +} + +/// The JSON root ARRAY merged into `chatLanguageModels.json`: one vendor +/// entry named `agentdesktop` (VS Code's "Custom Endpoint" Copilot Chat +/// provider) pointed at the loopback proxy's `/vscode-copilot` route, with +/// one model entry per configured model. No secret `apiKey` is written: VS +/// Code's schema requires the field, but the pairing travels in +/// `requestHeaders` instead. +pub(super) fn managed_document( + config: &VsCodeConfig, + listen: SocketAddr, + pairing: &str, +) -> anyhow::Result { + let models: Vec = config + .model_documents() + .into_iter() + .map(|mut model| { + if let Some(object) = model.as_object_mut() { + object.insert( + "url".to_owned(), + Value::String(format!( + "http://{listen}/vscode-copilot/v1/chat/completions" + )), + ); + object.insert( + "requestHeaders".to_owned(), + json!({ crate::llm_proxy::PAIRING_HEADER: pairing }), + ); + } + model + }) + .collect(); + Ok(json!([{ + "name": VsCodeConfig::VENDOR_NAME, + "vendor": "customendpoint", + "apiKey": "unused", + "apiType": "chat-completions", + "models": models, + }])) +} + +/// The single array agentdesktop manages by key: vendor entries by `name`, at +/// the document root (`field: ""`). Unlike the Copilot CLI's `providers.json`, +/// the vendor's `models` sub-array is not separately keyed: the whole vendor +/// entry is owned and replaced wholesale on every apply. +const KEYED: &[json_merge::KeyedArray] = &[json_merge::KeyedArray { + field: "", + keys: &["name"], +}]; + +fn options() -> json_merge::MergeOptions { + json_merge::MergeOptions { + mode: FILE_MODE, + keyed_arrays: KEYED, + // The file holds the pairing. + redact_diff: true, + } +} + +/// Plans the create/update/remove/conflict for `chatLanguageModels.json`. +/// +/// With the program absent, the gateway off for it (`useLlmGateway: false` or +/// no top-level `llmGateway`), or no loopback proxy, the managed vendor entry +/// is removed and the user's own entries stay. Otherwise the managed document +/// is merged in. The vendor entry named `agentdesktop` belongs to agentdesktop +/// once it has written the file; before that, a user vendor entry under that +/// name is a conflict, unless it carries a pairing header on one of its +/// models (then it was written by an agentdesktop daemon whose sidecar is +/// gone, and it is replaced). +pub(super) fn plan( + path: &Path, + proxy: Option<(SocketAddr, &str)>, + configured: Option<(&VsCodeConfig, Option<&LlmGatewayConfig>)>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + let state_path = json_merge::state_path(path); + let pairing = proxy.map(|(_, pairing)| pairing); + let Some((config, Some(_gateway))) = configured else { + remove(path, &state_path, pairing, plan)?; + return Ok(()); + }; + let Some((listen, pairing)) = proxy else { + warn!( + path = %path.display(), + "programs.vscode is configured but the local LLM proxy is not available, so VS Code is not pointed at the gateway; the reason is llmProxy.error in daemon-info (or daemon.llmProxy.listen is unset); removing the managed chat language models" + ); + remove(path, &state_path, None, plan)?; + return Ok(()); + }; + let managed = managed_document(config, listen, pairing)?; + if let Some(reason) = foreign_managed_entry(path, &state_path, plan)? { + warn!(path = %path.display(), reason, "refusing to change the VS Code chat language models file"); + plan.record( + VsCode::DISPLAY_NAME, + &format!("{DESCRIPTION} ({reason})"), + "conflict", + path, + ); + return Ok(()); + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + && !parent.exists() + { + plan.ensure_private_dir(parent); + } + json_merge::plan_merge_with( + path, + &state_path, + managed, + false, + DESCRIPTION, + VsCode::DISPLAY_NAME, + options(), + plan, + ) +} + +/// Managed entries are removed through the sidecar when there is one, and by +/// this daemon's pairing value when there is none. +fn remove( + path: &Path, + state_path: &Path, + pairing: Option<&str>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + if !json_merge::plan_remove_with( + path, + state_path, + DESCRIPTION, + VsCode::DISPLAY_NAME, + options(), + plan, + )? && let Some(pairing) = pairing + { + plan_remove_orphaned(path, pairing, plan)?; + } + Ok(()) +} + +/// Whether a vendor entry carries the pairing header on one of its models, +/// which only an agentdesktop daemon writes. With `Some(value)`, only that +/// pairing value counts. +fn carries_pairing(vendor: &Value, value: Option<&str>) -> bool { + vendor["models"] + .as_array() + .into_iter() + .flatten() + .any(|model| { + let header = &model["requestHeaders"][crate::llm_proxy::PAIRING_HEADER]; + match value { + Some(value) => header == value, + None => !header.is_null(), + } + }) +} + +/// Before agentdesktop has written the file (no sidecar), a vendor entry named +/// `agentdesktop` without a pairing header on any model is the user's. +fn foreign_managed_entry( + path: &Path, + state_path: &Path, + plan: &ReconcilePlan, +) -> anyhow::Result> { + if json_merge::managed_after(state_path, VsCode::DISPLAY_NAME, plan)?.is_some() { + return Ok(None); + } + let Some(current) = read_array(path, plan)? else { + return Ok(None); + }; + for vendor in ¤t { + if vendor["name"] == VsCodeConfig::VENDOR_NAME && !carries_pairing(vendor, None) { + return Ok(Some(format!( + "vendor entry {} exists but was not written by agentdesktop", + VsCodeConfig::VENDOR_NAME + ))); + } + } + Ok(None) +} + +/// Removal without a sidecar: the vendor entry named `agentdesktop` whose +/// models carry this daemon's own pairing value was written for this daemon, +/// so it is taken out; the file keeps its mode and is never deleted here. A +/// file that cannot be read is skipped with a warning. +fn plan_remove_orphaned(path: &Path, pairing: &str, plan: &ReconcilePlan) -> anyhow::Result<()> { + let current = match read_array(path, plan) { + Ok(Some(current)) => current, + Ok(None) => return Ok(()), + Err(error) => { + warn!(error = %format!("{error:#}"), "skipping the VS Code chat language models file"); + return Ok(()); + } + }; + let remaining: Vec = current + .iter() + .filter(|vendor| { + !(vendor["name"] == VsCodeConfig::VENDOR_NAME && carries_pairing(vendor, Some(pairing))) + }) + .cloned() + .collect(); + if remaining.len() == current.len() { + return Ok(()); + } + let existing = plan.read(path)?; + let mut contents = serde_json::to_vec_pretty(&Value::Array(remaining)) + .with_context(|| format!("serialize {} {DESCRIPTION}", VsCode::DISPLAY_NAME))?; + contents.push(b'\n'); + if contents == existing { + return Ok(()); + } + plan.record_diff( + VsCode::DISPLAY_NAME, + DESCRIPTION, + "update", + path, + None, + None, + ); + let mode = json_merge::current_mode(path).unwrap_or(FILE_MODE); + plan.write_file(path, &contents, mode) +} + +fn read_array(path: &Path, plan: &ReconcilePlan) -> anyhow::Result>> { + let existing = match plan.read(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error).with_context(|| { + format!( + "read {} {DESCRIPTION} from {}", + VsCode::DISPLAY_NAME, + path.display() + ) + }); + } + }; + // Anything that is not a JSON array is left to the merge, which reports it. + Ok(match serde_json::from_slice::(&existing) { + Ok(Value::Array(array)) => Some(array), + _ => None, + }) +} diff --git a/crates/agent/src/provider/vscode/settings.rs b/crates/agent/src/provider/vscode/settings.rs new file mode 100644 index 00000000..3f91531b --- /dev/null +++ b/crates/agent/src/provider/vscode/settings.rs @@ -0,0 +1,664 @@ +//! VS Code user `settings.json` management for the `githubModels` variant of +//! `copilotChat`: points Copilot Chat's CAPI endpoint +//! (`github.copilot.advanced.debug.overrideCapiUrl`) at the loopback proxy's +//! `/vscode-copilot-capi/` route, so VS Code keeps GitHub's own +//! models and its own Copilot token while the daemon adds the gateway +//! identity. +//! +//! The file is VS Code's JSONC, commonly commented and hand-edited, so it is +//! edited in place with a lossless syntax tree (`jsonc-parser`): only the +//! override and the two `settingsSync.ignoredSettings` entries change, and +//! comments, key order, indentation, line endings and a leading byte-order +//! mark stay as the user wrote them (a single-line object is expanded to one +//! property per line). The sidecar records only what removal must restore. + +use std::{ + net::SocketAddr, + path::{Path, PathBuf}, +}; + +use agentdesktop_core::config::{LlmGatewayConfig, VsCodeConfig, VsCodeCopilotChat}; +use anyhow::Context; +use jsonc_parser::{ + ParseOptions, + cst::{CstInputValue, CstObject, CstRootNode}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tracing::{debug, warn}; + +use super::{VsCode, discovery}; +use crate::provider::json_merge; +use crate::reconcile::ReconcilePlan; + +/// The setting VS Code's Copilot Chat reads for its CAPI base URL (the legacy +/// name; `github.copilot.internal.capiUrl` is the newer alias). +pub(super) const OVERRIDE_KEY: &str = "github.copilot.advanced.debug.overrideCapiUrl"; +const CAPI_ALIAS_KEY: &str = "github.copilot.internal.capiUrl"; +const IGNORED_SETTINGS_KEY: &str = "settingsSync.ignoredSettings"; +/// What the plan report calls the managed part of the file. +const DESCRIPTION: &str = "settings"; +/// The file carries the pairing value, so a file we write is owner-only. +const FILE_MODE: u32 = 0o600; + +/// The user's `settings.json` for the resolved home directory (per-OS VS Code +/// user profile root, shared with `chatLanguageModels.json` and MCP +/// discovery). +pub(super) fn settings_path(home: &Path) -> PathBuf { + discovery::user_profile_root(home).join("settings.json") +} + +/// The override URL for a listener and pairing: the pairing is the first path +/// segment of the `/vscode-copilot-capi` route, because VS Code cannot add a +/// header to these requests. +pub(super) fn override_url(listen: SocketAddr, pairing: &str) -> String { + format!("http://{listen}{}/{pairing}", crate::llm_proxy::CAPI_ROUTE) +} + +/// Why `edit_settings`/`remove_settings` refuse to touch the file: +/// anything beyond VS Code's own JSONC (comments, trailing commas), a +/// non-object root, `settingsSync.ignoredSettings` present and not an array, +/// or the override key or `settingsSync.ignoredSettings` appearing more than +/// once at the root (VS Code would read a different copy than the one +/// edited). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SettingsConflict { + Parse, + NotAnObject, + IgnoredNotArray, + DuplicateKey, +} + +impl SettingsConflict { + fn reason(self) -> &'static str { + match self { + Self::Parse => { + "not valid VS Code JSONC (only comments and trailing commas are accepted beyond JSON)" + } + Self::NotAnObject => "the top level is not an object", + Self::IgnoredNotArray => "settingsSync.ignoredSettings is not an array", + Self::DuplicateKey => "a managed key appears more than once", + } + } +} + +/// The sidecar: what removal must restore, without a copy of the user's +/// settings. `override_before` keeps an explicit JSON `null` as +/// `Some(Value::Null)`, distinct from the key being absent (`None`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct SettingsState { + pub version: u32, + pub created: bool, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "some_or_explicit_null" + )] + pub override_before: Option, + pub added_ignored: Vec, + pub ignored_created: bool, +} + +/// A present-but-`null` field deserializes to `Some(Value::Null)`; an absent +/// field is left at its `#[serde(default)]` (`None`) by serde before this +/// function is ever called. +fn some_or_explicit_null<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Value::deserialize(deserializer).map(Some) +} + +/// The sidecar version `edit_settings` writes; a sidecar without a `version` +/// (the whole-document `json_merge` state an earlier daemon wrote) is +/// upgraded by `read_state`. +pub(super) const SETTINGS_STATE_VERSION: u32 = 2; + +/// The outcome of `remove_settings`: nothing to change, rewritten text, or +/// the whole file going away (a file this daemon created that is empty once +/// the managed keys are taken out). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Removal { + Unchanged, + Write(String), + Delete, +} + +/// VS Code's JSONC: comments and trailing commas, nothing else (VS Code +/// flags the other JSON5 forms as errors, so a file using them is mid-edit or +/// broken and is left alone). +fn vscode_jsonc() -> ParseOptions { + ParseOptions { + allow_comments: true, + allow_trailing_commas: true, + allow_loose_object_property_names: false, + allow_missing_commas: false, + allow_single_quoted_strings: false, + allow_hexadecimal_numbers: false, + allow_unary_plus_numbers: false, + allow_bare_decimal_point_numbers: false, + allow_non_finite_numbers: false, + allow_extended_string_escapes: false, + } +} + +/// Parses the text and returns the root (which must outlive every node taken +/// from it) and its object, checking the shape the edit relies on. +fn parse_object(text: &str) -> Result<(CstRootNode, CstObject), SettingsConflict> { + let root = CstRootNode::parse(text, &vscode_jsonc()).map_err(|_| SettingsConflict::Parse)?; + let object = root + .object_value_or_create() + .ok_or(SettingsConflict::NotAnObject)?; + for key in [OVERRIDE_KEY, IGNORED_SETTINGS_KEY] { + let count = object + .properties() + .iter() + .filter(|property| property.decoded_name().as_deref() == Some(key)) + .count(); + if count > 1 { + return Err(SettingsConflict::DuplicateKey); + } + } + if object.get(IGNORED_SETTINGS_KEY).is_some() + && object.array_value(IGNORED_SETTINGS_KEY).is_none() + { + return Err(SettingsConflict::IgnoredNotArray); + } + Ok((root, object)) +} + +/// A `serde_json::Value` as a syntax-tree input value (`jsonc-parser` has no +/// conversion of its own); numbers keep their JSON text. +fn input_value(value: &Value) -> CstInputValue { + match value { + Value::Null => CstInputValue::Null, + Value::Bool(value) => CstInputValue::Bool(*value), + Value::Number(number) => CstInputValue::Number(number.to_string()), + Value::String(text) => CstInputValue::String(text.clone()), + Value::Array(items) => CstInputValue::Array(items.iter().map(input_value).collect()), + Value::Object(fields) => CstInputValue::Object( + fields + .iter() + .map(|(key, value)| (key.clone(), input_value(value))) + .collect(), + ), + } +} + +fn is_string(node: &jsonc_parser::cst::CstNode, text: &str) -> bool { + node.to_serde_value().as_ref().and_then(Value::as_str) == Some(text) +} + +/// In-place edit of `settings.json`: `text: None` is an absent file (created +/// as `{}` plus our keys); an empty or whitespace-only file is treated as +/// `{}`. Sets the override to `url` (in place when the key exists, appended +/// otherwise) and makes `settingsSync.ignoredSettings` hold both +/// `OVERRIDE_KEY` and `CAPI_ALIAS_KEY`, creating the array when absent and +/// appending only the missing entries. `state` is the previous sidecar +/// (`None` on a first apply or when it could not be read); the returned +/// state is what removal needs afterwards. +pub(super) fn edit_settings( + text: Option<&str>, + url: &str, + state: Option<&SettingsState>, +) -> Result<(String, SettingsState), SettingsConflict> { + // A UTF-8 byte-order mark (Windows PowerShell 5.1 writes one) is kept as + // it is and edited around. + let (bom, text) = match text { + Some(text) => { + let (bom, rest) = split_bom(text); + (bom, Some(rest)) + } + None => ("", None), + }; + let source = match text { + Some(text) if !text.trim().is_empty() => text, + _ => "{}\n", + }; + let (root, object) = parse_object(source)?; + + let existing = object.get(OVERRIDE_KEY); + let existing_value = existing + .as_ref() + .and_then(|property| property.value()) + .and_then(|value| value.to_serde_value()); + // New properties go at the top of the object: appending after the last + // one would add a comma to the user's last line, inserting first leaves + // every line the user wrote as it was. + let mut inserted = 0; + match &existing { + Some(property) => property.set_value(CstInputValue::String(url.to_owned())), + None => { + object.insert( + inserted, + OVERRIDE_KEY, + CstInputValue::String(url.to_owned()), + ); + inserted += 1; + } + } + + let ignored_created_now = object.get(IGNORED_SETTINGS_KEY).is_none(); + let mut appended = Vec::new(); + if ignored_created_now { + object.insert( + inserted, + IGNORED_SETTINGS_KEY, + CstInputValue::Array( + [OVERRIDE_KEY, CAPI_ALIAS_KEY] + .map(|entry| CstInputValue::String(entry.to_owned())) + .to_vec(), + ), + ); + appended.extend([OVERRIDE_KEY, CAPI_ALIAS_KEY].map(str::to_owned)); + } else { + // `parse_object` has already checked that the property is an array. + let ignored = object + .array_value(IGNORED_SETTINGS_KEY) + .ok_or(SettingsConflict::IgnoredNotArray)?; + for entry in [OVERRIDE_KEY, CAPI_ALIAS_KEY] { + if !ignored.elements().iter().any(|node| is_string(node, entry)) { + ignored.append(CstInputValue::String(entry.to_owned())); + appended.push(entry.to_owned()); + } + } + } + let only_ours = object + .array_value(IGNORED_SETTINGS_KEY) + .is_some_and(|ignored| { + ignored + .elements() + .iter() + .all(|node| is_string(node, OVERRIDE_KEY) || is_string(node, CAPI_ALIAS_KEY)) + }); + + let new_state = match state { + // The first apply's record stays: the value the user had, whether the + // daemon created the file and the array. A hand edit of the override + // since then is drift, never the user's value. + Some(previous) => { + let mut added = previous.added_ignored.clone(); + for entry in appended { + if !added.contains(&entry) { + added.push(entry); + } + } + SettingsState { + version: SETTINGS_STATE_VERSION, + // A file or list the user deleted and this apply recreated is + // the daemon's too. + created: previous.created || text.is_none(), + override_before: previous.override_before.clone(), + added_ignored: added, + ignored_created: previous.ignored_created || ignored_created_now, + } + } + None => { + // Without a record, an override equal to our own URL or to another + // agentdesktop loopback CAPI URL was written by a daemon: it is not the user's value, our entries + // next to it are ours too, and an ignore list holding nothing but + // them was created by it. + let ours = existing_value + .as_ref() + .and_then(Value::as_str) + .is_some_and(|value| value == url || is_daemon_capi_url(value)); + let added_ignored = if ours { + [OVERRIDE_KEY, CAPI_ALIAS_KEY].map(str::to_owned).to_vec() + } else { + appended + }; + SettingsState { + version: SETTINGS_STATE_VERSION, + created: text.is_none(), + override_before: if ours { None } else { existing_value }, + added_ignored, + ignored_created: ignored_created_now || (ours && only_ours), + } + } + }; + let edited = format!("{bom}{root}"); + Ok((edited, new_state)) +} + +fn split_bom(text: &str) -> (&str, &str) { + match text.strip_prefix('\u{feff}') { + Some(rest) => ("\u{feff}", rest), + None => ("", text), + } +} + +/// Whether an override value points at an agentdesktop loopback CAPI route +/// (`http://:/vscode-copilot-capi/`): the daemon's +/// own URL, or one an earlier listen address or pairing left behind. Such a +/// value is never the user's own setting. +fn is_daemon_capi_url(value: &str) -> bool { + let Some(rest) = value.strip_prefix("http://") else { + return false; + }; + let (authority, path) = rest.split_at(rest.find('/').unwrap_or(rest.len())); + let loopback = authority + .parse::() + .is_ok_and(|address| address.ip().is_loopback()) + || authority + .rsplit_once(':') + .is_some_and(|(host, _)| host.eq_ignore_ascii_case("localhost")); + loopback + && path + .strip_prefix(crate::llm_proxy::CAPI_ROUTE) + .is_some_and(|tail| tail.starts_with('/') && tail.len() > 1) +} + +/// In-place removal of the managed keys. With a sidecar: the override is +/// restored to `override_before` (in place, keeping a same-line comment; +/// appended when the user deleted the property meanwhile; removed when there +/// was none), `added_ignored` entries are taken out, and the array property +/// goes when the daemon created it and it is now empty. Without a sidecar, +/// an override equal to `own_url` or to any agentdesktop loopback CAPI URL, +/// and our two entries, are removed by value; an unparseable file is left +/// alone. +pub(super) fn remove_settings( + text: &str, + state: Option<&SettingsState>, + own_url: Option<&str>, +) -> Result { + let original = text; + let (bom, text) = split_bom(text); + if text.trim().is_empty() { + return Ok(match state { + Some(state) if state.created => Removal::Delete, + _ => Removal::Unchanged, + }); + } + let (root, object) = match parse_object(text) { + Ok(parsed) => parsed, + Err(conflict) if state.is_some() => return Err(conflict), + Err(_) => { + debug!( + "VS Code settings file is not valid VS Code JSONC; leaving it alone (no sidecar, nothing known to remove)" + ); + return Ok(Removal::Unchanged); + } + }; + + // What the override goes back to (`None`: removed), which entries come + // out of the ignore list, and whether an emptied list goes too. + let (restore, entries, drop_empty_array) = match state { + Some(state) => ( + state.override_before.clone(), + state.added_ignored.clone(), + state.ignored_created, + ), + None => { + let current = object + .get(OVERRIDE_KEY) + .and_then(|property| property.value()) + .and_then(|value| value.to_serde_value()); + let ours = current + .as_ref() + .and_then(Value::as_str) + .is_some_and(|value| own_url == Some(value) || is_daemon_capi_url(value)); + if !ours { + return Ok(Removal::Unchanged); + } + ( + None, + [OVERRIDE_KEY, CAPI_ALIAS_KEY].map(str::to_owned).to_vec(), + true, + ) + } + }; + + match (object.get(OVERRIDE_KEY), restore) { + (Some(property), Some(value)) => property.set_value(input_value(&value)), + (Some(property), None) => property.remove(), + (None, Some(value)) => { + object.append(OVERRIDE_KEY, input_value(&value)); + } + (None, None) => {} + } + if let Some(ignored) = object.array_value(IGNORED_SETTINGS_KEY) { + for node in ignored.elements() { + if entries.iter().any(|entry| is_string(&node, entry)) { + node.remove(); + } + } + if drop_empty_array + && ignored.elements().is_empty() + && let Some(property) = object.get(IGNORED_SETTINGS_KEY) + { + property.remove(); + } + } + + let body = root.to_string(); + let remaining = format!("{bom}{body}"); + if state.is_some_and(|state| state.created) + && body + .chars() + .filter(|c| !c.is_whitespace()) + .collect::() + == "{}" + { + return Ok(Removal::Delete); + } + Ok(if remaining == original { + Removal::Unchanged + } else { + Removal::Write(remaining) + }) +} + +/// The whole-document state an earlier daemon wrote through `json_merge`. +#[derive(Deserialize)] +struct LegacyMergeState { + created: bool, + before: Value, +} + +/// Reads a sidecar: the current form, or the earlier whole-document form +/// upgraded; `None` when the bytes are neither (the caller warns). +pub(super) fn read_state(bytes: &[u8]) -> Option { + let value: Value = serde_json::from_slice(bytes).ok()?; + if value.get("version").is_some() { + return serde_json::from_value::(value) + .ok() + .filter(|state| state.version == SETTINGS_STATE_VERSION); + } + let legacy: LegacyMergeState = serde_json::from_value(value).ok()?; + let before_ignored = legacy.before.get(IGNORED_SETTINGS_KEY); + let held = |entry: &str| { + before_ignored + .and_then(Value::as_array) + .is_some_and(|entries| entries.iter().any(|value| value == entry)) + }; + Some(SettingsState { + version: SETTINGS_STATE_VERSION, + created: legacy.created, + override_before: legacy.before.get(OVERRIDE_KEY).cloned(), + added_ignored: [OVERRIDE_KEY, CAPI_ALIAS_KEY] + .into_iter() + .filter(|entry| !held(entry)) + .map(str::to_owned) + .collect(), + ignored_created: before_ignored.is_none(), + }) +} + +/// Plans the create/update/remove/conflict for the user `settings.json`. +/// +/// Edits only when the program is set with `copilotChat: githubModels`, +/// uses the gateway, the gateway has `proxyUrl` and the loopback proxy is +/// available; removes the managed keys otherwise (with a warning when the +/// proxy is the missing part), the same decision shape as `reconcile::plan`. +pub(super) fn plan( + path: &Path, + proxy: Option<(SocketAddr, &str)>, + configured: Option<(&VsCodeConfig, Option<&LlmGatewayConfig>)>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + let state_path = json_merge::state_path(path); + let active = matches!( + configured, + Some((config, Some(gateway))) + if config.copilot_chat == VsCodeCopilotChat::GithubModels && gateway.proxy_url.is_some() + ); + if !active { + return remove(path, &state_path, proxy, plan); + } + let Some((listen, pairing)) = proxy else { + warn!( + path = %path.display(), + "programs.vscode uses copilotChat: githubModels but the local LLM proxy is not available, so VS Code is not pointed at the gateway; the reason is llmProxy.error in daemon-info (or daemon.llmProxy.listen is unset); removing the managed settings" + ); + return remove(path, &state_path, None, plan); + }; + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + && !parent.exists() + { + plan.ensure_private_dir(parent); + } + + let existing = read_optional(path, plan)?; + let (sidecar, state) = read_sidecar(&state_path, plan)?; + let text = match existing.as_deref().map(std::str::from_utf8) { + Some(Ok(text)) => Some(text), + Some(Err(_)) => return conflict(path, SettingsConflict::Parse, plan), + None => None, + }; + let (edited, new_state) = + match edit_settings(text, &override_url(listen, pairing), state.as_ref()) { + Ok(result) => result, + Err(kind) => return conflict(path, kind, plan), + }; + + // An unchanged file is not rewritten, unless its mode lets others read + // it (VS Code saves it 664): then the same bytes are written 0600. + let looser = json_merge::current_mode(path).is_some_and(|mode| mode & 0o077 != 0); + let action = match text { + None => "create", + Some(text) if text == edited && !looser => "unchanged", + Some(_) => "update", + }; + // The sidecar is written first: if the apply stops between the two + // writes, the record of the user's own value already exists. + let mut state_bytes = serde_json::to_vec_pretty(&new_state) + .with_context(|| format!("serialize {} {DESCRIPTION} state", VsCode::DISPLAY_NAME))?; + state_bytes.push(b'\n'); + if sidecar.as_deref() != Some(state_bytes.as_slice()) { + plan.write_file(&state_path, &state_bytes, FILE_MODE)?; + } + if action == "unchanged" { + plan.record(VsCode::DISPLAY_NAME, DESCRIPTION, action, path); + } else { + // Redacted: the file holds the pairing and possibly the user's own + // secrets. + plan.record_diff(VsCode::DISPLAY_NAME, DESCRIPTION, action, path, None, None); + plan.write_file(path, edited.as_bytes(), FILE_MODE)?; + } + + debug!(provider = VsCode::DISPLAY_NAME, action, path = %path.display(), "planned VS Code settings edit"); + Ok(()) +} + +/// Removes the managed keys: through the sidecar when there is one, by value +/// (an override pointing at an agentdesktop loopback CAPI route) when there +/// is none. The file keeps its mode. Like the other managed files, `unchanged` +/// is recorded only when there was something of ours to remove (a sidecar). +fn remove( + path: &Path, + state_path: &Path, + proxy: Option<(SocketAddr, &str)>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + let (sidecar, state) = read_sidecar(state_path, plan)?; + let existing = match read_optional(path, plan) { + Ok(existing) => existing, + Err(error) if state.is_none() => { + warn!(error = %format!("{error:#}"), "skipping the VS Code settings file"); + return Ok(()); + } + Err(error) => return Err(error), + }; + let own_url = proxy.map(|(listen, pairing)| override_url(listen, pairing)); + if let Some(existing) = existing { + let removal = match std::str::from_utf8(&existing) { + Ok(text) => remove_settings(text, state.as_ref(), own_url.as_deref()), + Err(_) if state.is_some() => Err(SettingsConflict::Parse), + Err(_) => { + debug!( + path = %path.display(), + "VS Code settings file is not UTF-8; leaving it alone (no sidecar, nothing known to remove)" + ); + Ok(Removal::Unchanged) + } + }; + match removal { + Ok(Removal::Unchanged) => { + if sidecar.is_some() { + plan.record(VsCode::DISPLAY_NAME, DESCRIPTION, "unchanged", path); + } + } + Ok(Removal::Write(text)) => { + plan.record_diff( + VsCode::DISPLAY_NAME, + DESCRIPTION, + "update", + path, + None, + None, + ); + let mode = json_merge::current_mode(path).unwrap_or(FILE_MODE); + plan.write_file(path, text.as_bytes(), mode)?; + } + Ok(Removal::Delete) => { + plan.record(VsCode::DISPLAY_NAME, DESCRIPTION, "remove", path); + plan.remove_file(path) + .with_context(|| format!("remove {}", path.display()))?; + } + Err(kind) => return conflict(path, kind, plan), + } + } else if sidecar.is_some() { + plan.record(VsCode::DISPLAY_NAME, DESCRIPTION, "unchanged", path); + } + if sidecar.is_some() { + plan.remove_file(state_path) + .with_context(|| format!("remove {}", state_path.display()))?; + } + Ok(()) +} + +fn conflict(path: &Path, kind: SettingsConflict, plan: &ReconcilePlan) -> anyhow::Result<()> { + warn!(path = %path.display(), reason = kind.reason(), "refusing to change the VS Code settings file"); + plan.record(VsCode::DISPLAY_NAME, DESCRIPTION, "conflict", path); + Ok(()) +} + +fn read_optional(path: &Path, plan: &ReconcilePlan) -> anyhow::Result>> { + match plan.read(path) { + Ok(contents) => Ok(Some(contents)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("read {}", path.display())), + } +} + +/// The sidecar's bytes and its parsed state. A sidecar that cannot be parsed +/// is a warning (the sidecar-less rules apply and the next write replaces +/// it); one that cannot be read at all (permissions) is an error, as for any +/// managed file. +fn read_sidecar( + state_path: &Path, + plan: &ReconcilePlan, +) -> anyhow::Result<(Option>, Option)> { + let bytes = read_optional(state_path, plan)?; + let state = bytes.as_deref().and_then(|bytes| { + let state = read_state(bytes); + if state.is_none() { + warn!(path = %state_path.display(), "ignoring a VS Code settings sidecar that cannot be parsed"); + } + state + }); + Ok((bytes, state)) +} diff --git a/crates/agent/src/provider/vscode/settings_tests.rs b/crates/agent/src/provider/vscode/settings_tests.rs new file mode 100644 index 00000000..9f172650 --- /dev/null +++ b/crates/agent/src/provider/vscode/settings_tests.rs @@ -0,0 +1,1431 @@ +//! Tests for VS Code's user `settings.json` management under the +//! `githubModels` variant of `copilotChat`, mirroring `provider::vscode::tests` +//! (`chatLanguageModels.json`) wherever the shape is shared. +//! `github_models_removes_the_managed_chat_language_models_entry_through_reconcile_plan` +//! exercises `reconcile::plan` with a `githubModels` config, which must plan a +//! removal of the custom-models entry. + +use std::{collections::BTreeMap, fs, net::SocketAddr, path::PathBuf}; + +use agentdesktop_core::config::{LlmGatewayConfig, VsCodeConfig, VsCodeCopilotChat, VsCodeModel}; +use jsonc_parser::{ParseOptions, cst::CstRootNode}; +use serde_json::{Value, json}; + +use super::settings::{ + Removal, SETTINGS_STATE_VERSION, SettingsConflict, SettingsState, edit_settings, plan, + read_state, remove_settings, settings_path, +}; +use crate::reconcile::ReconcilePlan; + +/// The two keys the daemon owns in `settings.json`. +const OVERRIDE_KEY: &str = "github.copilot.advanced.debug.overrideCapiUrl"; +const CAPI_ALIAS_KEY: &str = "github.copilot.internal.capiUrl"; +const IGNORED_SETTINGS_KEY: &str = "settingsSync.ignoredSettings"; + +const LISTEN: &str = "127.0.0.1:18095"; +const PAIRING: &str = "PAIRING-FIXTURE"; + +fn listen_addr() -> SocketAddr { + LISTEN.parse().unwrap() +} + +fn override_url(listen: SocketAddr, pairing: &str) -> String { + format!("http://{listen}/vscode-copilot-capi/{pairing}") +} + +fn github_models_config() -> VsCodeConfig { + VsCodeConfig { + use_llm_gateway: true, + copilot_chat: VsCodeCopilotChat::GithubModels, + models: BTreeMap::new(), + } +} + +fn own_models_config() -> VsCodeConfig { + let mut models = BTreeMap::new(); + models.insert("gpt-4.1-mini".to_owned(), VsCodeModel::default()); + VsCodeConfig { + use_llm_gateway: true, + copilot_chat: VsCodeCopilotChat::OwnModels, + models, + } +} + +fn gateway_with_proxy() -> LlmGatewayConfig { + LlmGatewayConfig { + url: "https://gateway.example.com".parse().unwrap(), + authentication: None, + proxy_url: Some("https://gateway.example.com/copilot-proxy".parse().unwrap()), + github_oauth: None, + } +} + +fn gateway_without_proxy_url() -> LlmGatewayConfig { + LlmGatewayConfig { + proxy_url: None, + ..gateway_with_proxy() + } +} + +fn read(path: &std::path::Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +fn ignored_settings(document: &Value) -> Vec { + document[IGNORED_SETTINGS_KEY] + .as_array() + .unwrap_or_else(|| panic!("{IGNORED_SETTINGS_KEY} missing or not an array in {document}")) + .iter() + .map(|value| value.as_str().unwrap().to_owned()) + .collect() +} + +fn apply_managed( + path: &std::path::Path, + listen: SocketAddr, + pairing: &str, + config: &VsCodeConfig, + gateway: &LlmGatewayConfig, +) { + let changes = ReconcilePlan::default(); + plan( + path, + Some((listen, pairing)), + Some((config, Some(gateway))), + &changes, + ) + .unwrap(); + assert!(!changes.has_conflicts(), "{}", changes.render()); + changes.apply().unwrap(); +} + +fn apply_removal( + path: &std::path::Path, + proxy: Option<(SocketAddr, &str)>, + configured: Option<(&VsCodeConfig, Option<&LlmGatewayConfig>)>, +) -> ReconcilePlan { + let changes = ReconcilePlan::default(); + plan(path, proxy, configured, &changes).unwrap(); + changes +} + +fn write_user_settings(path: &std::path::Path, document: &Value) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, serde_json::to_vec_pretty(document).unwrap()).unwrap(); +} + +// --- Helpers for the pure-function (`edit_settings`/`remove_settings`) +// tests: the strict VS Code JSONC grammar (comments and trailing commas, +// nothing else of JSON5's permissive defaults) and a comment-tolerant way to +// check semantic content without caring about formatting. + +fn vscode_parse_options() -> ParseOptions { + ParseOptions { + allow_comments: true, + allow_trailing_commas: true, + allow_loose_object_property_names: false, + allow_missing_commas: false, + allow_single_quoted_strings: false, + allow_hexadecimal_numbers: false, + allow_unary_plus_numbers: false, + allow_bare_decimal_point_numbers: false, + allow_non_finite_numbers: false, + allow_extended_string_escapes: false, + } +} + +/// The semantic content of a (possibly commented) `settings.json` text, for +/// assertions that do not care about comments or formatting. +fn parse_jsonc(text: &str) -> Value { + CstRootNode::parse(text, &vscode_parse_options()) + .unwrap_or_else(|error| panic!("valid VS Code JSONC: {error}\n{text}")) + .to_serde_value() + .unwrap_or_else(|| panic!("object or array root:\n{text}")) +} + +/// Every line of `original` still appears, in the same relative order +/// (insertions between them are allowed), in `edited`: the +/// byte-identical-preservation check for a fixture with comments and a +/// trailing comma, where the exact bytes of the *inserted* lines are an +/// implementation detail. Splitting on `'\n'` (not `str::lines`, which +/// strips a trailing `\r`) keeps a CRLF line ending part of what must match. +fn assert_original_lines_preserved_in_order(original: &str, edited: &str) { + let mut edited_lines = edited.split('\n'); + for line in original.split('\n') { + assert!( + edited_lines.any(|candidate| candidate == line), + "line {line:?} missing or reordered after the edit:\n--- original ---\n{original}\n--- edited ---\n{edited}" + ); + } +} + +const MANAGED_URL: &str = "http://127.0.0.1:18095/vscode-copilot-capi/PAIRING-FIXTURE"; +const OTHER_URL: &str = "http://127.0.0.1:18099/vscode-copilot-capi/PAIRING-OTHER"; + +// --- Fresh apply ------------------------------------------------------- + +#[test] +fn fresh_file_gets_both_managed_keys_and_is_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let written = read(&path); + assert_eq!(written[OVERRIDE_KEY], override_url(listen_addr(), PAIRING)); + let ignored = ignored_settings(&written); + assert!(ignored.contains(&OVERRIDE_KEY.to_owned()), "{written}"); + assert!(ignored.contains(&CAPI_ALIAS_KEY.to_owned()), "{written}"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "settings.json carries the pairing and must be owner-only" + ); + } + assert!( + super::super::json_merge::state_path(&path).exists(), + "a merge sidecar must be written alongside the created file" + ); +} + +// --- User content survives re-apply and re-pairing -------------------- + +#[test] +fn user_keys_and_a_user_ignored_settings_entry_survive_reapply_and_repairing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings( + &path, + &json!({ + "editor.fontSize": 14, + IGNORED_SETTINGS_KEY: ["some.other.userSetting"], + }), + ); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let after_first = read(&path); + assert_eq!(after_first["editor.fontSize"], 14); + let ignored = ignored_settings(&after_first); + assert!(ignored.contains(&"some.other.userSetting".to_owned())); + assert!(ignored.contains(&OVERRIDE_KEY.to_owned())); + assert!(ignored.contains(&CAPI_ALIAS_KEY.to_owned())); + + // Re-apply unchanged: nothing lost, nothing duplicated. + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let after_reapply = read(&path); + assert_eq!(after_reapply["editor.fontSize"], 14); + let ignored = ignored_settings(&after_reapply); + assert_eq!( + ignored + .iter() + .filter(|entry| *entry == &"some.other.userSetting".to_owned()) + .count(), + 1 + ); + assert_eq!( + ignored + .iter() + .filter(|entry| *entry == &OVERRIDE_KEY.to_owned()) + .count(), + 1 + ); + + // Re-pairing (new listen address and pairing value, e.g. FRESH=1 + // re-enrollment): the URL changes, everything else is unchanged. + let new_listen: SocketAddr = "127.0.0.1:18099".parse().unwrap(); + apply_managed(&path, new_listen, "PAIRING-NEW", &config, &gateway); + let after_repair = read(&path); + assert_eq!(after_repair["editor.fontSize"], 14); + assert_eq!( + after_repair[OVERRIDE_KEY], + override_url(new_listen, "PAIRING-NEW") + ); + let ignored = ignored_settings(&after_repair); + assert!(ignored.contains(&"some.other.userSetting".to_owned())); +} + +// --- Removal: one test per trigger, each keeping the user's keys ----- + +#[test] +fn removal_program_absent_keeps_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `programs.vscode` removed entirely. + apply_removal(&path, Some((listen_addr(), PAIRING)), None) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_own_models_keeps_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let github_models = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &github_models, &gateway); + + // `copilotChat` switched back to `ownModels`: settings.json has nothing + // of this variant's to keep, even though the program is still configured + // and the gateway is still on. + let own_models = own_models_config(); + apply_removal( + &path, + Some((listen_addr(), PAIRING)), + Some((&own_models, Some(&gateway))), + ) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_use_llm_gateway_false_keeps_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `useLlmGateway: false`: the caller passes the program's config with the + // gateway filtered to `None`. + apply_removal(&path, Some((listen_addr(), PAIRING)), Some((&config, None))) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_no_top_level_gateway_keeps_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // No top-level `llmGateway` at all: same call shape as `useLlmGateway: + // false` (the caller filters either way), a distinct real-world trigger. + apply_removal(&path, Some((listen_addr(), PAIRING)), Some((&config, None))) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_no_proxy_url_keeps_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `llmGateway.proxyUrl` unset: the gateway is still configured and used, + // but this route has nowhere to forward to. + let gateway_without_proxy = gateway_without_proxy_url(); + apply_removal( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway_without_proxy))), + ) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_no_proxy_available_keeps_user_keys_with_a_warning() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `ctx.llm_proxy` is `None` (bind failed, proxy off) even though the + // program is still configured with a usable gateway. + apply_removal(&path, None, Some((&config, Some(&gateway)))) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_of_a_created_and_emptied_file_deletes_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + assert!(path.exists()); + + // Emptied by hand afterwards: a file the merge created is removed whole, + // not left as an empty object. + fs::write(&path, "").unwrap(); + apply_removal(&path, Some((listen_addr(), PAIRING)), None) + .apply() + .unwrap(); + + assert!( + !path.exists(), + "a settings.json we created and the user then emptied must be removed" + ); + assert!(!super::super::json_merge::state_path(&path).exists()); +} + +// --- File mode --------------------------------------------------------- + +#[cfg(unix)] +#[test] +fn mode_0644_becomes_0600_on_merge_and_removal_keeps_the_mode_it_finds() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "an existing 0644 settings.json must be tightened on merge" + ); + + // The user (or another tool) sets an unusual mode after the merge; + // removal must not loosen or tighten it further. + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + apply_removal(&path, Some((listen_addr(), PAIRING)), None) + .apply() + .unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640, + "removal must keep the mode it finds, not the merge's own 0600" + ); +} + +// --- Redaction --------------------------------------------------------- + +#[test] +fn redaction_keeps_the_pairing_out_of_render() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!( + !changes.render().contains(PAIRING), + "the plan report must not leak the pairing value: {}", + changes.render() + ); +} + +// --- Pre-existing user override (json_merge rollback) ----------------- + +#[test] +fn preexisting_user_override_capi_url_is_overwritten_and_restored() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings( + &path, + &json!({ OVERRIDE_KEY: "http://someone-else.example:9999/other" }), + ); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let after_merge = read(&path); + assert_eq!( + after_merge[OVERRIDE_KEY], + override_url(listen_addr(), PAIRING) + ); + + apply_removal(&path, Some((listen_addr(), PAIRING)), None) + .apply() + .unwrap(); + let after_removal = read(&path); + assert_eq!( + after_removal[OVERRIDE_KEY], "http://someone-else.example:9999/other", + "the user's own overrideCapiUrl, set before agentdesktop ever wrote the file, must be restored" + ); +} + +// --- Comments and trailing commas are preserved, not a conflict ------ +// +// The in-place edit keeps comments and trailing commas, so applying a file +// that has them succeeds. + +#[test] +fn comments_and_trailing_commas_are_preserved() { + let config = github_models_config(); + let gateway = gateway_with_proxy(); + for (name, contents) in [ + ( + "comment", + "{\n // a user comment\n \"editor.fontSize\": 14\n}\n", + ), + ("trailing comma", "{\n \"editor.fontSize\": 14,\n}\n"), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + fs::write(&path, contents).unwrap(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!(!changes.has_conflicts(), "{name}: {}", changes.render()); + changes.apply().unwrap(); + + let written = fs::read_to_string(&path).unwrap(); + assert_original_lines_preserved_in_order(contents, &written); + let document = parse_jsonc(&written); + assert_eq!( + document[OVERRIDE_KEY], + override_url(listen_addr(), PAIRING), + "{name}: {document}" + ); + } +} + +// --- settings_path per OS ---------------------------------------------- + +#[cfg(target_os = "linux")] +#[test] +fn settings_path_on_linux() { + assert_eq!( + settings_path(std::path::Path::new("/home/user")), + PathBuf::from("/home/user/.config/Code/User/settings.json") + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn settings_path_on_macos() { + assert_eq!( + settings_path(std::path::Path::new("/Users/user")), + PathBuf::from("/Users/user/Library/Application Support/Code/User/settings.json") + ); +} + +#[cfg(windows)] +#[test] +fn settings_path_on_windows() { + assert_eq!( + settings_path(std::path::Path::new(r"C:\Users\user")), + PathBuf::from(r"C:\Users\user").join("AppData/Roaming/Code/User/settings.json") + ); +} + +// --- githubModels removes the managed chatLanguageModels.json entry, through +// the existing reconcile::plan. ----------------------------------------- + +#[test] +fn github_models_removes_the_managed_chat_language_models_entry_through_reconcile_plan() { + use super::reconcile::{chat_models_path, plan as chat_models_plan}; + + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + let path = chat_models_path(home); + let mut own_models = own_models_config(); + own_models.copilot_chat = VsCodeCopilotChat::OwnModels; + let gateway = gateway_with_proxy(); + + // The `ownModels` entry exists (a previous revision had it configured). + let changes = ReconcilePlan::default(); + chat_models_plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&own_models, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + assert!(path.exists()); + + // `copilotChat` switches to `githubModels`: `reconcile::plan` is called + // with `configured = None`, since this file has nothing to manage under + // that variant. It must plan (and apply) a removal. + let changes = ReconcilePlan::default(); + chat_models_plan(&path, Some((listen_addr(), PAIRING)), None, &changes).unwrap(); + assert!(!changes.has_conflicts(), "{}", changes.render()); + changes.apply().unwrap(); + + assert!( + !path.exists(), + "chatLanguageModels.json must be cleaned up once githubModels is active" + ); +} + +// Owned override: these exercise `edit_settings`/`remove_settings` directly, +// chained the way `plan` chains them across a re-apply. + +// A hand edit of the managed URL after the first apply is drift, not the +// user's value; a re-apply replaces it and removal must not put it back. +#[test] +fn hand_edited_override_is_replaced_on_reapply_and_not_restored_on_removal() { + let fixture = "{\n \"editor.fontSize\": 14\n}\n"; + + let (text1, state1) = edit_settings(Some(fixture), MANAGED_URL, None).unwrap(); + assert_eq!(state1.override_before, None, "no user override existed"); + + // Hand edit of our URL, then a daemon restart (re-apply against the + // sidecar from the first apply). + let text2 = text1.replacen(MANAGED_URL, OTHER_URL, 1); + assert_ne!(text2, text1, "the fixture must contain MANAGED_URL once"); + let (text3, state2) = edit_settings(Some(&text2), MANAGED_URL, Some(&state1)).unwrap(); + assert_eq!( + parse_jsonc(&text3)[OVERRIDE_KEY], + MANAGED_URL, + "hand edit replaced: {text3}" + ); + assert_eq!( + state2.override_before, None, + "still no user override to restore" + ); + + // Removal: the key goes entirely; the hand-edited value was never "the + // user's" to restore. + let removed = remove_settings(&text3, Some(&state2), None).unwrap(); + let Removal::Write(remaining_text) = removed else { + panic!("editor.fontSize must survive: {removed:?}"); + }; + let remaining = parse_jsonc(&remaining_text); + assert!( + remaining.get(OVERRIDE_KEY).is_none(), + "stale override restored: {remaining}" + ); + assert!( + remaining.get(IGNORED_SETTINGS_KEY).is_none(), + "the ignore array we created must go too: {remaining}" + ); + assert_eq!(remaining["editor.fontSize"], 14); +} + +// The user's own override from before the first apply survives a hand edit of +// the managed URL and comes back on removal. +#[test] +fn user_override_from_before_the_first_apply_survives_a_hand_edit_and_returns_on_removal() { + let users_own = "https://capi.example.invalid"; + let fixture = format!("{{\n \"{OVERRIDE_KEY}\": \"{users_own}\"\n}}\n"); + + let (text1, state1) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + assert_eq!( + state1.override_before, + Some(Value::String(users_own.to_owned())), + "the pre-existing user override must be captured" + ); + assert_eq!(parse_jsonc(&text1)[OVERRIDE_KEY], MANAGED_URL); + + let text2 = text1.replacen(MANAGED_URL, OTHER_URL, 1); + let (text3, state2) = edit_settings(Some(&text2), MANAGED_URL, Some(&state1)).unwrap(); + assert_eq!( + parse_jsonc(&text3)[OVERRIDE_KEY], + MANAGED_URL, + "hand edit replaced: {text3}" + ); + assert_eq!( + state2.override_before, + Some(Value::String(users_own.to_owned())), + "overrideBefore is kept across the re-apply, not recomputed from the hand edit" + ); + + let removed = remove_settings(&text3, Some(&state2), None).unwrap(); + let Removal::Write(remaining_text) = removed else { + panic!("the user's own override must survive: {removed:?}"); + }; + assert_eq!( + parse_jsonc(&remaining_text)[OVERRIDE_KEY], + users_own, + "the user's own value comes back: {remaining_text}" + ); +} + +// ========================================================================= +// Pure-function tests on `edit_settings`/`remove_settings`/`read_state`, no +// filesystem. +// ========================================================================= + +/// Comments (line and block), a trailing comma, CRLF, and a user key on +/// either side of where the override will be appended (no override or +/// `settingsSync.ignoredSettings` yet). +const FIXTURE_NO_OVERRIDE: &str = "{\r\n // top comment\r\n \"a.userSetting\": 1,\r\n /* block comment */\r\n \"z.userSetting\": true,\r\n}\r\n"; + +/// A pre-existing override, with a same-line comment, sandwiched between two +/// user keys. +const FIXTURE_WITH_OVERRIDE: &str = "{\n \"a.userSetting\": 1,\n \"github.copilot.advanced.debug.overrideCapiUrl\": \"https://old.example/capi\", // old\n \"z.userSetting\": true\n}\n"; +const FIXTURE_WITH_OVERRIDE_VALUE: &str = "https://old.example/capi"; + +// --- edit_settings: fresh apply ---------------------------------------- + +#[test] +fn an_absent_file_is_created_as_the_exact_golden_text() { + let (edited, state) = edit_settings(None, MANAGED_URL, None).unwrap(); + assert_eq!( + edited, + format!( + "{{\n \"{OVERRIDE_KEY}\": \"{MANAGED_URL}\",\n \"{IGNORED_SETTINGS_KEY}\": [\"{OVERRIDE_KEY}\", \"{CAPI_ALIAS_KEY}\"]\n}}\n" + ), + "the golden text is fixed exactly" + ); + assert!(state.created, "the file did not exist before this apply"); + assert!(state.ignored_created); + assert_eq!( + state.added_ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + assert_eq!(state.override_before, None); + assert_eq!(state.version, SETTINGS_STATE_VERSION); +} + +#[test] +fn an_empty_or_whitespace_only_existing_file_is_filled_like_absent_but_is_not_created() { + for text in ["", " \n\t"] { + let (edited, state) = edit_settings(Some(text), MANAGED_URL, None).unwrap(); + assert!( + !state.created, + "the file existed (just empty), unlike an absent one: {text:?}" + ); + let document = parse_jsonc(&edited); + assert_eq!(document[OVERRIDE_KEY], MANAGED_URL); + assert_eq!( + ignored_settings(&document), + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + } +} + +#[test] +fn byte_identical_preservation_for_a_commented_crlf_trailing_comma_fixture_with_the_override_appended() + { + let (edited, state) = edit_settings(Some(FIXTURE_NO_OVERRIDE), MANAGED_URL, None).unwrap(); + assert_original_lines_preserved_in_order(FIXTURE_NO_OVERRIDE, &edited); + + let document = parse_jsonc(&edited); + assert_eq!(document[OVERRIDE_KEY], MANAGED_URL); + assert_eq!( + ignored_settings(&document), + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + assert!(!state.created); + assert!(state.ignored_created); + assert_eq!(state.override_before, None); +} + +#[test] +fn override_is_replaced_in_place_at_its_position_keeping_its_same_line_comment() { + let (edited, state) = edit_settings(Some(FIXTURE_WITH_OVERRIDE), MANAGED_URL, None).unwrap(); + assert!( + edited.contains(&format!("\"{OVERRIDE_KEY}\": \"{MANAGED_URL}\", // old")), + "the value changes but the same-line comment travels with it: {edited}" + ); + let a_index = edited.find("\"a.userSetting\"").unwrap(); + let override_index = edited.find(&format!("\"{OVERRIDE_KEY}\": ")).unwrap(); + let z_index = edited.find("\"z.userSetting\"").unwrap(); + assert!( + a_index < override_index && override_index < z_index, + "the override must stay between the two user keys, only its value changing: {edited}" + ); + assert_eq!( + state.override_before, + Some(Value::String(FIXTURE_WITH_OVERRIDE_VALUE.to_owned())) + ); +} + +#[test] +fn ignore_array_created_extended_and_user_entries_kept_in_order() { + // Absent entirely: created fresh, with only our two entries. + let (edited, state) = edit_settings(Some("{\n \"a\": 1\n}\n"), MANAGED_URL, None).unwrap(); + let ignored = ignored_settings(&parse_jsonc(&edited)); + assert_eq!( + ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + assert!(state.ignored_created); + assert_eq!(state.added_ignored, ignored); + + // Present with a user entry: extended, the user's entry kept first. + let fixture = + format!("{{\n \"a\": 1,\n \"{IGNORED_SETTINGS_KEY}\": [\"custom.setting\"]\n}}\n"); + let (edited, state) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + let ignored = ignored_settings(&parse_jsonc(&edited)); + assert_eq!( + ignored, + vec![ + "custom.setting".to_owned(), + OVERRIDE_KEY.to_owned(), + CAPI_ALIAS_KEY.to_owned(), + ] + ); + assert!(!state.ignored_created, "the array already existed"); + assert_eq!( + state.added_ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); +} + +#[test] +fn a_user_held_ignore_entry_of_ours_is_not_added_and_survives_removal() { + let fixture = + format!("{{\n \"a\": 1,\n \"{IGNORED_SETTINGS_KEY}\": [\"{OVERRIDE_KEY}\"]\n}}\n"); + let (edited, state) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + assert_eq!( + state.added_ignored, + vec![CAPI_ALIAS_KEY.to_owned()], + "the override key was already user-held, only the alias is ours to add" + ); + let ignored = ignored_settings(&parse_jsonc(&edited)); + assert_eq!( + ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + + let removed = remove_settings(&edited, Some(&state), None).unwrap(); + let Removal::Write(remaining) = removed else { + panic!("editor.fontSize and the user's own entry must survive: {removed:?}"); + }; + let document = parse_jsonc(&remaining); + assert_eq!( + ignored_settings(&document), + vec![OVERRIDE_KEY.to_owned()], + "the user's own entry must survive removal, the alias we added must not: {document}" + ); +} + +#[test] +fn a_users_own_empty_ignored_settings_array_comes_back_as_empty() { + let fixture = format!("{{\n \"a\": 1,\n \"{IGNORED_SETTINGS_KEY}\": []\n}}\n"); + let (edited, state) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + assert!( + !state.ignored_created, + "the property already existed, empty" + ); + assert_eq!( + state.added_ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + + let removed = remove_settings(&edited, Some(&state), None).unwrap(); + let Removal::Write(remaining) = removed else { + panic!("{removed:?}"); + }; + let document = parse_jsonc(&remaining); + assert_eq!( + document[IGNORED_SETTINGS_KEY], + json!([]), + "the user's own empty array must come back, not be removed: {document}" + ); +} + +#[test] +fn a_null_override_is_restored_as_null() { + let fixture = format!("{{\n \"{OVERRIDE_KEY}\": null\n}}\n"); + let (edited, state) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + assert_eq!( + state.override_before, + Some(Value::Null), + "an explicit null must be kept as Some(Value::Null), not treated as absent" + ); + assert_eq!(parse_jsonc(&edited)[OVERRIDE_KEY], MANAGED_URL); + + let removed = remove_settings(&edited, Some(&state), None).unwrap(); + let Removal::Write(remaining) = removed else { + panic!("{removed:?}"); + }; + assert_eq!( + parse_jsonc(&remaining)[OVERRIDE_KEY], + Value::Null, + "null must be restored, not the key removed" + ); +} + +// --- remove_settings: restoring what edit_settings changed -------------- + +#[test] +fn removal_restores_the_pre_apply_fixture_byte_for_byte() { + let (edited, state) = edit_settings(Some(FIXTURE_NO_OVERRIDE), MANAGED_URL, None).unwrap(); + let removed = remove_settings(&edited, Some(&state), None).unwrap(); + assert_eq!(removed, Removal::Write(FIXTURE_NO_OVERRIDE.to_owned())); +} + +#[test] +fn users_own_override_is_restored_with_its_same_line_comment() { + let (edited, state) = edit_settings(Some(FIXTURE_WITH_OVERRIDE), MANAGED_URL, None).unwrap(); + assert_eq!( + state.override_before, + Some(Value::String(FIXTURE_WITH_OVERRIDE_VALUE.to_owned())) + ); + + let removed = remove_settings(&edited, Some(&state), None).unwrap(); + assert_eq!(removed, Removal::Write(FIXTURE_WITH_OVERRIDE.to_owned())); +} + +#[test] +fn user_deleted_the_override_after_apply_removal_appends_override_before() { + let (edited, state) = edit_settings(Some(FIXTURE_WITH_OVERRIDE), MANAGED_URL, None).unwrap(); + assert_eq!( + state.override_before, + Some(Value::String(FIXTURE_WITH_OVERRIDE_VALUE.to_owned())) + ); + + // The user deletes the whole override property line by hand; whatever we + // appended (the ignore entries) stays. + let without_override: String = edited + .lines() + .filter(|line| { + !line + .trim_start() + .starts_with(&format!("\"{OVERRIDE_KEY}\":")) + }) + .collect::>() + .join("\n") + + "\n"; + assert!( + parse_jsonc(&without_override).get(OVERRIDE_KEY).is_none(), + "the fixture must no longer have the property" + ); + + let removed = remove_settings(&without_override, Some(&state), None).unwrap(); + let Removal::Write(remaining) = removed else { + panic!("{removed:?}"); + }; + let document = parse_jsonc(&remaining); + assert_eq!( + document[OVERRIDE_KEY], FIXTURE_WITH_OVERRIDE_VALUE, + "the property must be appended back, not left missing: {document}" + ); +} + +#[test] +fn a_created_file_is_deleted_when_only_braces_or_nothing_remain_but_kept_with_a_comment() { + let state = SettingsState { + version: SETTINGS_STATE_VERSION, + created: true, + override_before: None, + added_ignored: vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()], + ignored_created: true, + }; + for remaining in ["{}\n", "", " \n"] { + assert_eq!( + remove_settings(remaining, Some(&state), None).unwrap(), + Removal::Delete, + "{remaining:?}" + ); + } + // A comment is something to keep, even though nothing else is left. + let with_comment = "{\n // still here\n}\n"; + let removed = remove_settings(with_comment, Some(&state), None).unwrap(); + assert!( + !matches!(removed, Removal::Delete), + "a remaining comment must keep the file: {removed:?}" + ); +} + +// --- Conflicts ----------------------------------------------------------- + +#[test] +fn edit_settings_conflicts() { + let cases: Vec<(&str, String, SettingsConflict)> = vec![ + ( + "parse error", + "{ \"a\": }".to_owned(), + SettingsConflict::Parse, + ), + ( + "missing comma", + "{ \"a\": 1 \"b\": 2 }".to_owned(), + SettingsConflict::Parse, + ), + ( + "single-quoted string", + "{ 'a': 1 }".to_owned(), + SettingsConflict::Parse, + ), + ( + "non-object root", + "[1, 2]".to_owned(), + SettingsConflict::NotAnObject, + ), + ( + "non-array ignoredSettings", + format!("{{ \"{IGNORED_SETTINGS_KEY}\": 5 }}"), + SettingsConflict::IgnoredNotArray, + ), + ( + "duplicate override key", + format!("{{ \"{OVERRIDE_KEY}\": \"a\", \"{OVERRIDE_KEY}\": \"b\" }}"), + SettingsConflict::DuplicateKey, + ), + ]; + for (name, text, expected) in cases { + assert_eq!( + edit_settings(Some(&text), MANAGED_URL, None), + Err(expected), + "{name}: {text}" + ); + } +} + +#[test] +fn remove_settings_without_a_sidecar_treats_an_unparseable_file_as_unchanged_not_a_conflict() { + // Matches today's `plan_remove_orphaned` (settings.rs:157-163): skipped + // with a debug line, not a hard conflict, since there is no sidecar to + // say anything was ever ours. + assert_eq!( + remove_settings("{ \"a\": }", None, Some(MANAGED_URL)), + Ok(Removal::Unchanged) + ); +} + +#[test] +fn remove_settings_with_a_sidecar_treats_an_unparseable_file_as_a_conflict() { + let state = SettingsState { + version: SETTINGS_STATE_VERSION, + created: false, + override_before: None, + added_ignored: vec![OVERRIDE_KEY.to_owned()], + ignored_created: false, + }; + assert_eq!( + remove_settings("{ \"a\": }", Some(&state), None), + Err(SettingsConflict::Parse) + ); +} + +// --- read_state: the earlier whole-document sidecar upgraded -------------- + +#[test] +fn a_v1_sidecar_is_read_and_upgraded() { + let before = json!({ "a": 1, OVERRIDE_KEY: FIXTURE_WITH_OVERRIDE_VALUE }); + let after = json!({ + "a": 1, + OVERRIDE_KEY: MANAGED_URL, + IGNORED_SETTINGS_KEY: [OVERRIDE_KEY, CAPI_ALIAS_KEY], + }); + let v1 = json!({ "created": false, "before": before, "after": after }); + let bytes = serde_json::to_vec(&v1).unwrap(); + + let state = read_state(&bytes).expect("a v1 sidecar (no `version` field) must be recognized"); + assert_eq!(state.version, SETTINGS_STATE_VERSION); + assert!(!state.created); + assert_eq!( + state.override_before, + Some(Value::String(FIXTURE_WITH_OVERRIDE_VALUE.to_owned())), + "override_before = before[OVERRIDE_KEY]" + ); + assert_eq!( + state.added_ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()], + "our two entries were not in `before.settingsSync.ignoredSettings`" + ); + assert!( + state.ignored_created, + "`before.settingsSync.ignoredSettings` was absent" + ); +} + +#[test] +fn read_state_is_none_for_bytes_that_are_neither_v1_nor_v2() { + assert!(read_state(b"not json at all").is_none()); +} + +// --- SettingsState's own serde shape (exact camelCase, deny_unknown_fields, +// an explicit null override_before kept distinct from an absent one) ----- + +#[test] +fn settings_state_serializes_camelcase_and_keeps_an_explicit_null_override_before() { + let with_null = SettingsState { + version: SETTINGS_STATE_VERSION, + created: false, + override_before: Some(Value::Null), + added_ignored: vec![OVERRIDE_KEY.to_owned()], + ignored_created: true, + }; + let json = serde_json::to_value(&with_null).unwrap(); + assert_eq!( + json, + json!({ + "version": 2, + "created": false, + "overrideBefore": null, + "addedIgnored": [OVERRIDE_KEY], + "ignoredCreated": true, + }) + ); + let round_tripped: SettingsState = serde_json::from_value(json).unwrap(); + assert_eq!(round_tripped, with_null); + + // Absent (no user override existed before the first apply): the field is + // omitted entirely, not written as `null`. + let without = SettingsState { + override_before: None, + ..with_null.clone() + }; + let json = serde_json::to_value(&without).unwrap(); + assert!( + json.get("overrideBefore").is_none(), + "skip_serializing_if must omit an absent override: {json}" + ); + let round_tripped: SettingsState = serde_json::from_value(json).unwrap(); + assert_eq!(round_tripped.override_before, None); + + // deny_unknown_fields: a stray field must not silently pass through. + let mut with_extra = serde_json::to_value(&without).unwrap(); + with_extra["unexpectedField"] = json!(true); + assert!(serde_json::from_value::(with_extra).is_err()); +} + +// ========================================================================= +// Plan-level tests (existing helpers): the v2 sidecar and the in-place edit +// through `plan`/`apply`. +// ========================================================================= + +#[test] +fn unchanged_reapply_writes_nothing_and_leaves_the_sidecar_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let settings_before = fs::read(&path).unwrap(); + let state_path = super::super::json_merge::state_path(&path); + let sidecar_before = fs::read(&state_path).unwrap(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + assert_eq!( + fs::read(&path).unwrap(), + settings_before, + "settings.json must not be rewritten when nothing changes" + ); + assert_eq!( + fs::read(&state_path).unwrap(), + sidecar_before, + "the sidecar must only be rewritten when its content changes" + ); +} + +#[cfg(unix)] +#[test] +fn unchanged_reapply_on_a_0664_file_is_byte_identical_but_tightened_to_0600() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let bytes_before = fs::read(&path).unwrap(); + + // VS Code's own save: same bytes, looser mode. + fs::set_permissions(&path, fs::Permissions::from_mode(0o664)).unwrap(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + assert_eq!( + fs::read(&path).unwrap(), + bytes_before, + "bytes must be identical" + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "a file looser than 0600 must be tightened even when nothing else changed" + ); +} + +#[test] +fn v2_sidecar_has_the_exact_camelcase_fields_after_a_fresh_apply() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let state_path = super::super::json_merge::state_path(&path); + let sidecar: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + assert_eq!(sidecar["version"], 2, "{sidecar}"); + assert_eq!(sidecar["created"], true, "{sidecar}"); + assert!(sidecar.get("overrideBefore").is_none(), "{sidecar}"); + assert_eq!(sidecar["ignoredCreated"], true, "{sidecar}"); + let added: Vec = sidecar["addedIgnored"] + .as_array() + .unwrap_or_else(|| panic!("{sidecar}")) + .iter() + .map(|value| value.as_str().unwrap().to_owned()) + .collect(); + assert_eq!( + added, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + + let typed: SettingsState = serde_json::from_value(sidecar).unwrap(); + assert_eq!(typed.version, SETTINGS_STATE_VERSION); +} + +#[test] +fn an_unreadable_sidecar_warns_and_apply_proceeds() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({ "editor.fontSize": 14 })); + let state_path = super::super::json_merge::state_path(&path); + fs::write(&state_path, b"not json at all").unwrap(); + + let config = github_models_config(); + let gateway = gateway_with_proxy(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!(!changes.has_conflicts(), "{}", changes.render()); + changes.apply().unwrap(); + + let written = read(&path); + assert_eq!(written["editor.fontSize"], 14); + assert_eq!(written[OVERRIDE_KEY], override_url(listen_addr(), PAIRING)); +} + +#[test] +fn sidecar_less_reapply_then_removal_clears_the_override_and_both_entries() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({ "editor.fontSize": 14 })); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let state_path = super::super::json_merge::state_path(&path); + fs::remove_file(&state_path).unwrap(); + + // Re-apply without a sidecar: the existing override, equal to our + // own URL, must not become "the user's value" to restore later. + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + apply_removal(&path, Some((listen_addr(), PAIRING)), None) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); + assert!(after.get(IGNORED_SETTINGS_KEY).is_none(), "{after}"); +} + +#[test] +fn sidecar_less_removal_by_own_url_on_a_commented_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let own_url = override_url(listen_addr(), PAIRING); + let contents = format!( + "{{\n // kept\n \"editor.fontSize\": 14,\n \"{OVERRIDE_KEY}\": \"{own_url}\",\n \"{IGNORED_SETTINGS_KEY}\": [\"{OVERRIDE_KEY}\", \"{CAPI_ALIAS_KEY}\"]\n}}\n" + ); + fs::write(&path, &contents).unwrap(); + + apply_removal(&path, Some((listen_addr(), PAIRING)), None) + .apply() + .unwrap(); + + let written = fs::read_to_string(&path).unwrap(); + assert!(written.contains("// kept"), "{written}"); + let document = parse_jsonc(&written); + assert_eq!(document["editor.fontSize"], 14); + assert!(document.get(OVERRIDE_KEY).is_none(), "{document}"); + assert!(document.get(IGNORED_SETTINGS_KEY).is_none(), "{document}"); +} + +#[cfg(unix)] +#[test] +fn a_created_parent_directory_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nested/profile/settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + assert_eq!( + fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700, + "a directory the daemon creates to hold settings.json must be owner-only" + ); +} + +#[test] +fn redaction_keeps_the_pairing_out_of_the_sidecar_too() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let state_path = super::super::json_merge::state_path(&path); + let sidecar = fs::read_to_string(&state_path).unwrap(); + assert!( + !sidecar.contains(PAIRING), + "the sidecar must not carry the pairing value: {sidecar}" + ); +} + +#[test] +fn an_earlier_daemon_url_is_ours_not_the_users_value() { + let stale = "http://127.0.0.1:18099/vscode-copilot-capi/OLD-PAIRING"; + let fixture = format!("{{\n \"{OVERRIDE_KEY}\": \"{stale}\"\n}}\n"); + let (edited, state) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + assert_eq!( + state.override_before, None, + "a loopback CAPI URL is never the user's own" + ); + assert!( + !serde_json::to_string(&state) + .unwrap() + .contains("OLD-PAIRING") + ); + assert_eq!(parse_jsonc(&edited)[OVERRIDE_KEY], MANAGED_URL); + // Sidecar-less removal takes a stale one out as well. + assert!(matches!( + remove_settings(&fixture, None, Some(MANAGED_URL)).unwrap(), + Removal::Write(remaining) if parse_jsonc(&remaining).get(OVERRIDE_KEY).is_none() + )); + // A user's own non-loopback URL stays theirs. + let users = format!( + "{{\n \"{OVERRIDE_KEY}\": \"https://capi.example.invalid/vscode-copilot-capi/x\"\n}}\n" + ); + let (_, state) = edit_settings(Some(&users), MANAGED_URL, None).unwrap(); + assert!(state.override_before.is_some()); +} + +#[test] +fn a_byte_order_mark_is_kept_and_edited_around() { + let fixture = "\u{feff}{\n \"a\": 1\n}\n"; + let (edited, state) = edit_settings(Some(fixture), MANAGED_URL, None).unwrap(); + assert!(edited.starts_with('\u{feff}'), "{edited:?}"); + assert_eq!( + parse_jsonc(edited.trim_start_matches('\u{feff}'))[OVERRIDE_KEY], + MANAGED_URL + ); + assert_eq!( + remove_settings(&edited, Some(&state), None).unwrap(), + Removal::Write(fixture.to_owned()) + ); +} + +#[test] +fn a_list_or_file_recreated_by_a_reapply_goes_again_on_removal() { + // The user had an ignore list and deleted it after the first apply; the + // re-apply recreates it, so removal takes it out rather than leaving []. + let fixture = format!("{{\n \"a\": 1,\n \"{IGNORED_SETTINGS_KEY}\": [\"x\"]\n}}\n"); + let (_, first) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + let (edited, state) = + edit_settings(Some("{\n \"a\": 1\n}\n"), MANAGED_URL, Some(&first)).unwrap(); + assert!(state.ignored_created); + let Removal::Write(remaining) = remove_settings(&edited, Some(&state), None).unwrap() else { + panic!("user key a must remain"); + }; + assert!( + parse_jsonc(&remaining).get(IGNORED_SETTINGS_KEY).is_none(), + "{remaining}" + ); + // The user deleted the whole file; the re-apply recreated it. + let (recreated, state) = edit_settings(None, MANAGED_URL, Some(&first)).unwrap(); + assert!(state.created); + assert_eq!( + remove_settings(&recreated, Some(&state), None).unwrap(), + Removal::Delete + ); +} + +#[test] +fn a_duplicated_ignore_list_is_a_conflict() { + let text = format!("{{ \"{IGNORED_SETTINGS_KEY}\": [], \"{IGNORED_SETTINGS_KEY}\": [] }}"); + assert_eq!( + edit_settings(Some(&text), MANAGED_URL, None), + Err(SettingsConflict::DuplicateKey) + ); +} + +#[test] +fn removal_records_unchanged_only_when_something_of_ours_was_there() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({ "editor.fontSize": 14 })); + // No program, no sidecar: nothing of ours, nothing recorded. + let plan_without = apply_removal(&path, Some((listen_addr(), PAIRING)), None); + assert!( + plan_without.render().contains("0 changes, 0 unchanged"), + "{}", + plan_without.render() + ); + // A sidecar and a deleted file: recorded as unchanged, sidecar removed. + apply_managed( + &path, + listen_addr(), + PAIRING, + &github_models_config(), + &gateway_with_proxy(), + ); + fs::remove_file(&path).unwrap(); + let plan_absent = apply_removal(&path, Some((listen_addr(), PAIRING)), None); + assert!( + plan_absent.render().contains("1 unchanged"), + "{}", + plan_absent.render() + ); + plan_absent.apply().unwrap(); + assert!(!super::super::json_merge::state_path(&path).exists()); +} diff --git a/crates/agent/src/provider/vscode/testdata/Dockerfile b/crates/agent/src/provider/vscode/testdata/Dockerfile index 75eb70a0..23fd6e3a 100644 --- a/crates/agent/src/provider/vscode/testdata/Dockerfile +++ b/crates/agent/src/provider/vscode/testdata/Dockerfile @@ -12,7 +12,9 @@ RUN case "$(uname -m)" in x86_64) arch=x64 ;; aarch64) arch=arm64 ;; *) exit 1 ; && mkdir -p /opt/vscode \ && tar -xzf /tmp/code.tar.gz --strip-components=1 -C /opt/vscode \ && rm /tmp/code.tar.gz \ - && ln -s /opt/vscode/bin/code /usr/local/bin/code -ENV HOME=/home/tester + && ln -s /opt/vscode/bin/code /usr/local/bin/code \ + && mkdir -p /run/user/1000 && chown tester:tester /run/user/1000 +ENV HOME=/home/tester \ + XDG_RUNTIME_DIR=/run/user/1000 WORKDIR /home/tester/project CMD ["sleep", "infinity"] diff --git a/crates/agent/src/provider/vscode/testdata/chatLanguageModels.lab.json b/crates/agent/src/provider/vscode/testdata/chatLanguageModels.lab.json new file mode 100644 index 00000000..a9cb21b4 --- /dev/null +++ b/crates/agent/src/provider/vscode/testdata/chatLanguageModels.lab.json @@ -0,0 +1,20 @@ +[ + { + "name": "agentdesktop", + "vendor": "customendpoint", + "apiKey": "unused", + "apiType": "chat-completions", + "models": [ + { + "id": "gpt-4.1-mini", + "name": "gpt-4.1-mini (agentdesktop)", + "url": "http://127.0.0.1:18095/vscode-copilot/v1/chat/completions", + "toolCalling": true, + "vision": false, + "maxInputTokens": 128000, + "maxOutputTokens": 16000, + "requestHeaders": { "x-agentdesktop-pairing": "PAIRING-FIXTURE" } + } + ] + } +] diff --git a/crates/agent/src/provider/vscode/tests.rs b/crates/agent/src/provider/vscode/tests.rs new file mode 100644 index 00000000..f8439319 --- /dev/null +++ b/crates/agent/src/provider/vscode/tests.rs @@ -0,0 +1,732 @@ +//! Tests for the VS Code Copilot Chat (own models) provider. Closely mirrors +//! `provider::copilot::tests`, which this feature builds on. + +use std::{collections::BTreeMap, fs, net::SocketAddr, path::PathBuf}; + +use agentdesktop_core::config::{LlmGatewayConfig, VsCodeConfig, VsCodeModel}; +use serde_json::{Value, json}; + +use super::reconcile::{chat_models_path, managed_document, plan}; +use crate::reconcile::ReconcilePlan; + +fn fixture() -> Value { + serde_json::from_str(include_str!("testdata/chatLanguageModels.lab.json")) + .expect("fixture is valid JSON") +} + +fn sample_config() -> VsCodeConfig { + let mut models = BTreeMap::new(); + models.insert( + "gpt-4.1-mini".to_owned(), + VsCodeModel { + name: None, + tool_calling: true, + vision: false, + max_input_tokens: Some(128_000), + max_output_tokens: Some(16_000), + extra: BTreeMap::new(), + }, + ); + VsCodeConfig { + use_llm_gateway: true, + copilot_chat: Default::default(), + models, + } +} + +fn sample_gateway() -> LlmGatewayConfig { + LlmGatewayConfig { + url: "https://gateway.example.com".parse().unwrap(), + authentication: None, + proxy_url: None, + github_oauth: None, + } +} + +const LISTEN: &str = "127.0.0.1:18095"; +const PAIRING: &str = "PAIRING-FIXTURE"; + +fn listen_addr() -> SocketAddr { + LISTEN.parse().unwrap() +} + +fn find_vendor<'a>(document: &'a Value, name: &str) -> &'a Value { + document + .as_array() + .unwrap_or_else(|| panic!("chatLanguageModels.json root must be an array in {document}")) + .iter() + .find(|vendor| vendor["name"] == name) + .unwrap_or_else(|| panic!("vendor {name} not found in {document}")) +} + +fn our_vendor_count(document: &Value) -> usize { + document + .as_array() + .unwrap() + .iter() + .filter(|vendor| vendor["name"] == VsCodeConfig::VENDOR_NAME) + .count() +} + +fn write_user_owned_document(path: &std::path::Path) { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .unwrap(); + } + fs::write( + path, + serde_json::to_vec_pretty(&json!([{ + "name": "my-own-vendor", + "vendor": "customendpoint", + "apiKey": "sk-user", + "apiType": "chat-completions", + "models": [{ + "id": "gpt-mine", + "url": "https://example.com/v1/chat/completions", + }], + }])) + .unwrap(), + ) + .unwrap(); +} + +fn apply_managed( + path: &std::path::Path, + listen: SocketAddr, + pairing: &str, + config: &VsCodeConfig, + gateway: &LlmGatewayConfig, +) { + let changes = ReconcilePlan::default(); + plan( + path, + Some((listen, pairing)), + Some((config, Some(gateway))), + &changes, + ) + .unwrap(); + assert!(!changes.has_conflicts(), "{}", changes.render()); + changes.apply().unwrap(); +} + +fn apply_removal(path: &std::path::Path) -> ReconcilePlan { + let changes = ReconcilePlan::default(); + plan(path, Some((listen_addr(), PAIRING)), None, &changes).unwrap(); + changes +} + +fn read(path: &std::path::Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +fn parse_programs(yaml: &str) -> anyhow::Result { + agentdesktop_core::config::parse_daemon(yaml) +} + +// --- Config-level validation, at parse time --------------------------------- + +#[test] +fn config_rejects_a_reserved_pass_through_key() { + for key in ["id", "url", "requestHeaders"] { + let error = parse_programs(&format!( + "programs:\n vscode:\n models:\n x:\n {key}: y\n" + )) + .unwrap_err(); + assert!( + format!("{error:#}").contains("set by agentdesktop"), + "{key}: {error:#}" + ); + } +} + +#[test] +fn config_requires_a_model_with_a_gateway() { + let error = + parse_programs("llmGateway:\n url: https://gateway.example\nprograms:\n vscode: {}\n") + .unwrap_err(); + assert!(format!("{error:#}").contains("at least one"), "{error:#}"); + parse_programs("programs:\n vscode: {}\n").expect("no gateway, no model needed"); +} + +#[test] +fn config_rejects_sandbox_with_vscode() { + let error = + parse_programs("sandbox:\n filesystem: {}\nprograms:\n vscode: {}\n").unwrap_err(); + assert!(format!("{error:#}").contains("sandbox"), "{error:#}"); +} + +#[test] +fn daemon_vscode_config_overrides_the_default_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + fs::write( + &path, + "daemon:\n user: true\n vscode:\n config: /custom/chatLanguageModels.json\n", + ) + .unwrap(); + let config = agentdesktop_core::config::load_daemon(&path).unwrap(); + assert_eq!( + config.daemon.unwrap().vscode.config, + Some(PathBuf::from("/custom/chatLanguageModels.json")) + ); +} + +// --- githubModels config validation ----------------------------------------- + +#[test] +fn config_rejects_github_models_with_a_non_empty_models_map() { + let error = parse_programs( + "programs:\n vscode:\n copilotChat: githubModels\n models:\n x: {}\n", + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("not allowed when copilotChat is githubModels"), + "{error:#}" + ); + parse_programs("programs:\n vscode:\n copilotChat: githubModels\n") + .expect("githubModels with no models is valid"); +} + +#[test] +fn config_rejects_github_models_without_proxy_url_only_when_the_gateway_is_used() { + // The gateway is used (useLlmGateway defaults to true) and configured, + // but llmGateway.proxyUrl is unset: rejected. + let error = parse_programs( + "llmGateway:\n url: https://gateway.example\nprograms:\n vscode:\n copilotChat: githubModels\n", + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("llmGateway.proxyUrl"), + "{error:#}" + ); + + // useLlmGateway: false: the gateway is configured but not used by this + // program, so no proxyUrl is required. + parse_programs( + "llmGateway:\n url: https://gateway.example\nprograms:\n vscode:\n copilotChat: githubModels\n useLlmGateway: false\n", + ) + .expect("githubModels with useLlmGateway: false needs no proxyUrl"); + + // No top-level llmGateway at all: same, no proxyUrl required. + parse_programs("programs:\n vscode:\n copilotChat: githubModels\n") + .expect("githubModels with no llmGateway needs no proxyUrl"); +} + +#[test] +fn config_accepts_github_models_when_both_the_gateway_and_proxy_url_are_set() { + // Both values matched in validate_daemon: use_llm_gateway true, gateway + // configured, and llmGateway.proxyUrl set. + parse_programs( + "llmGateway:\n url: https://gateway.example\n proxyUrl: https://gateway.example/copilot-proxy\nprograms:\n vscode:\n copilotChat: githubModels\n", + ) + .expect("githubModels with llmGateway.proxyUrl set is valid"); +} + +// --- chat_models_path per OS ------------------------------------------------ + +#[cfg(target_os = "linux")] +#[test] +fn chat_models_path_on_linux() { + assert_eq!( + chat_models_path(std::path::Path::new("/home/user")), + PathBuf::from("/home/user/.config/Code/User/chatLanguageModels.json") + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn chat_models_path_on_macos() { + assert_eq!( + chat_models_path(std::path::Path::new("/Users/user")), + PathBuf::from("/Users/user/Library/Application Support/Code/User/chatLanguageModels.json") + ); +} + +#[cfg(windows)] +#[test] +fn chat_models_path_on_windows() { + assert_eq!( + chat_models_path(std::path::Path::new(r"C:\Users\user")), + PathBuf::from(r"C:\Users\user").join("AppData/Roaming/Code/User/chatLanguageModels.json") + ); +} + +// --- managed_document shape ------------------------------------------------- + +#[test] +fn managed_document_matches_the_lab_fixture_shape() { + let config = sample_config(); + let document = managed_document(&config, listen_addr(), PAIRING).expect("managed document"); + assert_eq!(document, fixture()); +} + +// --- plan(): fresh create --------------------------------------------------- + +#[test] +fn fresh_file_is_created_matching_the_fixture_and_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .expect("plan succeeds"); + assert!(!changes.has_conflicts()); + changes.apply().expect("apply succeeds"); + + let written = read(&path); + assert_eq!(written, fixture()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "a file we create must be owner-only" + ); + } + assert!( + super::super::json_merge::state_path(&path).exists(), + "a merge sidecar must be written alongside the created file" + ); +} + +// --- plan(): user entries survive, re-pairing replaces without duplicating --- + +#[test] +fn user_vendor_entry_survives_reapply_and_repairing_without_duplicates() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // A user hand-adds their own custom-endpoint vendor entry. + let mut document = read(&path); + document.as_array_mut().unwrap().push(json!({ + "name": "my-own-vendor", + "vendor": "customendpoint", + "apiKey": "sk-user", + "apiType": "chat-completions", + "models": [{"id": "gpt-mine", "url": "https://example.com/v1/chat/completions"}], + })); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + + // Re-apply unchanged: both the user's and our entry stay, ours not duplicated. + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let after_reapply = read(&path); + assert!( + after_reapply + .as_array() + .unwrap() + .iter() + .any(|vendor| vendor["name"] == "my-own-vendor"), + "user vendor missing from {after_reapply}" + ); + assert_eq!(our_vendor_count(&after_reapply), 1); + + // Re-pairing (new listen address and pairing value, e.g. FRESH=1 + // re-enrollment): our entry is replaced in place, not duplicated. + let new_listen: SocketAddr = "127.0.0.1:18099".parse().unwrap(); + apply_managed(&path, new_listen, "PAIRING-NEW", &config, &gateway); + let after_repair = read(&path); + assert!( + after_repair + .as_array() + .unwrap() + .iter() + .any(|vendor| vendor["name"] == "my-own-vendor") + ); + assert_eq!( + our_vendor_count(&after_repair), + 1, + "re-pairing must replace our vendor entry, not duplicate it" + ); + let ours = find_vendor(&after_repair, VsCodeConfig::VENDOR_NAME); + assert_eq!( + ours["models"][0]["url"], + "http://127.0.0.1:18099/vscode-copilot/v1/chat/completions" + ); + assert_eq!( + ours["models"][0]["requestHeaders"]["x-agentdesktop-pairing"], + "PAIRING-NEW" + ); +} + +// --- plan(): hand edits and dropped models are undone ----------------------- + +#[test] +fn a_hand_edit_to_the_managed_vendor_entry_is_replaced() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let mut document = read(&path); + for vendor in document.as_array_mut().unwrap() { + if vendor["name"] == VsCodeConfig::VENDOR_NAME { + vendor["models"][0]["name"] = json!("edited-by-hand"); + vendor["apiType"] = json!("edited"); + } + } + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + + // Re-apply with a new pairing: the whole vendor entry is replaced, not + // merged field by field; the edit is gone and no duplicate appears. + apply_managed(&path, listen_addr(), "PAIRING-NEW", &config, &gateway); + let after = read(&path); + assert_eq!(our_vendor_count(&after), 1); + let ours = find_vendor(&after, VsCodeConfig::VENDOR_NAME); + assert_eq!(ours["apiType"], "chat-completions"); + assert_eq!(ours["models"][0]["name"], "gpt-4.1-mini (agentdesktop)"); +} + +#[test] +fn a_dropped_model_disappears() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let mut config = sample_config(); + config.models.insert( + "gpt-4.1".to_owned(), + VsCodeModel { + name: None, + tool_calling: true, + vision: false, + max_input_tokens: None, + max_output_tokens: None, + extra: BTreeMap::new(), + }, + ); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let first = read(&path); + let ours = find_vendor(&first, VsCodeConfig::VENDOR_NAME); + assert_eq!(ours["models"].as_array().unwrap().len(), 2); + + // The admin drops the extra model from the config. + config.models.remove("gpt-4.1"); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let after = read(&path); + let ours = find_vendor(&after, VsCodeConfig::VENDOR_NAME); + let models = ours["models"].as_array().unwrap(); + assert_eq!(models.len(), 1, "{after}"); + assert!(!models.iter().any(|model| model["id"] == "gpt-4.1")); +} + +// --- plan(): removal -------------------------------------------------------- + +#[test] +fn removal_deletes_the_file_only_when_we_created_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + assert!(path.exists()); + + apply_removal(&path).apply().unwrap(); + + assert!( + !path.exists(), + "a file we created must be removed once nothing else remains" + ); + assert!(!super::super::json_merge::state_path(&path).exists()); +} + +#[test] +fn removal_keeps_a_preexisting_user_owned_file_and_its_mode() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + write_user_owned_document(&path); + + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + apply_removal(&path).apply().unwrap(); + + assert!( + path.exists(), + "a file we did not create must not be deleted" + ); + let remaining = read(&path); + assert_eq!(remaining.as_array().unwrap().len(), 1); + assert_eq!(remaining[0]["name"], "my-own-vendor"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "removal must not loosen the mode of a file that may hold the user's own keys" + ); + } +} + +#[test] +fn removal_when_use_llm_gateway_is_effectively_false() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `useLlmGateway: false`, or no top-level `llmGateway`: the caller passes + // the program's config with the gateway filtered to `None`. + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, None)), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + assert!(!path.exists()); +} + +#[test] +fn removal_when_the_loopback_proxy_is_unavailable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `ctx.llm_proxy` is `None` (bind failed, proxy off, no pairing) even + // though the program is still configured with `useLlmGateway: true`. + let changes = ReconcilePlan::default(); + plan(&path, None, Some((&config, Some(&gateway))), &changes).unwrap(); + changes.apply().unwrap(); + + assert!( + !path.exists(), + "our vendor entry must be removed when the proxy is unavailable, even if programs.vscode is still configured" + ); +} + +#[test] +fn removal_without_a_sidecar_strips_entries_that_carry_a_pairing_header() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + write_user_owned_document(&path); + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + fs::remove_file(super::super::json_merge::state_path(&path)).unwrap(); + + let removal = apply_removal(&path); + assert!(!removal.render().contains(PAIRING)); + removal.apply().unwrap(); + let after = read(&path); + assert_eq!(our_vendor_count(&after), 0, "{after}"); + assert!( + after + .as_array() + .unwrap() + .iter() + .all(|vendor| vendor["name"] == "my-own-vendor") + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } +} + +// --- plan(): conflicts ------------------------------------------------------ + +#[test] +fn conflict_on_a_foreign_vendor_entry_named_agentdesktop() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!([{ + "name": "agentdesktop", + "vendor": "customendpoint", + "apiKey": "sk-user", + "apiType": "chat-completions", + "models": [{"id": "not-ours", "url": "https://not-ours.example.com/v1/chat/completions"}], + }])) + .unwrap(), + ) + .unwrap(); + let before = fs::read(&path).unwrap(); + + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + + assert!(changes.has_conflicts()); + assert!(changes.render().contains("CONFLICT")); + assert!(changes.apply().is_err()); + assert_eq!( + fs::read(&path).unwrap(), + before, + "a conflict must write nothing" + ); +} + +#[test] +fn conflict_on_a_non_array_non_object_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + // The spec's conflict shape is neither array nor object; an object root + // (`{}`) is deliberately not used here, since it is the merge's own + // "nothing here yet" representation for an object-shaped managed + // document and would not exercise the same code path. + fs::write(&path, b"42\n").unwrap(); + + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + + assert!(changes.has_conflicts()); + assert!(changes.apply().is_err()); + assert_eq!(fs::read(&path).unwrap(), b"42\n"); +} + +// --- Provider::plan: system-mode rejection ---------------------------------- + +#[test] +fn system_mode_rejects_a_configured_program_before_writing_anything() { + use super::VsCode; + use crate::provider::{Provider, ReconcileContext}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = parse_programs( + "llmGateway:\n url: https://gateway.example\nprograms:\n vscode:\n models:\n gpt-4.1-mini: {}\n", + ) + .unwrap(); + let context = |merge_user_settings: bool| ReconcileContext { + merge_user_settings, + credential_helper: dir.path().join("agentdesktop"), + socket: dir.path().join("agentdesktop.sock"), + llm_proxy: None, + }; + // System mode: no chat models path, or a path but no user settings. + for provider in [ + VsCode { + chat_models_path: None, + settings_path: None, + }, + VsCode { + chat_models_path: Some(path.clone()), + settings_path: Some(path.with_file_name("settings.json")), + }, + ] { + let error = match provider.plan(&context(false), &config) { + Ok(_) => panic!("system mode with programs.vscode configured must fail"), + Err(error) => error, + }; + assert!(error.to_string().contains("--user"), "{error}"); + assert!(error.to_string().contains("VS Code"), "{error}"); + } + assert!(!path.exists(), "preflight failure must not write any files"); +} + +#[test] +fn config_rejects_reserved_and_secret_keys_in_any_case() { + for key in ["ID", "Url", "RequestHeaders", "apikey", "APIKEY", "ApiKey"] { + let error = parse_programs(&format!( + "programs:\n vscode:\n models:\n x:\n {key}: y\n" + )) + .unwrap_err(); + assert!( + format!("{error:#}").contains("models.x."), + "{key}: {error:#}" + ); + } + parse_programs("programs:\n vscode:\n models:\n x:\n apiKeyHint: y\n") + .expect("a key that merely starts like a reserved one passes"); +} + +#[test] +fn github_models_through_the_provider_removes_the_chat_models_entry_and_writes_settings() { + use super::VsCode; + use crate::provider::{Provider, ReconcileContext}; + let dir = tempfile::tempdir().unwrap(); + let chat_models = dir.path().join("chatLanguageModels.json"); + let settings = dir.path().join("settings.json"); + let pairing: std::sync::Arc = "PAIRING-FIXTURE".into(); + let context = ReconcileContext { + merge_user_settings: true, + credential_helper: dir.path().join("agentdesktop"), + socket: dir.path().join("agentdesktop.sock"), + llm_proxy: Some(crate::llm_proxy::LlmProxyContext { + address: "127.0.0.1:4000".parse().unwrap(), + pairing: pairing.clone(), + }), + }; + let provider = VsCode { + chat_models_path: Some(chat_models.clone()), + settings_path: Some(settings.clone()), + }; + // First `ownModels`: the custom-models entry exists. + let own = parse_programs( + "llmGateway:\n url: https://gateway.example\n proxyUrl: https://gateway.example/copilot-proxy\nprograms:\n vscode:\n models:\n gpt-4.1-mini: {}\n", + ) + .unwrap(); + provider.plan(&context, &own).unwrap().apply().unwrap(); + assert!(chat_models.exists()); + assert!(!settings.exists()); + // Then `githubModels`: the entry goes (the file was ours, so it is deleted) + // and the settings override arrives. + let github = parse_programs( + "llmGateway:\n url: https://gateway.example\n proxyUrl: https://gateway.example/copilot-proxy\nprograms:\n vscode:\n copilotChat: githubModels\n", + ) + .unwrap(); + provider.plan(&context, &github).unwrap().apply().unwrap(); + assert!( + !chat_models.exists(), + "the daemon-created custom-models file is removed under githubModels" + ); + let written: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + written["github.copilot.advanced.debug.overrideCapiUrl"], + "http://127.0.0.1:4000/vscode-copilot-capi/PAIRING-FIXTURE" + ); + // And back to `ownModels`: the override goes, the entry returns. + provider.plan(&context, &own).unwrap().apply().unwrap(); + assert!(chat_models.exists()); + assert!( + !settings.exists(), + "a settings file the daemon created and emptied is deleted" + ); +} diff --git a/crates/agent/src/reconcile/mod.rs b/crates/agent/src/reconcile/mod.rs index d546bdd1..b766326a 100644 --- a/crates/agent/src/reconcile/mod.rs +++ b/crates/agent/src/reconcile/mod.rs @@ -4,7 +4,8 @@ pub use plan::ReconcilePlan; use crate::provider::{ Provider, ReconcileContext, claude_code::ClaudeCode, claude_desktop::ClaudeDesktop, - codex::Codex, cursor::Cursor, grok::Grok, ollama::Ollama, opencode::OpenCode, vscode::VsCode, + codex::Codex, copilot::Copilot, cursor::Cursor, grok::Grok, ollama::Ollama, opencode::OpenCode, + vscode::VsCode, }; use agentdesktop_core::{config::DaemonConfig, model::Discovery}; use serde_json::Value; @@ -23,8 +24,10 @@ pub use crate::provider::{ default_claude_desktop_credential_helper_path, default_claude_desktop_managed_settings_path, }, codex::default_codex_managed_config_path, + copilot::default_copilot_providers_path, grok::default_grok_managed_config_path, opencode::{default_open_code_managed_config_path, default_open_code_plugin_path}, + vscode::{default_vscode_chat_models_path, default_vscode_settings_path}, }; #[derive(Clone)] @@ -44,6 +47,9 @@ impl Reconciler { open_code_managed_config_path: PathBuf, open_code_plugin_path: PathBuf, grok_managed_config_path: PathBuf, + copilot_providers_path: Option, + vscode_chat_models_path: Option, + vscode_settings_path: Option, credential_helper: PathBuf, socket: PathBuf, ) -> Self { @@ -69,11 +75,17 @@ impl Reconciler { managed_config_path: open_code_managed_config_path, plugin_path: open_code_plugin_path, }), - Box::new(VsCode), + Box::new(VsCode { + chat_models_path: vscode_chat_models_path, + settings_path: vscode_settings_path, + }), Box::new(Cursor), Box::new(Grok { managed_config_path: grok_managed_config_path, }), + Box::new(Copilot { + providers_path: copilot_providers_path, + }), Box::new(Ollama), ]), } @@ -281,6 +293,9 @@ programs: root.join("opencode/config.json"), root.join("opencode/plugin.js"), root.join("grok/managed_config.toml"), + Some(root.join("copilot/providers.json")), + None, + None, root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -319,6 +334,9 @@ programs: root.join("opencode/config.json"), root.join("opencode/plugin.js"), root.join("grok/managed_config.toml"), + Some(root.join("copilot/providers.json")), + None, + None, root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -328,6 +346,158 @@ programs: assert!(!root.exists(), "preflight failure must not write any files"); } + #[test] + fn copilot_directory_is_created_owner_only_through_the_reconciler() { + // The private-directory request must survive `ReconcilePlan::append`, + // which the daemon always goes through (it was once dropped there). + let root = std::env::temp_dir().join(format!( + "agentdesktop-reconcile-copilot-dir-{}-{}", + std::process::id(), + rand::random::() + )); + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + copilot: + models: + gpt-4.1: {} +"#, + ) + .unwrap(); + let providers = root.join("copilot/.copilot/providers.json"); + let reconciler = Reconciler::new( + true, + root.join("claude/settings.json"), + root.join("claude-desktop/settings.json"), + root.join("claude-desktop/helper"), + root.join("codex/config.toml"), + root.join("opencode/config.json"), + root.join("opencode/plugin.js"), + root.join("grok/managed_config.toml"), + Some(providers.clone()), + None, + None, + root.join("bin/agentdesktop"), + root.join("agentdesktop.sock"), + ) + .with_llm_proxy(Some(crate::llm_proxy::LlmProxyContext { + address: "127.0.0.1:18095".parse().unwrap(), + pairing: std::sync::Arc::from("PAIRING-RECONCILER"), + })); + reconciler.apply(&config).expect("apply"); + assert!(providers.is_file()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(providers.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700, "directory created through the reconciler"); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn vscode_directory_is_created_owner_only_through_the_reconciler() { + // Same private-directory request as the Copilot CLI test above, for + // the VS Code user profile directory. + let root = std::env::temp_dir().join(format!( + "agentdesktop-reconcile-vscode-dir-{}-{}", + std::process::id(), + rand::random::() + )); + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + vscode: + models: + gpt-4.1-mini: {} +"#, + ) + .unwrap(); + let chat_models = root.join("vscode/User/chatLanguageModels.json"); + let reconciler = Reconciler::new( + true, + root.join("claude/settings.json"), + root.join("claude-desktop/settings.json"), + root.join("claude-desktop/helper"), + root.join("codex/config.toml"), + root.join("opencode/config.json"), + root.join("opencode/plugin.js"), + root.join("grok/managed_config.toml"), + Some(root.join("copilot/providers.json")), + Some(chat_models.clone()), + Some(chat_models.with_file_name("settings.json")), + root.join("bin/agentdesktop"), + root.join("agentdesktop.sock"), + ) + .with_llm_proxy(Some(crate::llm_proxy::LlmProxyContext { + address: "127.0.0.1:18095".parse().unwrap(), + pairing: std::sync::Arc::from("PAIRING-RECONCILER"), + })); + reconciler.apply(&config).expect("apply"); + assert!(chat_models.is_file()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(chat_models.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700, "directory created through the reconciler"); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn system_mode_rejects_copilot_before_writing_other_settings() { + let root = std::env::temp_dir().join(format!( + "agentdesktop-reconcile-system-copilot-{}-{}", + std::process::id(), + rand::random::() + )); + let config = parse_daemon( + r#" +programs: + claudeCode: {} + copilot: {} +"#, + ) + .unwrap(); + // The Copilot CLI program is the inverse of Grok: it manages a file in + // the user's home, so a system daemon (no providers path) rejects it + // before any provider writes. + let reconciler = Reconciler::new( + false, + root.join("claude/settings.json"), + root.join("claude-desktop/settings.json"), + root.join("claude-desktop/helper"), + root.join("codex/config.toml"), + root.join("opencode/config.json"), + root.join("opencode/plugin.js"), + root.join("grok/managed_config.toml"), + None, + None, + None, + root.join("bin/agentdesktop"), + root.join("agentdesktop.sock"), + ); + + let error = reconciler + .apply(&config) + .expect_err("system mode must fail"); + assert!(error.to_string().contains("GitHub Copilot CLI"), "{error}"); + assert!(error.to_string().contains("--user"), "{error}"); + assert!(!root.exists(), "preflight failure must not write any files"); + } + #[cfg(windows)] #[test] fn windows_reconciles_system_grok_managed_config() { @@ -374,6 +544,9 @@ programs: root.join("opencode/config.json"), root.join("opencode/plugin.js"), root.join("grok/managed_config.toml"), + Some(root.join("copilot/providers.json")), + None, + None, root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -415,6 +588,9 @@ programs: root.join("opencode/config.json"), root.join("opencode/plugin.js"), root.join("grok/managed_config.toml"), + Some(root.join("copilot/providers.json")), + None, + None, root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); diff --git a/crates/agent/src/reconcile/plan.rs b/crates/agent/src/reconcile/plan.rs index f28a27c1..fbef996a 100644 --- a/crates/agent/src/reconcile/plan.rs +++ b/crates/agent/src/reconcile/plan.rs @@ -14,6 +14,7 @@ use std::{ pub struct ReconcilePlan { observed: RefCell>>>, operations: RefCell>, + private_dirs: PrivateDirs, report: DryRunReport, } @@ -23,6 +24,12 @@ struct FileChange { permissions: u32, } +/// A directory to create owner-only before the file changes are applied. +/// Used for a directory that will hold a secret-bearing file and does not +/// exist yet; an existing directory is left as it is. +#[derive(Default)] +struct PrivateDirs(RefCell>); + impl ReconcilePlan { pub fn render(&self) -> String { self.report.render() @@ -61,6 +68,11 @@ impl ReconcilePlan { path.display() ); } + for dir in self.private_dirs.0.into_inner() { + if !dir.exists() { + crate::secure_fs::ensure_private_dir(&dir)?; + } + } for change in self.operations.into_inner() { match change.contents { Some(contents) => { @@ -93,6 +105,10 @@ impl ReconcilePlan { } observations.insert(path, expected); } + self.private_dirs + .0 + .get_mut() + .extend(other.private_dirs.0.into_inner()); let operations = self.operations.get_mut(); for change in other.operations.into_inner() { anyhow::ensure!( @@ -144,6 +160,11 @@ impl ReconcilePlan { Ok(()) } + /// Create `dir` owner-only at apply time if it does not exist by then. + pub(crate) fn ensure_private_dir(&self, dir: &Path) { + self.private_dirs.0.borrow_mut().push(dir.to_owned()); + } + pub(crate) fn remove_file(&self, path: &Path) -> io::Result<()> { self.read(path)?; self.operations.borrow_mut().push(FileChange { diff --git a/crates/agent/tests/common/container.rs b/crates/agent/tests/common/container.rs index b3158efa..dad4bfd1 100644 --- a/crates/agent/tests/common/container.rs +++ b/crates/agent/tests/common/container.rs @@ -179,11 +179,15 @@ impl Container { result } + /// Writes the file as the container's default user (root). An existing + /// file is removed first: with `fs.protected_regular=2` (the default on + /// current kernels) root cannot open a file another user owns inside a + /// sticky world-writable directory such as `/tmp` for writing. pub async fn write(&self, path: &str, contents: &str) -> anyhow::Result<()> { self.exec(&[ "sh", "-c", - "mkdir -p -- \"$(dirname -- \"$1\")\" && printf '%s' \"$2\" > \"$1\"", + "mkdir -p -- \"$(dirname -- \"$1\")\" && rm -f -- \"$1\" && printf '%s' \"$2\" > \"$1\"", "write", path, contents, @@ -216,8 +220,17 @@ impl Container { pub async fn stop_process(&self, name: &str) -> anyhow::Result<()> { let started = Instant::now(); let pid = self.read(&format!("/tmp/{name}.pid")).await?; - self.exec(&["sh", "-c", "kill -INT \"$1\"", "stop", pid.trim()]) - .await?; + // The recorded PID is the launcher (`runuser`, which ignores SIGINT + // while its child runs, as `su` does), so the signal also goes to its + // children, read from /proc since the image has no pgrep. + self.exec(&[ + "sh", + "-c", + "p=$1; for d in /proc/[0-9]*; do s=$(cat \"$d/stat\" 2>/dev/null) || continue; r=${s##*) }; set -- $r; [ \"$2\" = \"$p\" ] && kill -INT \"${d#/proc/}\" 2>/dev/null; done; kill -INT \"$p\" 2>/dev/null; exit 0", + "stop", + pid.trim(), + ]) + .await?; self.exec(&[ "timeout", "10", diff --git a/crates/agent/tests/common/gateway.rs b/crates/agent/tests/common/gateway.rs index fcbe133f..d4d15708 100644 --- a/crates/agent/tests/common/gateway.rs +++ b/crates/agent/tests/common/gateway.rs @@ -72,8 +72,9 @@ async fn handle( }; let authorization = headers.get("authorization").and_then(|v| v.to_str().ok()); let api_key = headers.get("x-api-key").and_then(|v| v.to_str().ok()); + let llm_token = headers.get("x-llm-token").and_then(|v| v.to_str().ok()); tracing::debug!(%method, path, "Gateway fixture request"); - requests.lock().unwrap().push(json!({"path": path, "method": method.as_str(), "authorization": authorization, "apiKey": api_key, "body": body})); + requests.lock().unwrap().push(json!({"path": path, "method": method.as_str(), "authorization": authorization, "apiKey": api_key, "llmToken": llm_token, "body": body})); if path == "/health" { return Json(json!({"ready": true})).into_response(); } diff --git a/crates/agent/tests/integration.rs b/crates/agent/tests/integration.rs index 76b20db0..7896e6fa 100644 --- a/crates/agent/tests/integration.rs +++ b/crates/agent/tests/integration.rs @@ -17,3 +17,6 @@ mod cursor; #[path = "../src/provider/grok/integration_tests.rs"] mod grok; + +#[path = "../src/provider/copilot/integration_tests.rs"] +mod copilot; diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 53fed7af..c1fbc58d 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -76,6 +76,18 @@ pub struct DaemonStartupConfig { /// Grok Build paths. #[serde(default)] pub grok: ToolConfigPath, + /// GitHub Copilot CLI paths (`config` = the `providers.json` to manage; + /// defaults to `COPILOT_PROVIDERS_CONFIG`, `$COPILOT_HOME/providers.json`, + /// then `~/.copilot/providers.json`). + #[serde(default)] + pub copilot: ToolConfigPath, + /// VS Code paths (`config` = the `chatLanguageModels.json` to manage under + /// the `ownModels` variant of `copilotChat`, `settings` = the user + /// `settings.json` to manage under the `githubModels` variant; each + /// defaults to its file inside the per-OS VS Code user profile + /// directory). + #[serde(default)] + pub vscode: VsCodeStartupConfig, /// Local loopback LLM proxy. #[serde(default)] pub llm_proxy: LlmProxyStartupConfig, @@ -118,6 +130,25 @@ pub struct ToolConfigPath { pub config: Option, } +/// VS Code paths: own type (not the shared `ToolConfigPath`) because the +/// `githubModels` variant of `copilotChat` manages a second +/// file, the user's `settings.json`, alongside `chatLanguageModels.json`. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct VsCodeStartupConfig { + /// `chatLanguageModels.json` to manage (the `ownModels` variant of + /// `copilotChat`). Defaults to that file inside the per-OS VS Code user + /// profile directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + /// The user `settings.json` to manage (the `githubModels` variant of + /// `copilotChat`). Defaults to that file inside the per-OS VS Code user + /// profile directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings: Option, +} + /// Local OpenCode paths. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -565,6 +596,13 @@ pub struct ProgramsConfig { /// Grok Build managed configuration. #[serde(default, skip_serializing_if = "Option::is_none")] pub grok: Option, + /// GitHub Copilot CLI managed configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub copilot: Option, + /// VS Code Copilot Chat managed configuration (own models through the + /// loopback proxy, or GitHub's models through the gateway). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vscode: Option, } impl ProgramsConfig { @@ -574,6 +612,8 @@ impl ProgramsConfig { && self.codex.is_none() && self.open_code.is_none() && self.grok.is_none() + && self.copilot.is_none() + && self.vscode.is_none() } } @@ -700,6 +740,321 @@ pub struct GrokConfig { pub managed_config: BTreeMap, } +/// Settings reconciled into GitHub Copilot CLI's `providers.json`. +/// +/// `providers.json` is merged, never owned outright: the CLI has no +/// managed-settings equivalent for providers, and users may have their own +/// BYOK entries. See `provider::copilot::reconcile` in the agent crate for +/// how this is turned into the merged document. User mode only: the file +/// lives in the user's Copilot directory, so a system daemon rejects the +/// program. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CopilotConfig { + /// Whether this program uses the top-level LLM gateway. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub use_llm_gateway: bool, + /// Copilot CLI model entries, keyed by the model ID the CLI shows + /// (`copilot --model agentdesktop/`). + /// + /// At least one is required when a top-level `llmGateway` is configured. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub models: BTreeMap, +} + +/// One Copilot CLI model entry. The entry's `id` is the map key; `provider` +/// and `modelId` are the typed fields below; any other key (for example +/// `wireModel`, the name sent upstream when it differs) is passed through to +/// the CLI unchanged. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct CopilotModel { + /// Which managed provider entry serves the model. + #[serde(default)] + pub provider: CopilotProvider, + /// The CLI's `modelId` for the entry. Defaults to the entry's ID. The + /// name sent to the gateway is `wireModel` when that pass-through key is + /// set, else this one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + /// Further Copilot CLI model keys, passed through unchanged. + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// The two provider entries agentdesktop writes into `providers.json`, one per +/// API shape the proxy's `/copilot-cli` route serves. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub enum CopilotProvider { + /// OpenAI-compatible, `http:///copilot-cli/v1`. + #[default] + #[serde(rename = "agentdesktop")] + Agentdesktop, + /// Anthropic, `http:///copilot-cli`. + #[serde(rename = "agentdesktop-anthropic")] + AgentdesktopAnthropic, +} + +impl CopilotProvider { + /// The provider entry's `name` in `providers.json`. + pub fn name(self) -> &'static str { + match self { + Self::Agentdesktop => CopilotConfig::PROVIDER_OPENAI, + Self::AgentdesktopAnthropic => CopilotConfig::PROVIDER_ANTHROPIC, + } + } +} + +impl CopilotConfig { + /// Provider name for the OpenAI-shaped route (`/copilot-cli/v1`). + pub const PROVIDER_OPENAI: &'static str = "agentdesktop"; + /// Provider name for the Anthropic-shaped route (`/copilot-cli`). + pub const PROVIDER_ANTHROPIC: &'static str = "agentdesktop-anthropic"; + /// The entry's ID comes from the map key; a pass-through key must not set it. + pub const RESERVED_MODEL_KEYS: [&'static str; 1] = ["id"]; + + /// The model objects written to `providers.json`, in ID order: `id`, + /// `provider`, `modelId` (defaulting to the ID) and the pass-through keys. + pub fn model_documents(&self) -> Vec { + self.models + .iter() + .map(|(id, model)| { + let mut object = serde_json::Map::new(); + object.insert( + "provider".to_owned(), + serde_json::Value::String(model.provider.name().to_owned()), + ); + object.insert("id".to_owned(), serde_json::Value::String(id.clone())); + object.insert( + "modelId".to_owned(), + serde_json::Value::String(model.model_id.clone().unwrap_or_else(|| id.clone())), + ); + for (key, value) in &model.extra { + object.insert(key.clone(), value.clone()); + } + serde_json::Value::Object(object) + }) + .collect() + } + + /// Rejects an empty model ID, an empty `modelId`, a pass-through `id` + /// and a pass-through `apiKey`. Called from daemon config validation, so + /// the controller refuses the config before any device sees it. + pub fn validate(&self) -> anyhow::Result<()> { + for (id, model) in &self.models { + if id.trim().is_empty() { + anyhow::bail!("programs.copilot.models has an entry with an empty ID"); + } + if model + .model_id + .as_deref() + .is_some_and(|value| value.trim().is_empty()) + { + anyhow::bail!("programs.copilot.models.{id}.modelId must not be empty"); + } + if let Some(key) = model.extra.keys().find(|key| { + Self::RESERVED_MODEL_KEYS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(key)) + }) { + anyhow::bail!( + "programs.copilot.models.{id}.{key} is set by agentdesktop and cannot be overridden" + ); + } + if model + .extra + .keys() + .any(|key| key.eq_ignore_ascii_case("apiKey")) + { + anyhow::bail!( + "programs.copilot.models.{id}.apiKey is not allowed: the local proxy adds the gateway credential" + ); + } + } + Ok(()) + } +} + +/// Settings reconciled into VS Code's Copilot Chat configuration. Under +/// `copilotChat: ownModels` this is `chatLanguageModels.json`, pointing +/// Copilot Chat's built-in "Custom Endpoint" model provider at the local LLM +/// proxy's `/vscode-copilot` route; under `copilotChat: githubModels` +/// it is the user `settings.json`, pointing Copilot Chat's +/// CAPI endpoint at the loopback proxy's `/vscode-copilot-capi/` +/// route so VS Code keeps GitHub's own models and its own Copilot token while +/// the daemon adds the gateway identity. See `provider::vscode::reconcile` +/// and `provider::vscode::settings` in the agent crate for how each is turned +/// into its merged document. User mode only: both files live in the user's +/// VS Code profile directory, so a system daemon rejects the program. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct VsCodeConfig { + /// Whether this program uses the top-level LLM gateway. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub use_llm_gateway: bool, + /// Which Copilot Chat model source VS Code is pointed at: agentdesktop's + /// own custom models (`ownModels`), or GitHub's own models reached + /// through the gateway (`githubModels`). + #[serde(default)] + pub copilot_chat: VsCodeCopilotChat, + /// Custom model entries exposed to VS Code's Copilot Chat model picker, + /// keyed by the model ID VS Code sends as `model`. Only meaningful under + /// `copilotChat: ownModels`; `githubModels` rejects a non-empty map. + /// + /// At least one is required when a top-level `llmGateway` is configured + /// and `copilotChat` is `ownModels`. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub models: BTreeMap, +} + +/// Which Copilot Chat model source VS Code is pointed at. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum VsCodeCopilotChat { + /// Custom models served through the local LLM proxy's `/vscode-copilot` + /// route (VS Code's "Custom Endpoint" provider). + #[default] + OwnModels, + /// GitHub's own models, reached through the gateway's pass-through route + /// (`llmGateway.proxyUrl`) with the user's GitHub Copilot token forwarded: + /// the daemon points `github.copilot.advanced.debug.overrideCapiUrl` in + /// the user `settings.json` at the loopback proxy's + /// `/vscode-copilot-capi/` route. `models` must be empty. + GithubModels, +} + +/// One VS Code custom model entry. The entry's `id` is the map key; other +/// typed fields below match VS Code's `chatLanguageModels.json` model shape; +/// any other key is passed through to VS Code unchanged. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct VsCodeModel { + /// Display name shown in VS Code's model picker. Defaults to + /// `" (agentdesktop)"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Whether the model supports tool calling. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub tool_calling: bool, + /// Whether the model supports image input. + #[serde(default)] + pub vision: bool, + /// Maximum input tokens accepted by the model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_input_tokens: Option, + /// Maximum output tokens produced by the model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + /// Further VS Code model keys, passed through unchanged. + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl Default for VsCodeModel { + fn default() -> Self { + Self { + name: None, + tool_calling: true, + vision: false, + max_input_tokens: None, + max_output_tokens: None, + extra: BTreeMap::new(), + } + } +} + +impl VsCodeConfig { + /// The `name` of the single vendor entry agentdesktop owns in + /// `chatLanguageModels.json`, matching the Copilot CLI provider's name. + pub const VENDOR_NAME: &'static str = "agentdesktop"; + /// Keys agentdesktop sets on every model entry; a pass-through key must + /// not set them. + pub const RESERVED_MODEL_KEYS: [&'static str; 3] = ["id", "url", "requestHeaders"]; + + /// The configured part of each model object in the vendor entry's + /// `models` array, in ID order: `id`, `name` (defaulting to + /// `" (agentdesktop)"`), `toolCalling`, `vision`, the optional token + /// limits and the pass-through keys. The daemon adds the proxy `url` and + /// the pairing `requestHeaders`; the core crate knows neither. + pub fn model_documents(&self) -> Vec { + self.models + .iter() + .map(|(id, model)| { + let mut object = serde_json::Map::new(); + object.insert("id".to_owned(), serde_json::Value::String(id.clone())); + object.insert( + "name".to_owned(), + serde_json::Value::String( + model + .name + .clone() + .unwrap_or_else(|| format!("{id} ({})", Self::VENDOR_NAME)), + ), + ); + object.insert( + "toolCalling".to_owned(), + serde_json::Value::Bool(model.tool_calling), + ); + object.insert("vision".to_owned(), serde_json::Value::Bool(model.vision)); + if let Some(limit) = model.max_input_tokens { + object.insert("maxInputTokens".to_owned(), serde_json::Value::from(limit)); + } + if let Some(limit) = model.max_output_tokens { + object.insert("maxOutputTokens".to_owned(), serde_json::Value::from(limit)); + } + for (key, value) in &model.extra { + object.insert(key.clone(), value.clone()); + } + serde_json::Value::Object(object) + }) + .collect() + } + + /// Rejects an empty model ID, a pass-through `id`, `url` or + /// `requestHeaders`, a pass-through `apiKey`, and a non-empty `models` + /// under `copilotChat: githubModels` (that variant has no custom models + /// of its own; GitHub's own model picker is used instead). Called from + /// daemon config validation, so the controller refuses the config before + /// any device sees it. + pub fn validate(&self) -> anyhow::Result<()> { + if self.copilot_chat == VsCodeCopilotChat::GithubModels && !self.models.is_empty() { + anyhow::bail!( + "programs.vscode.models is not allowed when copilotChat is githubModels: GitHub's own models are used instead" + ); + } + for (id, model) in &self.models { + if id.trim().is_empty() { + anyhow::bail!("programs.vscode.models has an entry with an empty ID"); + } + if model + .extra + .keys() + .any(|key| key.eq_ignore_ascii_case("apiKey")) + { + anyhow::bail!( + "programs.vscode.models.{id}.apiKey is not allowed: the local proxy adds the gateway credential" + ); + } + if let Some(key) = model.extra.keys().find(|key| { + Self::RESERVED_MODEL_KEYS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(key)) + }) { + anyhow::bail!( + "programs.vscode.models.{id}.{key} is set by agentdesktop and cannot be overridden" + ); + } + } + Ok(()) + } +} + /// Upstream authentication selected by a managed agent. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -842,6 +1197,12 @@ fn validate_daemon( if programs.grok.is_some() { anyhow::bail!("sandbox is not supported for Grok Build"); } + if programs.copilot.is_some() { + anyhow::bail!("sandbox is not supported for GitHub Copilot CLI"); + } + if programs.vscode.is_some() { + anyhow::bail!("sandbox is not supported for VS Code"); + } } if let Some(gateway) = llm_gateway { if !matches!(gateway.url.scheme(), "http" | "https") { @@ -1036,6 +1397,36 @@ fn validate_daemon( anyhow::bail!("Grok Build model {model} is not declared in models"); } } + if let Some(copilot) = &programs.copilot { + copilot.validate()?; + if llm_gateway.is_some() && copilot.use_llm_gateway && copilot.models.is_empty() { + anyhow::bail!( + "GitHub Copilot CLI requires at least one entry in models when llmGateway is configured" + ); + } + } + if let Some(vscode) = &programs.vscode { + vscode.validate()?; + // Matched, not defaulted: a new model source must decide its own rules. + match vscode.copilot_chat { + VsCodeCopilotChat::OwnModels => { + if llm_gateway.is_some() && vscode.use_llm_gateway && vscode.models.is_empty() { + anyhow::bail!( + "VS Code requires at least one entry in models when llmGateway is configured" + ); + } + } + VsCodeCopilotChat::GithubModels => { + if vscode.use_llm_gateway + && llm_gateway.is_some_and(|gateway| gateway.proxy_url.is_none()) + { + anyhow::bail!( + "VS Code copilotChat githubModels requires llmGateway.proxyUrl when the gateway is used" + ); + } + } + } + } Ok(()) } diff --git a/frontend/controller/src/types.ts b/frontend/controller/src/types.ts index d3bb0aeb..e2ee8906 100644 --- a/frontend/controller/src/types.ts +++ b/frontend/controller/src/types.ts @@ -74,7 +74,9 @@ export type AgentKind = | "claudeDesktop" | "codex" | "openCode" - | "grok"; + | "grok" + | "copilot" + | "vscode"; export type AgentDraft = { kind: AgentKind; @@ -95,6 +97,7 @@ export type SandboxConfigDocument = { export type DaemonConfigDocument = { llmGateway?: { url: string; + proxyUrl?: string; authentication?: { type: string; audience?: string; diff --git a/frontend/controller/src/views/ConfigurationView.tsx b/frontend/controller/src/views/ConfigurationView.tsx index ad4ad3f3..351fa0a3 100644 --- a/frontend/controller/src/views/ConfigurationView.tsx +++ b/frontend/controller/src/views/ConfigurationView.tsx @@ -24,6 +24,7 @@ export function ConfigurationView({ const initializedFromController = useRef(false); const [gateway, setGateway] = useState(true); const [gatewayUrl, setGatewayUrl] = useState("https://gateway.example.com"); + const [gatewayProxyUrl, setGatewayProxyUrl] = useState(""); const [controllerJwt, setControllerJwt] = useState(true); const [audience, setAudience] = useState("agentgateway"); const [sessionNewTelemetry, setSessionNewTelemetry] = useState(false); @@ -48,6 +49,7 @@ export function ConfigurationView({ const yaml = daemonConfigYaml({ gateway, gatewayUrl, + gatewayProxyUrl, controllerJwt, audience, sandboxEnabled, @@ -75,6 +77,7 @@ export function ConfigurationView({ setGateway(Boolean(llmGateway)); if (llmGateway) { setGatewayUrl(llmGateway.url); + setGatewayProxyUrl(llmGateway.proxyUrl ?? ""); setControllerJwt(llmGateway.authentication?.type === "controllerJwt"); setAudience(llmGateway.authentication?.audience ?? "agentgateway"); } @@ -177,6 +180,21 @@ export function ConfigurationView({ onChange={(event) => setGatewayUrl(event.target.value)} /> +