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..9b6eec0b 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,37 @@ The incoming path and query are appended to the gateway base URL: with `llmGateway.proxyUrl: https://gateway.example/prefix` (or `url`, when `proxyUrl` is unset), `/v1/messages` goes to `https://gateway.example/prefix/v1/messages`. Avoid repeating `/v1` in both URLs. + +**Without the proxy.** When the daemon runs without its loopback proxy +(`daemon.llmProxy.listen` unset, or the address could not be bound), +`llmGateway.whenProxyUnavailable` decides what happens to the files of the +programs that use the proxy (`programs.copilot`, `programs.vscode`). With +`failClosed`, the default, the daemon leaves their managed entries as they +are: the Copilot CLI and VS Code stay pointed at the loopback port, and those +requests fail instead of reaching GitHub past the gateway. This needs entries +to be in place: on a first apply without the proxy, or after the entries were +removed by hand, nothing points at the loopback port, so VS Code on GitHub's +models talks to GitHub directly and the Copilot CLI and VS Code on own models +have no gateway models; the status says so. If the address could +not be bound because another process holds it, that process receives those +requests, with VS Code's Copilot tokens and the pairing value: use the default +on devices with a single user, as for the proxy in general. With +`failOpen`, the daemon removes the entries until the proxy is back: VS Code on +GitHub's models then talks to GitHub directly, and the Copilot CLI and VS Code +on own models lose the gateway's models. Either way the programs report +`inactive` with the reason, and the next apply with the proxy writes the +current entries. A stopped daemon changes no file, so the tools fail until it +runs again, whatever the policy. The policy covers only the files the daemon +writes: whether developers can reach GitHub's models without the gateway is +set by the organisation's Copilot policy and egress rules. Set the key only +after the controller and the daemons run a version that knows it; older ones +reject a configuration that carries it. + +```yaml +llmGateway: + url: https://gateway.example.com + whenProxyUnavailable: failOpen # default failClosed +``` The proxy does not discover models or rewrite model IDs; configure those in the client. VS Code's built-in **Custom Endpoint** provider can use this endpoint without the experimental extension in `vscode/`. The provider reads @@ -186,9 +217,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 +241,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 +265,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 +302,360 @@ 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`. +### GitHub Copilot: a runnable example + +[`examples/copilot`](examples/copilot/README.md) runs Dex and Agentgateway +locally and walks through the Copilot CLI, VS Code on the gateway's models and +VS Code on GitHub's models, with what to check at each step (drift, +conflicts, removal) and the settings the GitHub organisation owner controls +([`examples/copilot/github-side.md`](examples/copilot/github-side.md)). + +### 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. While the daemon runs +without the proxy it has no pairing of its own, so any pairing value counts +(only an agentdesktop daemon writes that header). The file is written +owner-only. The +daemon's sidecar next to it (`.providers.json.agentdesktop`, owner-only) keeps +a copy of the file as last written, including the user's own providers and +their `apiKey` values, until the next apply. Removing +`programs.copilot` or setting `useLlmGateway: false` takes the managed +entries out again (running without the proxy depends on +`llmGateway.whenProxyUnavailable`, see **Without the proxy**) 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 (while the daemon runs without the proxy, one carrying any pairing +value). 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). With the controller's default `gatewayJwt.lifetime` of 5 +minutes, and a credential that may have been cached for up to 60 s, a tunnel +is closed after 4 to 5 minutes, also in the middle of a turn; whether VS Code +then reconnects without the user noticing has not been verified. A longer +lifetime lengthens the tunnel and the revocation window together. 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` (owner-only, tightened the same way). 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, a daemon restart, a reconnect to the +controller, or the reconcile tick when `daemon.reconcileInterval` is set); 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. Exceptions: 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, an empty object +written over several lines comes back as `{}`, and a file that holds only +comments keeps them and gains `{}`. 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 sidecar the daemon cannot read (not JSON, or not a state +it knows) fails the apply like a conflict: every managed file on the device +stays untouched, also while `programs.vscode` is not configured (removal reads +the sidecar too), and the daemon does not start; without it the override's +earlier value could not be restored. Fix the sidecar, or delete it to go on +without it (the daemon then treats an override pointing at its own loopback +route as its own, and a value it replaced earlier is not restored). 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 (with `daemon.reconcileInterval` set, the next tick applies it). Removal (program +absent, `ownModels`, `useLlmGateway: false`, no gateway, or no `proxyUrl`) +takes the key and the two entries out and deletes the file only if the daemon +created it. Without the proxy, `llmGateway.whenProxyUnavailable` decides (see +**Without the proxy** above): the default keeps the override, so Copilot Chat +fails instead of reaching GitHub directly. 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, so it lists nothing for this file +under `failClosed` and the removal under `failOpen`; `--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 +``` + +## Periodic re-apply (opt-in) + +The daemon applies its configuration at startup and whenever the controller +pushes one; a controller-managed device also re-applies the controller's +configuration on every reconnect, which happens at least once per OIDC +access-token lifetime. To repair drift between those points, set an interval +in the local configuration: + +```yaml +daemon: + reconcileInterval: 5m +``` + +Every interval the daemon re-applies the current configuration (the local +file, or the last configuration the controller pushed). A managed file that +was deleted or edited by hand comes back, a file whose mode was loosened is +tightened, and a conflict that was fixed is applied. Nothing is written when +nothing changed, and a tick reports to the controller and logs its outcome lines only when the outcome changed; a provider warning that explains a conflict (for example the file it refuses to change) repeats with every apply until the conflict is fixed. Deleting a managed file is then no longer a way to opt out: +switch the program off in the configuration or stop the daemon instead. The +tick cannot start a local LLM proxy that was not running when the daemon +started (restart the daemon for that). Unset means no periodic re-apply; zero +and more than 30 days are rejected. The interval is read at startup only. After a +logout from the controller the tick stops re-applying (the managed files stay +until the next configuration). A controller configuration whose apply failed +is saved as the one to restore once a tick applies it; if the device loses the +connection before that and restarts offline, it restores the previous saved +configuration until the controller pushes again. + +Any apply (tick or not) leaves a managed file alone when it already holds the +planned bytes, and rewrites it with the same bytes when its mode grants more +than the daemon writes: for example a Claude Code user `settings.json` at +0664 becomes 0644, a Copilot CLI `providers.json` at 0644 becomes 0600. + ## Start locally, grow into a fleet Agentdesktop uses the same daemon and tool-native configuration model at every @@ -274,6 +668,44 @@ stage. ![Agentdesktop controller device inventory](images/controller-ui.png) +### Configuration status + +Each apply reports the outcome for every managed program, next to the +device-wide state. The device page in the controller lists them under +"Managed programs" (`GET /api/v1/devices/{id}` returns them as `programs`): + +| State | Meaning | +| --- | --- | +| `applied` | The program is configured and its files were changed. | +| `unchanged` | The program is configured and nothing needed to change. | +| `removed` | The program is no longer configured and its managed content was removed. | +| `conflict` | A managed file holds configuration the daemon will not overwrite; the detail names the file. | +| `inactive` | The program uses the LLM gateway but the local LLM proxy is not running (see `llmProxy.error` in daemon-info): its entries were left pointing at the loopback port (`whenProxyUnavailable: failClosed`, the default; the detail says when no entries were in place, so the tool keeps its own settings) or removed (`failOpen`). | +| `blocked` | The program had changes, but none were written because another program conflicted or failed; the detail names that program. | +| `failed` | Planning or writing this program failed; the detail carries the error. | + +When several apply, the first in the list `failed`, `conflict`, `blocked`, +`inactive`, `applied`/`removed`, `unchanged` is shown. An apply is all or +nothing across programs: one conflict or failure means no file is written for +any program, which is what `blocked` makes visible. Programs that are not +configured and have nothing to clean up, and discovery-only tools, are not +listed. After each push and startup apply the daemon logs one `program +configuration outcome` line (`program`, `state`, `detail`) per program that is +not `applied` or `unchanged`; a reconcile tick logs and reports only when the +outcome changed. A daemon restart reports again, because the controller re-sends +the configuration on every connection; the files are already written by the +startup apply of the saved configuration at that point, so the report after a +restart usually says `unchanged` (the startup apply's outcome lines for programs +that are not `applied` or `unchanged` are in the daemon log). If the controller +rejects a report (an agent outside the reporting limits), it keeps the device +status and the last accepted program rows; rows from an earlier revision show +that revision on the device page. Agents and controllers can be upgraded +in either order: an older agent shows as "Per-program status is not reported +by this agent version", and an older controller ignores the new fields. The +controller's database migration is one-way: once this controller has run, an +older controller refuses the same database, so roll back from a database +backup taken before the upgrade. + ## Core capabilities - **AI tool discovery:** detect supported developer tools and their versions @@ -303,8 +735,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..bd01c190 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -81,6 +81,14 @@ 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, + /// `daemon.reconcileInterval`; `None` means no periodic re-apply. + reconcile_interval: Option, once: bool, dry_run: bool, } @@ -131,6 +139,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 +202,15 @@ 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, + reconcile_interval: startup.reconcile_interval, once: self.once || self.dry_run, dry_run: self.dry_run, }); @@ -250,6 +281,23 @@ 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)), + ), + reconcile_interval: startup.reconcile_interval, once: self.once || self.dry_run, dry_run: self.dry_run, }) @@ -371,6 +419,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(), ) @@ -383,9 +434,9 @@ where .context("preview daemon configuration")?; } else { validate_one_shot(&config)?; - reconciler - .apply(&config) - .context("apply daemon configuration")?; + let (report, applied) = reconciler.apply_with_report(&config); + report.log(); + applied.context("apply daemon configuration")?; println!("Reconciliation complete."); } return Ok(()); @@ -432,21 +483,43 @@ where } else { Some(local_config) }; - if let Some(initial_config) = initial_config { - reconciler - .apply(&initial_config) - .context("apply initial daemon configuration")?; + if let Some(initial_config) = &initial_config { + // The outcomes are logged before a failure stops the daemon. + let (report, applied) = reconciler.apply_with_report(initial_config); + report.log(); + applied.context("apply initial daemon configuration")?; // With a controller, the cached controller configuration is what is in effect // from here, and start_gateway_authentication above only saw the local file. // Without one the two are the same configuration and it already ran. if config.controller.is_some() { - gateway_oidc::start_device_login_if_configured(&initial_config, &args.state_dir); + gateway_oidc::start_device_login_if_configured(initial_config, &args.state_dir); } } else { tracing::info!( "preserving managed files until the controller provides daemon configuration" ); } + // The current configuration, re-applied by the reconcile tick; held here + // for the daemon's lifetime and replaced by each pushed configuration. + let (current_config, current_config_receiver) = + watch::channel(initial_config.map(|config| crate::tick::CurrentConfig { + revision: None, + config: Arc::new(config), + })); + let (tick_status_sender, tick_status_receiver) = watch::channel(None); + let tick_enabled = args.reconcile_interval.is_some(); + if let Some(interval) = args.reconcile_interval { + tracing::info!( + interval_seconds = interval.as_secs_f64(), + "reconcile tick enabled" + ); + tokio::spawn(crate::tick::run_tick( + reconciler.clone(), + current_config_receiver, + interval, + config.controller.is_some().then_some(tick_status_sender), + )); + } let discovery = reconciler.discover().await; log_discovery(&discovery); let (inventory_sender, inventory) = watch::channel(Arc::new(discovery)); @@ -460,6 +533,7 @@ where let (logout_sender, logout_receiver) = mpsc::channel(1); let logout = config.controller.as_ref().map(|_| logout_sender); if let Some(controller) = config.controller.clone() { + let remote_current_config = current_config.clone(); let remote_discovery = inventory.clone(); let state_dir = args.state_dir.clone(); let oidc_callback_listen = args.oidc_callback_listen; @@ -475,6 +549,8 @@ where remote::Requests { telemetry: telemetry_receiver, logout: logout_receiver, + current: remote_current_config, + tick_statuses: tick_enabled.then_some(tick_status_receiver), }, )); } @@ -560,7 +636,7 @@ fn attach_llm_proxy_pairing( /// failure is logged and reported as `bound: false` rather than propagated: /// the proxy is optional, the rest of the daemon is not. /// -/// Invariant for callers: this runs before the initial `reconciler.apply`, so a +/// Invariant for callers: this runs before the initial apply, so a /// reconciler that writes the proxy address into a client file must take the /// address from this result and must not write it when `bound` is false. The /// accept loop starts later, after discovery; connections in between queue in @@ -856,6 +932,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 +972,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 +1703,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 +1798,69 @@ 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 the_reconcile_tick_is_off_unless_an_interval_is_set() { + let resolved = daemon_args(true) + .resolve( + config::DaemonStartupConfig::default(), + PathBuf::from("config.yaml"), + PathBuf::from("agentdesktop.sock"), + ) + .unwrap(); + assert_eq!(resolved.reconcile_interval, None); + let startup = config::DaemonStartupConfig { + reconcile_interval: Some(Duration::from_secs(120)), + ..Default::default() + }; + let resolved = daemon_args(true) + .resolve( + startup, + PathBuf::from("config.yaml"), + PathBuf::from("agentdesktop.sock"), + ) + .unwrap(); + assert_eq!(resolved.reconcile_interval, Some(Duration::from_secs(120))); + } + + #[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/lib.rs b/crates/agent/src/lib.rs index 9b16f3dd..b961d56a 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -14,6 +14,7 @@ pub mod remote; pub mod secret_store; pub mod secure_fs; pub mod subscription; +mod tick; #[cfg(windows)] mod windows_security; diff --git a/crates/agent/src/llm_proxy.rs b/crates/agent/src/llm_proxy.rs index 5fff6f92..b9ea6522 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,857 @@ 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). The cache reuses the entry for a minute, so the + // request always gets it however slowly it arrives; the + // credential itself expires in 300 ms, and that is the deadline + // the tunnel must close at. + 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_secs(60), + 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/claude_code/mod.rs b/crates/agent/src/provider/claude_code/mod.rs index 1acb4f8c..0c016487 100644 --- a/crates/agent/src/provider/claude_code/mod.rs +++ b/crates/agent/src/provider/claude_code/mod.rs @@ -70,6 +70,10 @@ impl ClaudeCode { #[async_trait::async_trait] impl Provider for ClaudeCode { + fn id(&self) -> &'static str { + Self::ID + } + async fn discover(&self) -> Discovery { Discovery { agents: discovery::discover(&self.settings_path) diff --git a/crates/agent/src/provider/claude_code/reconcile.rs b/crates/agent/src/provider/claude_code/reconcile.rs index 54727276..a981b7b9 100644 --- a/crates/agent/src/provider/claude_code/reconcile.rs +++ b/crates/agent/src/provider/claude_code/reconcile.rs @@ -17,6 +17,9 @@ use crate::provider::{ shared::{CommandSpec, deep_merge}, }; +/// Mode of the managed file; a looser mode is repaired on the next apply. +const MANAGED_FILE_MODE: u32 = 0o644; + const OWNER_MARKER: &[u8] = b"Agentdesktop\n"; pub(super) struct Hooks<'a> { @@ -91,7 +94,10 @@ pub(super) fn plan( } }; let action = match existing.as_deref() { - Some(existing) if existing == contents => { + Some(existing) + if existing == contents + && !crate::reconcile::grants_beyond(path, MANAGED_FILE_MODE) => + { if !owned { plan.write_file(&owner_path, OWNER_MARKER, 0o644)?; } @@ -104,7 +110,9 @@ pub(super) fn plan( plan.record(ClaudeCode::DISPLAY_NAME, "settings", "unchanged", path); return Ok(()); } - Some(_) if owned => "update", + // Identical bytes at a mode looser than planned are rewritten, like + // an owned file (the arm above claims identical bytes as ours). + Some(existing) if owned || existing == contents => "update", Some(existing) => { plan.record_diff( ClaudeCode::DISPLAY_NAME, @@ -119,7 +127,7 @@ pub(super) fn plan( None => "create", }; - plan.write_file(path, &contents, 0o644)?; + plan.write_file(path, &contents, MANAGED_FILE_MODE)?; plan.write_file(&owner_path, OWNER_MARKER, 0o644)?; debug!( program = ClaudeCode::ID, @@ -503,4 +511,64 @@ programs: json!(["~/.ssh"]) ); } + + #[cfg(unix)] + fn mode_repair_fixture() -> (tempfile::TempDir, agentdesktop_core::config::DaemonConfig) { + let config = parse_daemon( + r#" +programs: + claudeCode: {} +"#, + ) + .expect("valid daemon configuration"); + (tempfile::tempdir().unwrap(), config) + } + + #[test] + #[cfg(unix)] + fn identical_bytes_with_looser_mode_is_an_update_at_the_planned_mode() { + let (dir, config) = mode_repair_fixture(); + let path = dir.path().join("settings.json"); + let marker = super::owner_path(&path); + // System mode: `merge_existing = false` writes the file and its + // owner marker, the path that returned `unchanged` before writing. + let plan_fn = |plan: &crate::reconcile::ReconcilePlan| plan_with(&path, &config, plan); + crate::reconcile::plan::mode_repair::assert_identical_bytes_with_looser_mode_is_an_update( + &[&path, &marker], + 0o644, + &plan_fn, + ); + } + + #[test] + #[cfg(unix)] + fn identical_bytes_within_mode_is_unchanged() { + let (dir, config) = mode_repair_fixture(); + let path = dir.path().join("settings.json"); + let marker = super::owner_path(&path); + // System mode: `merge_existing = false` writes the file and its + // owner marker, the path that returned `unchanged` before writing. + let plan_fn = |plan: &crate::reconcile::ReconcilePlan| plan_with(&path, &config, plan); + crate::reconcile::plan::mode_repair::assert_identical_bytes_within_mode_is_unchanged( + &[&path, &marker], + &plan_fn, + ); + } + + #[cfg(unix)] + fn plan_with( + path: &Path, + config: &agentdesktop_core::config::DaemonConfig, + reconcile_plan: &crate::reconcile::ReconcilePlan, + ) -> anyhow::Result<()> { + plan( + path, + false, + "agentdesktop", + Hooks::new(None, None), + None, + Some((config.programs.claude_code.as_ref().unwrap(), None)), + reconcile_plan, + ) + } } diff --git a/crates/agent/src/provider/claude_desktop/mod.rs b/crates/agent/src/provider/claude_desktop/mod.rs index cbac676e..e6a09e58 100644 --- a/crates/agent/src/provider/claude_desktop/mod.rs +++ b/crates/agent/src/provider/claude_desktop/mod.rs @@ -31,6 +31,10 @@ impl ClaudeDesktop { #[async_trait::async_trait] impl Provider for ClaudeDesktop { + fn id(&self) -> &'static str { + Self::ID + } + async fn discover(&self) -> Discovery { Discovery { agents: discovery::discover(&self.managed_settings_path) diff --git a/crates/agent/src/provider/claude_desktop/reconcile.rs b/crates/agent/src/provider/claude_desktop/reconcile.rs index 29477c66..7b2982c2 100644 --- a/crates/agent/src/provider/claude_desktop/reconcile.rs +++ b/crates/agent/src/provider/claude_desktop/reconcile.rs @@ -194,13 +194,17 @@ fn write_owned( Err(error) => return Err(error).with_context(|| format!("read {}", path.display())), }; let action = match existing.as_deref() { - Some(existing) if existing == contents => { + Some(existing) + if existing == contents && !crate::reconcile::grants_beyond(path, permissions) => + { if !owned { plan.write_file(owner_path, OWNER_MARKER, 0o644)?; } "unchanged" } - Some(_) if owned => "update", + // Identical bytes at a mode looser than planned are rewritten, like + // an owned file (the arm above claims identical bytes as ours). + Some(existing) if owned || existing == contents => "update", Some(existing) => { plan.record_diff( ClaudeDesktop::DISPLAY_NAME, @@ -385,4 +389,67 @@ programs: Some("https://gateway.example.com/") ); } + + #[cfg(unix)] + fn mode_repair_fixture() -> (tempfile::TempDir, agentdesktop_core::config::DaemonConfig) { + let config = parse_daemon( + r#" +programs: + claudeDesktop: {} +"#, + ) + .expect("valid daemon configuration"); + (tempfile::tempdir().unwrap(), config) + } + + #[test] + #[cfg(unix)] + fn identical_bytes_with_looser_mode_is_an_update_at_the_planned_mode() { + let (dir, config) = mode_repair_fixture(); + let path = dir.path().join("managed-settings.json"); + let marker = super::owner_path(&path); + let helper = dir.path().join("helper.sh"); + // System mode: `merge_existing = false`. + let plan_fn = |plan: &crate::reconcile::ReconcilePlan| { + super::plan( + &path, + false, + &helper, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + Some((config.programs.claude_desktop.as_ref().unwrap(), None)), + plan, + ) + }; + crate::reconcile::plan::mode_repair::assert_identical_bytes_with_looser_mode_is_an_update( + &[&path, &marker], + 0o644, + &plan_fn, + ); + } + + #[test] + #[cfg(unix)] + fn identical_bytes_within_mode_is_unchanged() { + let (dir, config) = mode_repair_fixture(); + let path = dir.path().join("managed-settings.json"); + let marker = super::owner_path(&path); + let helper = dir.path().join("helper.sh"); + // System mode: `merge_existing = false`. + let plan_fn = |plan: &crate::reconcile::ReconcilePlan| { + super::plan( + &path, + false, + &helper, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + Some((config.programs.claude_desktop.as_ref().unwrap(), None)), + plan, + ) + }; + crate::reconcile::plan::mode_repair::assert_identical_bytes_within_mode_is_unchanged( + &[&path, &marker], + &plan_fn, + ); + } } diff --git a/crates/agent/src/provider/codex/mod.rs b/crates/agent/src/provider/codex/mod.rs index afbfe16b..0fceb0c6 100644 --- a/crates/agent/src/provider/codex/mod.rs +++ b/crates/agent/src/provider/codex/mod.rs @@ -29,6 +29,10 @@ impl Codex { #[async_trait::async_trait] impl Provider for Codex { + fn id(&self) -> &'static str { + Self::ID + } + async fn discover(&self) -> Discovery { Discovery { agents: discovery::discover().into_iter().collect(), diff --git a/crates/agent/src/provider/codex/reconcile.rs b/crates/agent/src/provider/codex/reconcile.rs index ea67ce06..5f2c6956 100644 --- a/crates/agent/src/provider/codex/reconcile.rs +++ b/crates/agent/src/provider/codex/reconcile.rs @@ -13,6 +13,9 @@ use crate::reconcile::ReconcilePlan; use crate::provider::shared::{deep_merge, responses_base_url}; +/// Mode of the managed file; a looser mode is repaired on the next apply. +const MANAGED_FILE_MODE: u32 = 0o644; + const MANAGED_HEADER: &str = "# Managed by Agentdesktop. Manual changes will be replaced.\n"; pub(super) fn plan( @@ -48,7 +51,11 @@ pub(super) fn plan( } }; let action = match existing.as_deref() { - Some(existing) if existing == contents => { + // Identical bytes at a mode looser than planned: rewritten (update). + Some(existing) + if existing == contents + && !crate::reconcile::grants_beyond(path, MANAGED_FILE_MODE) => + { debug!( program = Codex::ID, action = "unchanged", @@ -73,7 +80,7 @@ pub(super) fn plan( None => "create", }; - plan.write_file(path, &contents, 0o644)?; + plan.write_file(path, &contents, MANAGED_FILE_MODE)?; debug!( program = Codex::ID, action, @@ -334,4 +341,59 @@ programs: "allow" ); } + + #[cfg(unix)] + fn mode_repair_fixture() -> (tempfile::TempDir, agentdesktop_core::config::DaemonConfig) { + let config = parse_daemon( + r#" +programs: + codex: {} +"#, + ) + .expect("valid daemon configuration"); + (tempfile::tempdir().unwrap(), config) + } + + #[test] + #[cfg(unix)] + fn identical_bytes_with_looser_mode_is_an_update_at_the_planned_mode() { + let (dir, config) = mode_repair_fixture(); + let path = dir.path().join("config.toml"); + let plan_fn = |plan: &crate::reconcile::ReconcilePlan| { + super::plan( + &path, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + None, + Some((config.programs.codex.as_ref().unwrap(), None)), + plan, + ) + }; + crate::reconcile::plan::mode_repair::assert_identical_bytes_with_looser_mode_is_an_update( + &[&path], + 0o644, + &plan_fn, + ); + } + + #[test] + #[cfg(unix)] + fn identical_bytes_within_mode_is_unchanged() { + let (dir, config) = mode_repair_fixture(); + let path = dir.path().join("config.toml"); + let plan_fn = |plan: &crate::reconcile::ReconcilePlan| { + super::plan( + &path, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + None, + Some((config.programs.codex.as_ref().unwrap(), None)), + plan, + ) + }; + crate::reconcile::plan::mode_repair::assert_identical_bytes_within_mode_is_unchanged( + &[&path], + &plan_fn, + ); + } } 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..c461b9a3 --- /dev/null +++ b/crates/agent/src/provider/copilot/mod.rs @@ -0,0 +1,85 @@ +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 { + fn id(&self) -> &'static str { + Self::ID + } + + 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..63a108b3 --- /dev/null +++ b/crates/agent/src/provider/copilot/reconcile.rs @@ -0,0 +1,337 @@ +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 { + if crate::reconcile::fail_closed_without_proxy(gateway, plan, || in_place(path, plan)) { + tracing::debug!( + path = %path.display(), + "programs.copilot is configured but the local LLM proxy is not available; whenProxyUnavailable: failClosed, so the Copilot CLI providers file is left as it is" + ); + return Ok(()); + } + tracing::debug!( + 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); whenProxyUnavailable: failOpen, removing the managed Copilot CLI providers" + ); + plan.inactive(crate::reconcile::FAIL_OPEN_REASON); + 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)? + { + plan_remove_orphaned(path, pairing, plan)?; + } + Ok(()) +} + +/// Whether a provider under a managed name carries a pairing header: with +/// `Some(value)` that value only; with `None` (the daemon runs without the +/// proxy and has no pairing of its own) any value, since only an +/// agentdesktop daemon writes that header. +fn paired(provider: &Value, pairing: Option<&str>) -> bool { + let header = &provider["headers"][crate::llm_proxy::PAIRING_HEADER]; + let name = provider["name"].as_str(); + name.is_some_and(|name| name == PROVIDER_OPENAI || name == PROVIDER_ANTHROPIC) + && match pairing { + Some(pairing) => header == pairing, + None => !header.is_null(), + } +} + +/// Whether the file holds a provider entry an agentdesktop daemon wrote (a +/// managed name with a pairing header). A missing file is not in place; a +/// file that cannot be read or parsed is logged and counts as not in place. +fn in_place(path: &Path, plan: &ReconcilePlan) -> bool { + match read_object(path, plan) { + Ok(Some(current)) => providers(¤t).any(|provider| paired(provider, None)), + Ok(None) => false, + Err(error) => { + warn!(error = %format!("{error:#}"), "reading the Copilot CLI providers file"); + false + } + } +} + +/// 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 pairing value (any pairing value while the daemon runs +/// without the proxy and has none of its own) were written by an agentdesktop +/// daemon, 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: Option<&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| paired(provider, pairing)) + .filter_map(|provider| provider["name"].as_str().map(str::to_owned)) + .collect(); + if orphaned.is_empty() { + return Ok(()); + } + let mut remaining = Value::Object(current); + // get_mut: indexing a mutable Value inserts a missing key as null. + if let Some(entries) = remaining.get_mut("providers").and_then(Value::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.get_mut("models").and_then(Value::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..4de39f1c --- /dev/null +++ b/crates/agent/src/provider/copilot/tests.rs @@ -0,0 +1,1271 @@ +//! 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, ProxyUnavailable, +}; +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, + when_proxy_unavailable: Default::default(), + } +} + +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 fail_open_removes_the_entries_when_the_loopback_proxy_is_unavailable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let mut gateway = sample_gateway(); + gateway.when_proxy_unavailable = ProxyUnavailable::FailOpen; + + 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" + ); +} + +#[test] +fn fail_closed_keeps_the_entries_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(); + assert_eq!( + gateway.when_proxy_unavailable, + ProxyUnavailable::FailClosed, + "the default" + ); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + let state = super::super::json_merge::state_path(&path); + let (before, state_before) = (fs::read(&path).unwrap(), fs::read(&state).unwrap()); + + let changes = ReconcilePlan::default(); + plan(&path, None, Some((&config, Some(&gateway))), &changes).unwrap(); + assert_eq!( + changes.inactive_reason().as_deref(), + Some(crate::reconcile::FAIL_CLOSED_REASON) + ); + changes.apply().unwrap(); + assert_eq!( + fs::read(&path).unwrap(), + before, + "the providers stay, pointed at the loopback port" + ); + assert_eq!(fs::read(&state).unwrap(), state_before); + + // Removing the program still removes, whatever the policy. + let changes = ReconcilePlan::default(); + plan(&path, None, None, &changes).unwrap(); + changes.apply().unwrap(); + assert!(!path.exists()); +} + +// --- 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 orphan_removal_does_not_add_a_missing_models_key() { + // No sidecar and no `models` key: removing our providers by their pairing + // header must not invent `"models": null` in the user's file. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let document = json!({ + "providers": [ + {"name": super::PROVIDER_OPENAI, "headers": {"x-agentdesktop-pairing": PAIRING}}, + {"name": "mine", "baseUrl": "https://example.com"} + ] + }); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + apply_removal(&path).apply().unwrap(); + assert_eq!( + read(&path), + json!({"providers": [{"name": "mine", "baseUrl": "https://example.com"}]}) + ); +} + +#[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:#}" + ); +} + +// --- failClosed in place, failOpen reason, cleanup without a sidecar -------- + +fn fail_open_gateway() -> LlmGatewayConfig { + let mut gateway = sample_gateway(); + gateway.when_proxy_unavailable = ProxyUnavailable::FailOpen; + gateway +} + +#[test] +fn fail_closed_first_apply_reports_not_in_place() { + 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, None, Some((&config, Some(&gateway))), &changes).unwrap(); + assert_eq!( + changes.inactive_reason().as_deref(), + Some(crate::reconcile::FAIL_CLOSED_NOT_IN_PLACE_REASON) + ); + changes.apply().unwrap(); + assert!( + !path.exists(), + "nothing is written when nothing is in place" + ); + assert!(!super::super::json_merge::state_path(&path).exists()); +} + +#[test] +fn fail_open_reports_the_policy() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = fail_open_gateway(); + apply_managed_with(&path, &config, &gateway); + + let changes = ReconcilePlan::default(); + plan(&path, None, Some((&config, Some(&gateway))), &changes).unwrap(); + assert_eq!( + changes.inactive_reason().as_deref(), + Some(crate::reconcile::FAIL_OPEN_REASON) + ); +} + +fn apply_managed_with(path: &std::path::Path, config: &CopilotConfig, gateway: &LlmGatewayConfig) { + 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(); +} + +/// A user-owned file with our entries applied, the sidecar gone and the mode +/// loosened to 0640. +fn paired_entries_without_a_sidecar(path: &std::path::Path, gateway: &LlmGatewayConfig) { + write_user_owned_document(path); + apply_managed_with(path, &sample_config(), gateway); + fs::remove_file(super::super::json_merge::state_path(path)).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o640)).unwrap(); + } + assert_eq!(our_provider_count(&read(path)), 2); +} + +fn assert_paired_entries_gone_user_entries_kept(path: &std::path::Path) { + 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"), + "{after}" + ); + assert_user_entries_present(&after); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o640, + "the file keeps its mode" + ); + } +} + +#[test] +fn fail_open_without_sidecar_removes_paired_entries_and_keeps_user_entries() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = fail_open_gateway(); + paired_entries_without_a_sidecar(&path, &gateway); + + // No proxy, so no pairing is known. + let changes = ReconcilePlan::default(); + plan(&path, None, Some((&config, Some(&gateway))), &changes).unwrap(); + changes.apply().unwrap(); + assert_paired_entries_gone_user_entries_kept(&path); +} + +#[test] +fn removal_without_proxy_and_sidecar_removes_paired_entries() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + paired_entries_without_a_sidecar(&path, &sample_gateway()); + + let changes = ReconcilePlan::default(); + plan(&path, None, None, &changes).unwrap(); + changes.apply().unwrap(); + assert_paired_entries_gone_user_entries_kept(&path); +} + +#[test] +fn known_pairing_keeps_entries_with_another_pairing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + paired_entries_without_a_sidecar(&path, &sample_gateway()); + + 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); +} diff --git a/crates/agent/src/provider/cursor/mod.rs b/crates/agent/src/provider/cursor/mod.rs index d549ead7..0877f61f 100644 --- a/crates/agent/src/provider/cursor/mod.rs +++ b/crates/agent/src/provider/cursor/mod.rs @@ -13,6 +13,10 @@ impl Cursor { #[async_trait::async_trait] impl Provider for Cursor { + fn id(&self) -> &'static str { + Self::ID + } + async fn discover(&self) -> Discovery { Discovery { agents: discovery::discover().into_iter().collect(), diff --git a/crates/agent/src/provider/grok/mod.rs b/crates/agent/src/provider/grok/mod.rs index 80c463f8..b91e24e5 100644 --- a/crates/agent/src/provider/grok/mod.rs +++ b/crates/agent/src/provider/grok/mod.rs @@ -29,6 +29,10 @@ impl Grok { #[async_trait::async_trait] impl Provider for Grok { + fn id(&self) -> &'static str { + Self::ID + } + async fn discover(&self) -> Discovery { Discovery { agents: discovery::discover().into_iter().collect(), diff --git a/crates/agent/src/provider/grok/reconcile.rs b/crates/agent/src/provider/grok/reconcile.rs index abcc3241..eb25049e 100644 --- a/crates/agent/src/provider/grok/reconcile.rs +++ b/crates/agent/src/provider/grok/reconcile.rs @@ -10,6 +10,9 @@ use tracing::debug; use crate::provider::shared::{deep_merge, responses_base_url}; use crate::reconcile::ReconcilePlan; +/// Mode of the managed file; a looser mode is repaired on the next apply. +const MANAGED_FILE_MODE: u32 = 0o644; + const MANAGED_HEADER: &str = "# Managed by agentdesktop. Manual changes will be replaced.\n"; const PROVIDER_NAME: &str = "agentdesktop"; @@ -45,7 +48,11 @@ pub(super) fn plan( } }; let action = match existing.as_deref() { - Some(existing) if existing == contents => { + // Identical bytes at a mode looser than planned: rewritten (update). + Some(existing) + if existing == contents + && !crate::reconcile::grants_beyond(path, MANAGED_FILE_MODE) => + { debug!( program = Grok::ID, action = "unchanged", @@ -70,7 +77,7 @@ pub(super) fn plan( None => "create", }; - plan.write_file(path, &contents, 0o644)?; + plan.write_file(path, &contents, MANAGED_FILE_MODE)?; debug!( program = Grok::ID, action, @@ -470,4 +477,58 @@ programs: 600 ); } + + #[cfg(unix)] + fn mode_repair_fixture() -> (tempfile::TempDir, agentdesktop_core::config::DaemonConfig) { + let config = parse_daemon( + r#" +programs: + grok: + model: grok-4.6 +"#, + ) + .expect("valid daemon configuration"); + (tempfile::tempdir().unwrap(), config) + } + + #[test] + #[cfg(unix)] + fn identical_bytes_with_looser_mode_is_an_update_at_the_planned_mode() { + let (dir, config) = mode_repair_fixture(); + let path = dir.path().join("config.toml"); + let plan_fn = |plan: &crate::reconcile::ReconcilePlan| { + super::plan( + &path, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + Some((config.programs.grok.as_ref().unwrap(), None)), + plan, + ) + }; + crate::reconcile::plan::mode_repair::assert_identical_bytes_with_looser_mode_is_an_update( + &[&path], + 0o644, + &plan_fn, + ); + } + + #[test] + #[cfg(unix)] + fn identical_bytes_within_mode_is_unchanged() { + let (dir, config) = mode_repair_fixture(); + let path = dir.path().join("config.toml"); + let plan_fn = |plan: &crate::reconcile::ReconcilePlan| { + super::plan( + &path, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + Some((config.programs.grok.as_ref().unwrap(), None)), + plan, + ) + }; + crate::reconcile::plan::mode_repair::assert_identical_bytes_within_mode_is_unchanged( + &[&path], + &plan_fn, + ); + } } diff --git a/crates/agent/src/provider/json_merge.rs b/crates/agent/src/provider/json_merge.rs index d9e39c35..eecf7d27 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); @@ -71,22 +170,28 @@ pub(super) fn plan_merge( let mut contents = serde_json::to_vec_pretty(&combined) .with_context(|| format!("serialize merged {display_name}"))?; contents.push(b'\n'); + // An unchanged file that grants more than `options.mode` is rewritten + // with the same bytes at that mode (the plan skips identical writes + // otherwise). + let looser = crate::reconcile::grants_beyond(path, options.mode); let action = match existing.as_deref() { - Some(existing) if existing == contents => "unchanged", + Some(existing) if existing == contents && !looser => "unchanged", 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 +211,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 +247,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 +309,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 +325,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"); @@ -171,8 +340,18 @@ fn read_state( plan: &ReconcilePlan, ) -> anyhow::Result> { match plan.read(path) { + // The parse error names the position only: serde's message can quote + // a value from the file, and the sidecar holds managed content (a + // pairing value, a credential helper command). Ok(contents) => serde_json::from_slice(&contents) - .with_context(|| format!("parse {display_name} merge state from {}", path.display())) + .map_err(|error| { + anyhow::anyhow!( + "parse {display_name} merge state from {}: not a valid merge state (line {}, column {})", + path.display(), + error.line(), + error.column() + ) + }) .map(Some), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error) @@ -283,3 +462,483 @@ 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"); + } + + // --- No-op writes skipped -------------------------------------------------- + + #[test] + #[cfg(unix)] + fn a_looser_mode_with_identical_bytes_is_recorded_as_update_and_tightened() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let state = state_path(&path); + let options = root_array_options(); // mode 0600 + + let managed = json!([{"name": "agentdesktop"}]); + let plan = ReconcilePlan::default(); + plan_merge_with( + &path, + &state, + managed.clone(), + false, + "chat models", + "Test", + options, + &plan, + ) + .unwrap(); + plan.apply().unwrap(); + + // VS Code (or another program) saves the file at a looser mode. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o664)).unwrap(); + + let plan = ReconcilePlan::default(); + plan_merge_with( + &path, + &state, + managed, + false, + "chat models", + "Test", + options, + &plan, + ) + .unwrap(); + assert!( + plan.render().contains("UPDATE"), + "a file looser than the planned mode must be treated as an update, \ + not unchanged: {}", + plan.render() + ); + plan.apply().unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "the loosened file must be tightened"); + } +} diff --git a/crates/agent/src/provider/mod.rs b/crates/agent/src/provider/mod.rs index b7629da5..b22310d3 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; @@ -23,6 +24,10 @@ pub mod vscode; /// An integration with a developer tool or local model runtime. #[async_trait::async_trait] pub trait Provider: Send + Sync { + /// The provider's ID (the inventory's agent kind and the program key in + /// configuration status reports). + fn id(&self) -> &'static str; + async fn discover(&self) -> Discovery; /// Propose configuration changes, including cleanup when disabled. diff --git a/crates/agent/src/provider/ollama/mod.rs b/crates/agent/src/provider/ollama/mod.rs index f69a9579..e05a75b8 100644 --- a/crates/agent/src/provider/ollama/mod.rs +++ b/crates/agent/src/provider/ollama/mod.rs @@ -13,6 +13,10 @@ impl Ollama { #[async_trait::async_trait] impl Provider for Ollama { + fn id(&self) -> &'static str { + Self::ID + } + async fn discover(&self) -> Discovery { Discovery { agents: Vec::new(), diff --git a/crates/agent/src/provider/opencode/mod.rs b/crates/agent/src/provider/opencode/mod.rs index de739e3b..9c86ebe9 100644 --- a/crates/agent/src/provider/opencode/mod.rs +++ b/crates/agent/src/provider/opencode/mod.rs @@ -31,6 +31,10 @@ impl OpenCode { #[async_trait::async_trait] impl Provider for OpenCode { + fn id(&self) -> &'static str { + Self::ID + } + async fn discover(&self) -> Discovery { Discovery { agents: discovery::discover().into_iter().collect(), diff --git a/crates/agent/src/provider/opencode/reconcile.rs b/crates/agent/src/provider/opencode/reconcile.rs index 31793ee4..1b4f9219 100644 --- a/crates/agent/src/provider/opencode/reconcile.rs +++ b/crates/agent/src/provider/opencode/reconcile.rs @@ -12,6 +12,9 @@ use crate::reconcile::ReconcilePlan; use crate::provider::shared::{deep_merge, responses_base_url}; +/// Mode of the managed file; a looser mode is repaired on the next apply. +const MANAGED_FILE_MODE: u32 = 0o644; + const MANAGED_HEADER: &str = "// Managed by Agentdesktop. Manual changes will be replaced.\n"; const CONFIG_PROGRAM: &str = OpenCode::ID; @@ -190,7 +193,11 @@ fn reconcile_file( } }; let action = match existing.as_deref() { - Some(existing) if existing == contents => { + // Identical bytes at a mode looser than planned: rewritten (update). + Some(existing) + if existing == contents + && !crate::reconcile::grants_beyond(path, MANAGED_FILE_MODE) => + { debug!( program = CONFIG_PROGRAM, kind = description, @@ -215,7 +222,7 @@ fn reconcile_file( } None => "create", }; - plan.write_file(path, contents, 0o644)?; + plan.write_file(path, contents, MANAGED_FILE_MODE)?; debug!( program = CONFIG_PROGRAM, kind = description, @@ -352,4 +359,61 @@ programs: assert!(plugin.contains("input.model.providerID !== provider")); assert!(plugin.contains("output.headers.Authorization")); } + + #[cfg(unix)] + fn mode_repair_fixture() -> (tempfile::TempDir, agentdesktop_core::config::DaemonConfig) { + let config = parse_daemon( + r#" +programs: + openCode: {} +"#, + ) + .expect("valid daemon configuration"); + (tempfile::tempdir().unwrap(), config) + } + + #[test] + #[cfg(unix)] + fn identical_bytes_with_looser_mode_is_an_update_at_the_planned_mode() { + let (dir, config) = mode_repair_fixture(); + let path = dir.path().join("opencode.jsonc"); + let plugin = dir.path().join("plugin.js"); + let plan_fn = |plan: &crate::reconcile::ReconcilePlan| { + super::plan( + &path, + &plugin, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + Some((config.programs.open_code.as_ref().unwrap(), None)), + plan, + ) + }; + crate::reconcile::plan::mode_repair::assert_identical_bytes_with_looser_mode_is_an_update( + &[&path], + 0o644, + &plan_fn, + ); + } + + #[test] + #[cfg(unix)] + fn identical_bytes_within_mode_is_unchanged() { + let (dir, config) = mode_repair_fixture(); + let path = dir.path().join("opencode.jsonc"); + let plugin = dir.path().join("plugin.js"); + let plan_fn = |plan: &crate::reconcile::ReconcilePlan| { + super::plan( + &path, + &plugin, + Path::new("agentdesktop"), + Path::new("agentdesktop.sock"), + Some((config.programs.open_code.as_ref().unwrap(), None)), + plan, + ) + }; + crate::reconcile::plan::mode_repair::assert_identical_bytes_within_mode_is_unchanged( + &[&path], + &plan_fn, + ); + } } 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..86fad1e7 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,431 @@ 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") +} + +#[tokio::test] +async fn program_status_reports_inactive_without_the_proxy() -> anyhow::Result<()> { + Container::run( + "vscode", + "crates/agent/src/provider/vscode/testdata/Dockerfile", + async |container| { + let gateway = Gateway::start().await?; + // A gateway and the VS Code program, but no daemon.llmProxy: the + // program uses the gateway without a listener, so it is inactive. + let document = serde_json::json!({ + "daemon": { "user": true }, + "llmGateway": { "url": gateway.url }, + "programs": { "vscode": { "models": { "gpt-4.1-mini": {} } } }, + }); + container + .write(CONFIG, &serde_json::to_string_pretty(&document)?) + .await?; + container.exec(&["chown", "tester:tester", CONFIG]).await?; + container + .exec(&["chown", "-R", "tester:tester", "/home/tester"]) + .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 program outcome line in the daemon log"); + let log = container.exec(&["cat", "/tmp/daemon.log"]).await?; + let line = log + .lines() + .find(|line| { + line.contains("program configuration outcome") && line.contains("vscode") + }) + .with_context(|| format!("no outcome line for vscode in the daemon log:\n{log}"))?; + ensure!( + line.contains("inactive"), + "vscode must be inactive without the proxy: {line}" + ); + ensure!( + line.contains("local LLM proxy not available"), + "the reason is in the detail: {line}" + ); + Ok(()) + }, + ) + .await +} + +/// Its own port: the Docker tests share the host network and run in parallel. +const TICK_LISTEN: &str = "127.0.0.1:18098"; + +#[tokio::test] +async fn reconcile_tick_recreates_a_deleted_managed_file() -> anyhow::Result<()> { + Container::run("vscode", "crates/agent/src/provider/vscode/testdata/Dockerfile", async |container| { + let gateway = Gateway::start().await?; + let document = serde_json::json!({ + "daemon": { "user": true, "llmProxy": { "listen": TICK_LISTEN }, "reconcileInterval": "2s" }, + "llmGateway": { "url": gateway.url }, + "programs": { "vscode": { "models": { "gpt-4.1-mini": {} } } }, + }); + container.write(CONFIG, &serde_json::to_string_pretty(&document)?).await?; + container.exec(&["chown", "tester:tester", CONFIG]).await?; + container.exec(&["chown", "-R", "tester:tester", "/home/tester"]).await?; + let daemon = ["runuser", "-u", "tester", "--", "agentdesktop", "daemon", "--config", CONFIG]; + container.start_process("daemon", &daemon).await?; + container.wait_ready(&["agentdesktop", "--socket", SOCKET, "status"]).await?; + container.exec(&["test", "-e", CHAT_MODELS]).await?; + + info!("Deleting the managed file and waiting for the tick"); + container.exec(&["rm", CHAT_MODELS]).await?; + let mut back = false; + for _ in 0..40 { + if container.exec(&["test", "-e", CHAT_MODELS]).await.is_ok() { + back = true; + break; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + ensure!(back, "the reconcile tick did not recreate {CHAT_MODELS} within 10 s"); + let mode = container.exec(&["stat", "-c", "%a", CHAT_MODELS]).await?; + ensure!(mode.trim() == "600", "recreated file must be owner-only, got {mode}"); + + info!("Checking that later ticks write nothing"); + let before = container.exec(&["stat", "-c", "%i %Y", CHAT_MODELS, CHAT_SIDECAR]).await?; + tokio::time::sleep(Duration::from_secs(5)).await; + let after = container.exec(&["stat", "-c", "%i %Y", CHAT_MODELS, CHAT_SIDECAR]).await?; + ensure!(before == after, "an unchanged tick rewrote a file:\n{before}\n{after}"); + Ok(()) + }) + .await +} diff --git a/crates/agent/src/provider/vscode/mod.rs b/crates/agent/src/provider/vscode/mod.rs index 6ed0d5d9..8e5c7d97 100644 --- a/crates/agent/src/provider/vscode/mod.rs +++ b/crates/agent/src/provider/vscode/mod.rs @@ -1,21 +1,96 @@ +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 { + fn id(&self) -> &'static str { + Self::ID + } + 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.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..acd9e16a --- /dev/null +++ b/crates/agent/src/provider/vscode/reconcile.rs @@ -0,0 +1,296 @@ +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 { + if crate::reconcile::fail_closed_without_proxy(gateway, plan, || in_place(path, plan)) { + tracing::debug!( + path = %path.display(), + "programs.vscode is configured but the local LLM proxy is not available; whenProxyUnavailable: failClosed, so the chat language models file is left as it is" + ); + return Ok(()); + } + tracing::debug!( + 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); whenProxyUnavailable: failOpen, removing the managed chat language models" + ); + plan.inactive(crate::reconcile::FAIL_OPEN_REASON); + 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 +/// pairing value when there is none (any value when `pairing` 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, + )? { + plan_remove_orphaned(path, pairing, plan)?; + } + Ok(()) +} + +/// Whether the file holds the `agentdesktop` vendor entry an agentdesktop +/// daemon wrote (a model with a pairing header). A missing file is not in +/// place; a file that cannot be read or parsed is logged and counts as not +/// in place. +fn in_place(path: &Path, plan: &ReconcilePlan) -> bool { + match read_array(path, plan) { + Ok(Some(current)) => current.iter().any(|vendor| { + vendor["name"] == VsCodeConfig::VENDOR_NAME && carries_pairing(vendor, None) + }), + Ok(None) => false, + Err(error) => { + warn!(error = %format!("{error:#}"), "reading the VS Code chat language models file"); + false + } + } +} + +/// 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 pairing value (any pairing value while the +/// daemon runs without the proxy and has none of its own) was written by an +/// agentdesktop 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: Option<&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, 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..3c1cb9d5 --- /dev/null +++ b/crates/agent/src/provider/vscode/settings.rs @@ -0,0 +1,725 @@ +//! 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 there is none); 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 fails the plan). +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, the same decision shape as +/// `reconcile::plan`. The one exception is the proxy being the missing part: +/// then `llmGateway.whenProxyUnavailable` decides, and `failClosed` (the default) leaves +/// the file as it is, so VS Code stays pointed at the loopback port +/// instead of reaching GitHub past the gateway. +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 { + if let Some((_, Some(gateway))) = configured + && crate::reconcile::fail_closed_without_proxy(gateway, plan, || { + override_in_place(path, plan) + }) + { + debug!( + path = %path.display(), + "programs.vscode uses copilotChat: githubModels but the local LLM proxy is not available; whenProxyUnavailable: failClosed, so settings.json is left as it is" + ); + return Ok(()); + } + tracing::debug!( + 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); whenProxyUnavailable: failOpen, removing the managed settings" + ); + plan.inactive(crate::reconcile::FAIL_OPEN_REASON); + 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 = crate::reconcile::grants_beyond(path, FILE_MODE); + 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'); + // An identical sidecar is rewritten only when its mode lets others read it. + let looser_sidecar = crate::reconcile::grants_beyond(&state_path, FILE_MODE); + if sidecar.as_deref() != Some(state_bytes.as_slice()) || looser_sidecar { + 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(()) +} + +/// Whether the override points at an agentdesktop loopback CAPI route, so +/// VS Code goes through the daemon (and fails while the proxy is down). A +/// missing file is not in place; one that cannot be read or parsed is logged +/// and counts as not in place. +fn override_in_place(path: &Path, plan: &ReconcilePlan) -> bool { + let bytes = match read_optional(path, plan) { + Ok(Some(bytes)) => bytes, + Ok(None) => return false, + Err(error) => { + warn!(error = %format!("{error:#}"), "reading the VS Code settings file"); + return false; + } + }; + let Ok(text) = std::str::from_utf8(&bytes) else { + return false; + }; + let Ok((_root, object)) = parse_object(split_bom(text).1) else { + return false; + }; + object + .get(OVERRIDE_KEY) + .and_then(|property| property.value()) + .and_then(|value| value.to_serde_value()) + .as_ref() + .and_then(Value::as_str) + .is_some_and(is_daemon_capi_url) +} + +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 is present but +/// not a settings state (after a leading byte-order mark, which a hand edit +/// on Windows can add) is an error, as is one that cannot be read at all +/// (permissions), 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 = match bytes.as_deref() { + Some(bytes) => { + let bytes = bytes.strip_prefix(b"\xef\xbb\xbf").unwrap_or(bytes); + Some(read_state(bytes).ok_or_else(|| unreadable_sidecar(state_path, bytes))?) + } + None => None, + }; + Ok((bytes, state)) +} + +/// A sidecar that is present but not a settings state fails the plan, as the +/// json_merge sidecars do: without it the user's own override cannot be +/// restored. The message names the position of a syntax error only, never a +/// value: the sidecar can hold the user's own override URL. +fn unreadable_sidecar(state_path: &Path, bytes: &[u8]) -> anyhow::Error { + let position = match serde_json::from_slice::(bytes) { + Err(error) => format!(" (line {}, column {})", error.line(), error.column()), + Ok(_) => String::new(), + }; + anyhow::anyhow!( + "parse {} {DESCRIPTION} state from {}: not a valid settings state{position}; fix it, or delete it (the override's earlier value is then not restored)", + VsCode::DISPLAY_NAME, + state_path.display() + ) +} 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..a5df6b48 --- /dev/null +++ b/crates/agent/src/provider/vscode/settings_tests.rs @@ -0,0 +1,1776 @@ +//! 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, ProxyUnavailable, 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::provider::json_merge; +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, + when_proxy_unavailable: Default::default(), + } +} + +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 fail_open_removal_no_proxy_available_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 mut gateway = gateway_with_proxy(); + gateway.when_proxy_unavailable = ProxyUnavailable::FailOpen; + 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 fail_closed_leaves_the_file_and_sidecar_until_the_proxy_is_back() { + 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 = json_merge::state_path(&path); + let (before, state_before) = (fs::read(&path).unwrap(), fs::read(&state).unwrap()); + + let changes = apply_removal(&path, None, Some((&config, Some(&gateway)))); + assert_eq!( + changes.inactive_reason().as_deref(), + Some(crate::reconcile::FAIL_CLOSED_REASON) + ); + changes.apply().unwrap(); + assert_eq!( + fs::read(&path).unwrap(), + before, + "the override stays, pointed at the loopback port" + ); + assert_eq!(fs::read(&state).unwrap(), state_before); + + // The proxy back, on another port: the current URL is written. + let other: SocketAddr = "127.0.0.1:18199".parse().unwrap(); + apply_managed(&path, other, PAIRING, &config, &gateway); + assert_eq!(read(&path)[OVERRIDE_KEY], override_url(other, PAIRING)); + + // Removing the program still removes, whatever the policy. + apply_removal(&path, None, None).apply().unwrap(); + let after = read(&path); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); + assert_eq!(after["editor.fontSize"], 14); +} + +#[test] +fn fail_closed_does_not_create_a_file_without_the_proxy() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let changes = apply_removal( + &path, + None, + Some((&github_models_config(), Some(&gateway_with_proxy()))), + ); + changes.apply().unwrap(); + assert!(!path.exists()); +} + +#[test] +fn when_proxy_unavailable_defaults_to_fail_closed_and_parses_fail_open() { + let parse = |extra: Value| { + let mut document = json!({"url": "https://gateway.example.com"}); + document + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + serde_json::from_value::(document) + }; + let default = parse(json!({})).unwrap(); + assert_eq!(default.when_proxy_unavailable, ProxyUnavailable::FailClosed); + assert!( + serde_json::to_value(&default) + .unwrap() + .get("whenProxyUnavailable") + .is_none() + ); + assert_eq!( + parse(json!({"whenProxyUnavailable": "failOpen"})) + .unwrap() + .when_proxy_unavailable, + ProxyUnavailable::FailOpen + ); + assert!(parse(json!({"whenProxyUnavailable": "failSoft"})).is_err()); +} + +#[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); +} + +/// An overridden URL the user owns; must never appear in an error message. +const SECRET: &str = "https://secret-user-override.example.com/v1"; + +/// `settings.json` holding the user's override, applied once so a valid +/// sidecar exists. Returns the settings path and the sidecar path. +fn applied_with_user_override() -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings( + &path, + &json!({ OVERRIDE_KEY: SECRET, "editor.fontSize": 14 }), + ); + apply_managed( + &path, + listen_addr(), + PAIRING, + &github_models_config(), + &gateway_with_proxy(), + ); + let state_path = super::super::json_merge::state_path(&path); + (dir, path, state_path) +} + +/// Plans an apply and returns the plan error text, asserting nothing was +/// written (the plan failed before `apply`). +fn apply_plan_error(path: &std::path::Path) -> String { + let config = github_models_config(); + let gateway = gateway_with_proxy(); + let changes = ReconcilePlan::default(); + let error = plan( + path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .expect_err("an unreadable sidecar must fail the plan"); + format!("{error:#}") +} + +fn removal_plan_error(path: &std::path::Path) -> String { + let changes = ReconcilePlan::default(); + let error = plan(path, None, None, &changes) + .expect_err("an unreadable sidecar must fail the removal plan"); + format!("{error:#}") +} + +#[test] +fn corrupt_sidecar_fails_the_plan_on_apply() { + let (_dir, path, state_path) = applied_with_user_override(); + fs::write(&state_path, b"{ not json").unwrap(); + let settings_before = fs::read(&path).unwrap(); + + let message = apply_plan_error(&path); + + assert!( + message.contains(&state_path.display().to_string()), + "{message}" + ); + assert_eq!(fs::read(&path).unwrap(), settings_before); + assert_eq!(fs::read(&state_path).unwrap(), b"{ not json"); +} + +#[test] +fn corrupt_sidecar_fails_the_plan_on_removal() { + let (_dir, path, state_path) = applied_with_user_override(); + fs::write(&state_path, b"{ not json").unwrap(); + let settings_before = fs::read(&path).unwrap(); + + let message = removal_plan_error(&path); + + assert!( + message.contains(&state_path.display().to_string()), + "{message}" + ); + assert_eq!(fs::read(&path).unwrap(), settings_before); + assert_eq!(fs::read(&state_path).unwrap(), b"{ not json"); +} + +#[test] +fn sidecar_with_unknown_version_fails_the_plan() { + let (_dir, path, state_path) = applied_with_user_override(); + let bytes = serde_json::to_vec(&json!({ + "version": 99, + "created": false, + "overrideBefore": SECRET, + "addedIgnored": [], + "ignoredCreated": false, + })) + .unwrap(); + fs::write(&state_path, &bytes).unwrap(); + + let message = apply_plan_error(&path); + assert!( + message.contains(&state_path.display().to_string()), + "{message}" + ); + let message = removal_plan_error(&path); + assert!( + message.contains(&state_path.display().to_string()), + "{message}" + ); + assert_eq!(fs::read(&state_path).unwrap(), bytes); +} + +#[test] +fn sidecar_with_wrong_shape_fails_the_plan() { + for shape in [ + // Current version, a field missing and an unknown one. + json!({ "version": SETTINGS_STATE_VERSION, "bogus": SECRET }), + // No version, not the whole-document legacy form either. + json!({ "unrelated": SECRET }), + // Valid JSON, not an object. + json!([SECRET]), + ] { + let (_dir, path, state_path) = applied_with_user_override(); + let bytes = serde_json::to_vec(&shape).unwrap(); + fs::write(&state_path, &bytes).unwrap(); + + let message = apply_plan_error(&path); + assert!( + message.contains(&state_path.display().to_string()), + "{shape}: {message}" + ); + assert_eq!(fs::read(&state_path).unwrap(), bytes, "{shape}"); + } +} + +#[test] +fn the_unreadable_sidecar_error_quotes_nothing_from_the_file() { + let (_dir, path, state_path) = applied_with_user_override(); + // Invalid JSON and valid-but-wrong-shape JSON, both carrying the secret. + for bytes in [ + format!("{{\n \"overrideBefore\": \"{SECRET}\" oops\n}}").into_bytes(), + format!("{{\"version\": 2, \"overrideBefore\": \"{SECRET}\"}}").into_bytes(), + ] { + fs::write(&state_path, &bytes).unwrap(); + for message in [apply_plan_error(&path), removal_plan_error(&path)] { + assert!( + message.contains(&state_path.display().to_string()), + "{message}" + ); + assert!(!message.contains("secret-user-override"), "{message}"); + assert!(!message.contains("oops"), "{message}"); + } + } + // A parse error names the position only: line and column. + fs::write(&state_path, b"{\n \"version\": }\n").unwrap(); + let message = apply_plan_error(&path); + assert!( + message.contains("line 2") && message.contains("column"), + "{message}" + ); +} + +#[test] +fn corrupt_sidecar_does_not_lose_the_users_override() { + let (_dir, path, state_path) = applied_with_user_override(); + let valid_sidecar = fs::read(&state_path).unwrap(); + let settings_before = fs::read(&path).unwrap(); + + fs::write(&state_path, b"{ not json").unwrap(); + apply_plan_error(&path); + assert_eq!( + fs::read(&path).unwrap(), + settings_before, + "settings.json unchanged" + ); + assert_eq!(fs::read(&state_path).unwrap(), b"{ not json"); + + // A valid sidecar restored, the removal brings the user's value back. + fs::write(&state_path, &valid_sidecar).unwrap(); + let changes = apply_removal(&path, None, None); + changes.apply().unwrap(); + assert_eq!(read(&path)[OVERRIDE_KEY], SECRET); +} + +#[cfg(unix)] +#[test] +fn identical_sidecar_with_looser_mode_is_rewritten_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 state_path = super::super::json_merge::state_path(&path); + let sidecar_before = fs::read(&state_path).unwrap(); + fs::set_permissions(&state_path, fs::Permissions::from_mode(0o664)).unwrap(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + assert_eq!(fs::read(&state_path).unwrap(), sidecar_before); + assert_eq!( + fs::metadata(&state_path).unwrap().permissions().mode() & 0o777, + 0o600, + "an identical sidecar looser than 0600 must be rewritten at 0600" + ); +} + +#[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()); +} + +#[cfg(unix)] +#[test] +fn identical_sidecar_within_0600_is_not_rewritten() { + use std::os::unix::fs::MetadataExt; + 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 inode = fs::metadata(&state_path).unwrap().ino(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + assert_eq!(fs::metadata(&state_path).unwrap().ino(), inode); +} + +#[test] +fn a_sidecar_with_a_byte_order_mark_is_read() { + let (_dir, path, state_path) = applied_with_user_override(); + let mut bytes = b"\xef\xbb\xbf".to_vec(); + bytes.extend(fs::read(&state_path).unwrap()); + fs::write(&state_path, &bytes).unwrap(); + + apply_removal(&path, None, None).apply().unwrap(); + + assert_eq!(read(&path)[OVERRIDE_KEY], SECRET); +} + +// --- failClosed in place, failOpen reason ----------------------------------- + +#[test] +fn fail_closed_first_apply_reports_not_in_place() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + + let changes = apply_removal(&path, None, Some((&config, Some(&gateway)))); + assert_eq!( + changes.inactive_reason().as_deref(), + Some(crate::reconcile::FAIL_CLOSED_NOT_IN_PLACE_REASON) + ); + changes.apply().unwrap(); + assert!( + !path.exists(), + "nothing is written when nothing is in place" + ); + assert!(!json_merge::state_path(&path).exists()); +} + +#[test] +fn fail_open_reports_the_policy() { + 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 mut gateway = gateway_with_proxy(); + gateway.when_proxy_unavailable = ProxyUnavailable::FailOpen; + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let changes = apply_removal(&path, None, Some((&config, Some(&gateway)))); + assert_eq!( + changes.inactive_reason().as_deref(), + Some(crate::reconcile::FAIL_OPEN_REASON) + ); +} + +#[test] +fn fail_closed_after_the_user_removed_the_override_reports_not_in_place() { + 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); + assert!(read(&path).get(OVERRIDE_KEY).is_some()); + + // The user deletes the override, keeping their own keys. + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let before = fs::read(&path).unwrap(); + + let changes = apply_removal(&path, None, Some((&config, Some(&gateway)))); + assert_eq!( + changes.inactive_reason().as_deref(), + Some(crate::reconcile::FAIL_CLOSED_NOT_IN_PLACE_REASON) + ); + changes.apply().unwrap(); + assert_eq!(fs::read(&path).unwrap(), before, "nothing is written"); +} 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..91d37bbe --- /dev/null +++ b/crates/agent/src/provider/vscode/tests.rs @@ -0,0 +1,881 @@ +//! 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, ProxyUnavailable, 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, + when_proxy_unavailable: Default::default(), + } +} + +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 fail_open_removes_the_entry_when_the_loopback_proxy_is_unavailable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let mut gateway = sample_gateway(); + gateway.when_proxy_unavailable = ProxyUnavailable::FailOpen; + 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 fail_closed_keeps_the_entry_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); + let state = super::super::json_merge::state_path(&path); + let (before, state_before) = (fs::read(&path).unwrap(), fs::read(&state).unwrap()); + + let changes = ReconcilePlan::default(); + plan(&path, None, Some((&config, Some(&gateway))), &changes).unwrap(); + assert_eq!( + changes.inactive_reason().as_deref(), + Some(crate::reconcile::FAIL_CLOSED_REASON) + ); + changes.apply().unwrap(); + assert_eq!(fs::read(&path).unwrap(), before); + assert_eq!(fs::read(&state).unwrap(), state_before); +} + +#[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" + ); +} + +// --- failClosed in place, failOpen reason, cleanup without a sidecar -------- + +fn fail_open_gateway() -> LlmGatewayConfig { + let mut gateway = sample_gateway(); + gateway.when_proxy_unavailable = ProxyUnavailable::FailOpen; + gateway +} + +#[test] +fn fail_closed_first_apply_reports_not_in_place() { + 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, None, Some((&config, Some(&gateway))), &changes).unwrap(); + assert_eq!( + changes.inactive_reason().as_deref(), + Some(crate::reconcile::FAIL_CLOSED_NOT_IN_PLACE_REASON) + ); + changes.apply().unwrap(); + assert!( + !path.exists(), + "nothing is written when nothing is in place" + ); + assert!(!super::super::json_merge::state_path(&path).exists()); +} + +#[test] +fn fail_open_reports_the_policy() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = fail_open_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let changes = ReconcilePlan::default(); + plan(&path, None, Some((&config, Some(&gateway))), &changes).unwrap(); + assert_eq!( + changes.inactive_reason().as_deref(), + Some(crate::reconcile::FAIL_OPEN_REASON) + ); +} + +/// A user-owned file with our entry applied, the sidecar gone and the mode +/// loosened to 0640. +fn paired_entry_without_a_sidecar(path: &std::path::Path, gateway: &LlmGatewayConfig) { + write_user_owned_document(path); + apply_managed(path, listen_addr(), PAIRING, &sample_config(), gateway); + fs::remove_file(super::super::json_merge::state_path(path)).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o640)).unwrap(); + } + assert_eq!(our_vendor_count(&read(path)), 1); +} + +fn assert_paired_entry_gone_user_entries_kept(path: &std::path::Path) { + let after = read(path); + assert_eq!(our_vendor_count(&after), 0, "{after}"); + assert!( + after + .as_array() + .unwrap() + .iter() + .any(|vendor| vendor["name"] == "my-own-vendor"), + "{after}" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o640, + "the file keeps its mode" + ); + } +} + +#[test] +fn fail_open_without_sidecar_removes_paired_entries_and_keeps_user_entries() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = fail_open_gateway(); + paired_entry_without_a_sidecar(&path, &gateway); + + // No proxy, so no pairing is known. + let changes = ReconcilePlan::default(); + plan(&path, None, Some((&config, Some(&gateway))), &changes).unwrap(); + changes.apply().unwrap(); + assert_paired_entry_gone_user_entries_kept(&path); +} + +#[test] +fn removal_without_proxy_and_sidecar_removes_paired_entries() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + paired_entry_without_a_sidecar(&path, &sample_gateway()); + + let changes = ReconcilePlan::default(); + plan(&path, None, None, &changes).unwrap(); + changes.apply().unwrap(); + assert_paired_entry_gone_user_entries_kept(&path); +} + +#[test] +fn known_pairing_keeps_entries_with_another_pairing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + paired_entry_without_a_sidecar(&path, &sample_gateway()); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), "PAIRING-OTHER")), + None, + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + assert_eq!(our_vendor_count(&read(&path)), 1); +} diff --git a/crates/agent/src/reconcile/mod.rs b/crates/agent/src/reconcile/mod.rs index d546bdd1..dee29d4d 100644 --- a/crates/agent/src/reconcile/mod.rs +++ b/crates/agent/src/reconcile/mod.rs @@ -1,12 +1,17 @@ //! Shared reconciliation orchestration, plan application, and dry-run reporting. -mod plan; +pub(crate) mod plan; pub use plan::ReconcilePlan; +pub(crate) use plan::grants_beyond; 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, ProgramsConfig}, + model::Discovery, }; -use agentdesktop_core::{config::DaemonConfig, model::Discovery}; use serde_json::Value; use similar::TextDiff; use std::{ @@ -23,14 +28,22 @@ 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)] pub struct Reconciler { context: ReconcileContext, providers: Arc>>, + /// The apply lock and the report of the last apply. Held for planning + /// and applying, so a push, the startup apply and a reconcile tick never + /// overlap across clones; never held across an `.await`. `plan`, + /// `plan_with_report` and `dry_run` take no lock. The report feeds the + /// tick's log rule. + last_apply: Arc>>, } impl Reconciler { @@ -44,6 +57,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,13 +85,20 @@ 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), ]), + last_apply: Arc::new(std::sync::Mutex::new(None)), } } @@ -109,13 +132,482 @@ impl Reconciler { } pub fn apply(&self, config: &DaemonConfig) -> anyhow::Result<()> { - self.plan(config)?.apply() + self.apply_with_report(config).1 } pub fn dry_run(&self, config: &DaemonConfig) -> anyhow::Result<()> { print!("{}", self.plan(config)?.render()); Ok(()) } + + /// Plans every provider like [`Reconciler::plan`], but a provider whose + /// `plan` fails is recorded as failed and the others are still planned, + /// so each program's outcome is known. Nothing is written here. + pub fn plan_with_report(&self, config: &DaemonConfig) -> AttributedPlan { + let configured = configured_programs(&config.programs); + let mut plan = ReconcilePlan::default(); + let mut entries: Vec = Vec::new(); + let mut first_error = None; + for provider in self.providers.iter() { + let program = provider.id(); + let mut entry = ProgramEntry { + program, + configured: configured.contains(&program), + failed: None, + changes: false, + conflicts: Vec::new(), + inactive: None, + paths: Vec::new(), + operations: 0..0, + }; + match provider.plan(&self.context, config) { + Err(error) => { + entry.failed = Some(format!("{error:#}")); + first_error.get_or_insert(error); + } + Ok(own) => { + let summary = own.summary(); + entry.changes = summary.changes; + entry.conflicts = summary.conflicts; + entry.inactive = summary.inactive; + entry.paths = summary.paths; + let start = plan.operation_count(); + if let Err((error, path)) = plan.append_attributed(own) { + // Both programs of the disagreement fail. + for other in entries + .iter_mut() + .filter(|other| other.paths.contains(&path)) + { + other.failed.get_or_insert_with(|| format!("{error:#}")); + } + entry.failed = Some(format!("{error:#}")); + first_error.get_or_insert(error); + } + entry.operations = start..plan.operation_count(); + } + } + entries.push(entry); + } + AttributedPlan { + plan, + entries, + first_error, + } + } + + /// [`Reconciler::plan_with_report`] followed by [`AttributedPlan::apply`], + /// under the apply lock. + pub fn apply_with_report(&self, config: &DaemonConfig) -> (ApplyReport, anyhow::Result<()>) { + let (_, report, result) = self.apply_after_previous(config); + (report, result) + } + + /// Like [`Reconciler::apply_with_report`], also returning the report of + /// the apply before this one (from any source), read under the same lock. + pub(crate) fn apply_after_previous( + &self, + config: &DaemonConfig, + ) -> (Option, ApplyReport, anyhow::Result<()>) { + let ((), previous, report, result) = self + .apply_read_under_lock(|| Some(((), Arc::new(config.clone())))) + .expect("a configuration was given"); + (previous, report, result) + } + + /// Takes the apply lock, then reads the configuration to apply (with + /// whatever `read` returns alongside it, such as its revision), so a + /// configuration pushed while this call waited for the lock is the one + /// applied (a tick never re-applies an older revision after a push). + /// `None` from `read` applies nothing. + #[allow(clippy::type_complexity)] + pub(crate) fn apply_read_under_lock( + &self, + read: impl FnOnce() -> Option<(T, Arc)>, + ) -> Option<(T, Option, ApplyReport, anyhow::Result<()>)> { + let mut last = self + .last_apply + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (value, config) = read()?; + let (report, result) = self.plan_with_report(&config).apply(); + let previous = last.replace(report.clone()); + Some((value, previous, report, result)) + } +} + +/// The `inactive` detail under `llmGateway.whenProxyUnavailable: failClosed`, +/// when a program behind the local LLM proxy keeps its managed entries. +pub(crate) const FAIL_CLOSED_REASON: &str = concat!( + "local LLM proxy not available; see llmProxy.error in daemon-info, or set daemon.llmProxy.listen", + "; the managed entries stay and the tool fails until the proxy is back (whenProxyUnavailable: failClosed)" +); + +/// The `inactive` detail under `failClosed` when nothing of the daemon's is +/// in the file, so the tool keeps its own settings. +pub(crate) const FAIL_CLOSED_NOT_IN_PLACE_REASON: &str = concat!( + "local LLM proxy not available; see llmProxy.error in daemon-info, or set daemon.llmProxy.listen", + "; no managed entries are in place, so the tool keeps its own settings until the proxy is back (whenProxyUnavailable: failClosed)" +); + +/// The `inactive` detail under `failOpen`: the managed entries are removed. +pub(crate) const FAIL_OPEN_REASON: &str = concat!( + "local LLM proxy not available; see llmProxy.error in daemon-info, or set daemon.llmProxy.listen", + "; the managed entries are removed until the proxy is back (whenProxyUnavailable: failOpen)" +); + +/// Under `failClosed`, marks the program `inactive` (with the reason that +/// matches `in_place`, which reads the file and is called only under +/// `failClosed`) and returns `true`: the caller leaves its managed files as +/// they are. +pub(crate) fn fail_closed_without_proxy( + gateway: &agentdesktop_core::config::LlmGatewayConfig, + plan: &ReconcilePlan, + in_place: impl FnOnce() -> bool, +) -> bool { + if gateway.when_proxy_unavailable != agentdesktop_core::config::ProxyUnavailable::FailClosed { + return false; + } + plan.inactive(if in_place() { + FAIL_CLOSED_REASON + } else { + FAIL_CLOSED_NOT_IN_PLACE_REASON + }); + true +} + +/// A program's outcome, by precedence (highest first): Failed > Conflict > +/// Blocked > Inactive > Applied/Removed > Unchanged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProgramState { + /// The program is configured and its files were changed by this apply. + Applied, + /// The program is configured; nothing needed to change. + Unchanged, + /// The program is not configured; its managed content was removed by + /// this apply. + Removed, + /// A managed file held conflicting or invalid existing configuration. + Conflict, + /// The program uses the gateway, a gateway is configured, and the + /// loopback proxy is absent. + Inactive, + /// The program had changes, but none of them was written because the + /// apply was refused or stopped. + Blocked, + /// The program's plan failed, it disagreed with another program's plan, + /// one of its writes failed, or one of its files changed before the + /// apply. + Failed, +} + +impl ProgramState { + /// Lowercase name, as logged and stored. + pub fn as_str(self) -> &'static str { + match self { + Self::Applied => "applied", + Self::Unchanged => "unchanged", + Self::Removed => "removed", + Self::Conflict => "conflict", + Self::Inactive => "inactive", + Self::Blocked => "blocked", + Self::Failed => "failed", + } + } +} + +/// One program's outcome from an apply. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProgramOutcome { + /// The provider ID, for example `"claude-code"` or `"vscode"`. + pub program: &'static str, + pub state: ProgramState, + pub detail: String, +} + +/// Every reported program's outcome from one apply, in provider +/// registration order. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ApplyReport { + pub programs: Vec, +} + +impl ApplyReport { + /// One log line per program whose state is not applied or unchanged; a + /// program that failed, conflicted, was blocked or is inactive is a + /// warning. + pub fn log(&self) { + for outcome in &self.programs { + // Inactive is a warning too: the program is configured for the + // gateway but its files are removed. The providers log the cause + // at debug level, since they plan on every apply. + let warning = matches!( + outcome.state, + ProgramState::Failed + | ProgramState::Conflict + | ProgramState::Blocked + | ProgramState::Inactive + ); + if matches!( + outcome.state, + ProgramState::Applied | ProgramState::Unchanged + ) { + continue; + } + if warning { + tracing::warn!( + program = outcome.program, + state = outcome.state.as_str(), + detail = %outcome.detail, + "program configuration outcome" + ); + } else { + tracing::info!( + program = outcome.program, + state = outcome.state.as_str(), + detail = %outcome.detail, + "program configuration outcome" + ); + } + } + } +} + +/// Whether `current` (a tick's apply outcome) differs enough from +/// `previous` (the report of the last apply from any source: startup, a +/// push, or an earlier tick) to log, using the same same-kind-of-difference +/// rule as `remote::tick_config_status`: a program moving from `Applied` or +/// `Removed` to `Unchanged` is not itself a difference. `previous: None` +/// (nothing has been applied yet) always logs. +pub(crate) fn should_log(previous: Option<&ApplyReport>, current: &ApplyReport) -> bool { + let Some(previous) = previous else { + return true; + }; + outcomes_differ( + &outcome_keys(&previous.programs), + &outcome_keys(¤t.programs), + ) +} + +/// `(program, state, detail)` of each outcome, for [`outcomes_differ`]. +pub(crate) fn outcome_keys(programs: &[ProgramOutcome]) -> Vec<(&str, ProgramState, &str)> { + programs + .iter() + .map(|outcome| (outcome.program, outcome.state, outcome.detail.as_str())) + .collect() +} + +/// Whether two outcome lists differ by program, state or detail. A program +/// that settles from applied or removed to unchanged, or a removed program +/// that is no longer listed, is not a difference: the files are as reported. +pub(crate) fn outcomes_differ( + previous: &[(&str, ProgramState, &str)], + current: &[(&str, ProgramState, &str)], +) -> bool { + let changed = current.iter().any(|&(program, state, detail)| { + match previous.iter().find(|(before, _, _)| *before == program) { + None => true, + Some(&(_, before_state, before_detail)) => { + let settled = state == ProgramState::Unchanged + && matches!(before_state, ProgramState::Applied | ProgramState::Removed); + !settled && (state != before_state || detail != before_detail) + } + } + }); + let gone = previous.iter().any(|&(program, state, _)| { + state != ProgramState::Removed && !current.iter().any(|(now, _, _)| *now == program) + }); + changed || gone +} + +/// The longest detail an outcome carries, in bytes. +const DETAIL_LIMIT: usize = 1024; + +fn truncate_detail(mut detail: String) -> String { + if detail.len() > DETAIL_LIMIT { + let mut end = DETAIL_LIMIT; + while !detail.is_char_boundary(end) { + end -= 1; + } + detail.truncate(end); + } + detail +} + +struct ProgramEntry { + program: &'static str, + configured: bool, + failed: Option, + changes: bool, + conflicts: Vec, + inactive: Option, + paths: Vec, + operations: std::ops::Range, +} + +impl ProgramEntry { + /// The outcome when this program's own part did not fail or conflict: + /// `written` says whether its changes reached the disk. + fn settled(&self, written: bool, blocked_by: &str) -> Option<(ProgramState, String)> { + if self.changes && !written { + return Some((ProgramState::Blocked, format!("not applied: {blocked_by}"))); + } + if let Some(reason) = &self.inactive { + return Some((ProgramState::Inactive, reason.clone())); + } + match (self.changes, self.configured) { + (true, true) => Some((ProgramState::Applied, String::new())), + (true, false) => Some((ProgramState::Removed, String::new())), + (false, true) => Some((ProgramState::Unchanged, String::new())), + (false, false) => None, + } + } +} + +/// A plan with each program's part identified, ready to apply with +/// per-program reporting. +pub struct AttributedPlan { + plan: ReconcilePlan, + entries: Vec, + first_error: Option, +} + +impl AttributedPlan { + /// Applies with the all-or-nothing rules of [`ReconcilePlan::apply`] (a + /// failed plan or any conflict means nothing is written) and returns the + /// same first error, alongside each program's outcome. + pub fn apply(self) -> (ApplyReport, anyhow::Result<()>) { + let Self { + plan, + entries, + first_error, + } = self; + let (result, stop) = if let Some(error) = first_error { + (Err(error), Stop::BeforeWrites) + } else if entries.iter().any(|entry| !entry.conflicts.is_empty()) { + // The plan refuses without writing and returns the conflict error. + (plan.apply(), Stop::BeforeWrites) + } else { + match plan.apply_tracked() { + Ok(()) => (Ok(()), Stop::Completed), + Err((error, at)) => { + let stop = Stop::Failed { + owners: owners_of(&entries, &at), + detail: format!("{error:#}"), + written: match at { + plan::ApplyStop::Operation(index) => index, + _ => 0, + }, + }; + (Err(error), stop) + } + } + }; + let failed_during = |program: &str| match &stop { + Stop::Failed { owners, .. } => owners.contains(&program), + _ => false, + }; + // The first program, in registration order, that stopped the apply. + let blocked_by = entries + .iter() + .find_map(|entry| { + if entry.failed.is_some() || failed_during(entry.program) { + Some(format!("{} failed", entry.program)) + } else if !entry.conflicts.is_empty() { + Some(format!("{} conflicted", entry.program)) + } else { + None + } + }) + .unwrap_or_default(); + let programs = entries + .iter() + .filter_map(|entry| { + let (state, detail) = if let Some(detail) = &entry.failed { + (ProgramState::Failed, detail.clone()) + } else if let Stop::Failed { detail, .. } = &stop + && failed_during(entry.program) + { + (ProgramState::Failed, detail.clone()) + } else if !entry.conflicts.is_empty() { + (ProgramState::Conflict, entry.conflicts.join("; ")) + } else { + let written = match &stop { + Stop::Completed => true, + Stop::BeforeWrites => false, + Stop::Failed { written, .. } => entry.operations.end <= *written, + }; + entry.settled(written, &blocked_by)? + }; + Some(outcome(entry.program, state, detail)) + }) + .collect(); + (ApplyReport { programs }, result) + } +} + +/// How an attributed apply ended. +enum Stop { + /// Every operation was written. + Completed, + /// Refused before any write (a failed plan or a conflict). + BeforeWrites, + /// Stopped while applying: `owners` failed, the first `written` + /// operations reached the disk. + Failed { + owners: Vec<&'static str>, + detail: String, + written: usize, + }, +} + +/// The programs responsible for where an apply stopped. +fn owners_of(entries: &[ProgramEntry], at: &plan::ApplyStop) -> Vec<&'static str> { + entries + .iter() + .filter(|entry| match at { + plan::ApplyStop::Conflict => false, + plan::ApplyStop::Observed(path) => entry.paths.contains(path), + plan::ApplyStop::PrivateDir(dir) => { + entry.paths.iter().any(|path| path.starts_with(dir)) + } + plan::ApplyStop::Operation(index) => entry.operations.contains(index), + }) + .map(|entry| entry.program) + .collect() +} + +fn outcome(program: &'static str, state: ProgramState, detail: String) -> ProgramOutcome { + ProgramOutcome { + program, + state, + detail: truncate_detail(detail), + } +} + +/// The provider IDs of the configured programs. The destructure is +/// exhaustive, so a new program key does not compile until it is mapped. +pub fn configured_programs(programs: &ProgramsConfig) -> Vec<&'static str> { + let ProgramsConfig { + claude_code, + claude_desktop, + codex, + open_code, + grok, + copilot, + vscode, + } = programs; + [ + (claude_code.is_some(), ClaudeCode::ID), + (claude_desktop.is_some(), ClaudeDesktop::ID), + (codex.is_some(), Codex::ID), + (open_code.is_some(), OpenCode::ID), + (grok.is_some(), Grok::ID), + (copilot.is_some(), Copilot::ID), + (vscode.is_some(), VsCode::ID), + ] + .into_iter() + .filter_map(|(configured, id)| configured.then_some(id)) + .collect() } #[derive(Default)] @@ -228,7 +720,7 @@ mod tests { use agentdesktop_core::config::parse_daemon; - use super::{DryRunReport, Reconciler}; + use super::{ApplyReport, DryRunReport, ProgramOutcome, ProgramState, Reconciler}; #[test] fn dry_run_report_shows_changes_and_hides_unchanged_files() { @@ -281,6 +773,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 +814,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 +826,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 +1024,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 +1068,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"), ); @@ -565,4 +1221,1029 @@ programs: ); assert!(!fixture.root.exists()); } + + // --- Per-program configuration status ----------------------------------- + + fn new_root(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "agentdesktop-reconcile-status-{name}-{}-{}", + std::process::id(), + rand::random::() + )) + } + + /// A user-mode reconciler with every provider's paths set (including + /// Copilot CLI and VS Code), rooted at `root`. + fn full_reconciler(root: &Path) -> 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(root.join("vscode/User/chatLanguageModels.json")), + Some(root.join("vscode/User/settings.json")), + root.join("bin/agentdesktop"), + root.join("agentdesktop.sock"), + ) + } + + fn proxy_context(pairing: &str) -> crate::llm_proxy::LlmProxyContext { + crate::llm_proxy::LlmProxyContext { + address: "127.0.0.1:18095".parse().unwrap(), + pairing: std::sync::Arc::from(pairing), + } + } + + fn program_outcome<'a>(report: &'a ApplyReport, program: &str) -> Option<&'a ProgramOutcome> { + report + .programs + .iter() + .find(|outcome| outcome.program == program) + } + + /// A provider whose `plan` always fails with a caller-supplied message, + /// for scenarios only the message shape matters for (detail truncation). + struct FailWithMessage { + message: String, + } + + #[async_trait::async_trait] + impl crate::provider::Provider for FailWithMessage { + fn id(&self) -> &'static str { + "fail-with-message" + } + + async fn discover(&self) -> agentdesktop_core::model::Discovery { + agentdesktop_core::model::Discovery { + agents: Vec::new(), + model_runtimes: Vec::new(), + } + } + + fn plan( + &self, + _ctx: &crate::provider::ReconcileContext, + _config: &agentdesktop_core::config::DaemonConfig, + ) -> anyhow::Result { + anyhow::bail!("{}", self.message) + } + } + + #[test] + fn applied_then_unchanged_with_report() { + let root = new_root("applied-unchanged"); + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + claudeCode: {} + copilot: + models: + gpt-4.1: {} +"#, + ) + .unwrap(); + let reconciler = full_reconciler(&root).with_llm_proxy(Some(proxy_context("PAIRING-1"))); + + let (report, result) = reconciler.apply_with_report(&config); + result.expect("first apply succeeds"); + assert_eq!( + program_outcome(&report, "claude-code").map(|outcome| outcome.state), + Some(ProgramState::Applied) + ); + assert_eq!( + program_outcome(&report, "copilot").map(|outcome| outcome.state), + Some(ProgramState::Applied) + ); + + let (report, result) = reconciler.apply_with_report(&config); + result.expect("second apply succeeds"); + assert_eq!( + program_outcome(&report, "claude-code").map(|outcome| outcome.state), + Some(ProgramState::Unchanged) + ); + assert_eq!( + program_outcome(&report, "copilot").map(|outcome| outcome.state), + Some(ProgramState::Unchanged) + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn removed_then_absent_on_the_next_apply() { + let root = new_root("removed-absent"); + let configured = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + copilot: + models: + gpt-4.1: {} +"#, + ) + .unwrap(); + let reconciler = full_reconciler(&root).with_llm_proxy(Some(proxy_context("PAIRING-2"))); + reconciler + .apply_with_report(&configured) + .1 + .expect("apply configured"); + + let disabled = parse_daemon("programs: {}").unwrap(); + let (report, result) = reconciler.apply_with_report(&disabled); + result.expect("apply disabled"); + assert_eq!( + program_outcome(&report, "copilot").map(|outcome| outcome.state), + Some(ProgramState::Removed) + ); + + let (report, result) = reconciler.apply_with_report(&disabled); + result.expect("apply disabled again"); + assert!( + program_outcome(&report, "copilot").is_none(), + "an unconfigured, idle provider gets no row" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn use_llm_gateway_false_reports_applied_or_unchanged() { + let root = new_root("use-llm-gateway-false"); + let with_gateway = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + copilot: + models: + gpt-4.1: {} +"#, + ) + .unwrap(); + let reconciler = full_reconciler(&root).with_llm_proxy(Some(proxy_context("PAIRING-3"))); + reconciler + .apply_with_report(&with_gateway) + .1 + .expect("apply with the gateway on"); + + let gateway_off = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + copilot: + useLlmGateway: false +"#, + ) + .unwrap(); + let (report, result) = reconciler.apply_with_report(&gateway_off); + result.expect("apply with the gateway off"); + assert_eq!( + program_outcome(&report, "copilot").map(|outcome| outcome.state), + Some(ProgramState::Applied), + "the managed file is removed, so a configured program is Applied, not Removed" + ); + + let (report, result) = reconciler.apply_with_report(&gateway_off); + result.expect("apply with the gateway off again"); + assert_eq!( + program_outcome(&report, "copilot").map(|outcome| outcome.state), + Some(ProgramState::Unchanged) + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn conflict_blocks_other_programs_and_reports_blocked() { + let root = new_root("conflict-blocked"); + fs::create_dir_all(root.join("codex")).unwrap(); + fs::write(root.join("codex/config.toml"), b"model = \"personal\"\n").unwrap(); + let config = parse_daemon("programs:\n claudeCode: {}\n codex: {}").unwrap(); + let reconciler = full_reconciler(&root); + + let (report, result) = reconciler.apply_with_report(&config); + assert!(result.is_err(), "a conflict means nothing is written"); + assert_eq!( + program_outcome(&report, "codex").map(|outcome| outcome.state), + Some(ProgramState::Conflict) + ); + let claude_code = program_outcome(&report, "claude-code").expect("claude-code is reported"); + assert_eq!(claude_code.state, ProgramState::Blocked); + assert!( + claude_code.detail.contains("codex"), + "a blocked program's detail names the first conflicted program: {}", + claude_code.detail + ); + assert!(!root.join("claude").exists()); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn failed_provider_plan_does_not_stop_the_others() { + let root = new_root("failed-plan-continues"); + // Grok Build's managed config lives in a system location, so a + // user-mode daemon rejects it in `plan()` (see + // `user_mode_rejects_grok_before_writing_other_settings` above) while + // Claude Code plans normally: a real, deterministic plan failure. + let reconciler = full_reconciler(&root); + let config = parse_daemon("programs:\n claudeCode: {}\n grok: {}").unwrap(); + + let (report, result) = reconciler.apply_with_report(&config); + assert!(result.is_err()); + let grok = program_outcome(&report, "grok").expect("grok is reported"); + assert_eq!(grok.state, ProgramState::Failed); + assert!(grok.detail.contains("Grok Build"), "{}", grok.detail); + assert_eq!( + program_outcome(&report, "claude-code").map(|outcome| outcome.state), + Some(ProgramState::Blocked), + "claude-code was still planned, but nothing is written because the apply fails" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn observed_file_mismatch_fails_its_owners_and_blocks_the_rest() { + let root = new_root("observed-mismatch"); + let reconciler = full_reconciler(&root); + let original = parse_daemon("programs:\n claudeCode: {}\n codex: {}").unwrap(); + reconciler + .apply_with_report(&original) + .1 + .expect("initial apply"); + + let update = + parse_daemon("programs:\n claudeCode:\n env:\n COMPANY: updated\n codex: {}") + .unwrap(); + let attributed = reconciler.plan_with_report(&update); + // Codex's file changes after planning but before apply. + fs::write(root.join("codex/config.toml"), b"externally changed").unwrap(); + let (report, result) = attributed.apply(); + assert!(result.is_err()); + assert_eq!( + program_outcome(&report, "codex").map(|outcome| outcome.state), + Some(ProgramState::Failed) + ); + assert_eq!( + program_outcome(&report, "claude-code").map(|outcome| outcome.state), + Some(ProgramState::Blocked) + ); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn write_failure_orders_applied_failed_blocked() { + // SAFETY: geteuid has no preconditions and does not dereference pointers. + if unsafe { libc::geteuid() } == 0 { + return; // root ignores directory permissions. + } + let root = new_root("write-failure-order"); + fs::create_dir_all(root.join("codex")).unwrap(); + let mut reconciler = full_reconciler(&root); + reconciler.providers = std::sync::Arc::new(vec![ + Box::new(super::ClaudeCode { + settings_path: root.join("claude/settings.json"), + }), + Box::new(super::Codex { + managed_config_path: root.join("codex/config.toml"), + }), + Box::new(super::OpenCode { + managed_config_path: root.join("opencode/config.json"), + plugin_path: root.join("opencode/plugin.js"), + }), + ]); + let config = parse_daemon( + r#" +programs: + claudeCode: {} + codex: {} + openCode: + model: m + models: + m: {} +"#, + ) + .unwrap(); + let attributed = reconciler.plan_with_report(&config); + + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(root.join("codex"), fs::Permissions::from_mode(0o500)).unwrap(); + let (report, result) = attributed.apply(); + // Restore permissions so the temp directory can be removed. + fs::set_permissions(root.join("codex"), fs::Permissions::from_mode(0o700)).unwrap(); + + assert!(result.is_err()); + assert_eq!( + program_outcome(&report, "claude-code").map(|outcome| outcome.state), + Some(ProgramState::Applied) + ); + assert_eq!( + program_outcome(&report, "codex").map(|outcome| outcome.state), + Some(ProgramState::Failed) + ); + assert_eq!( + program_outcome(&report, "opencode").map(|outcome| outcome.state), + Some(ProgramState::Blocked) + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn copilot_and_vscode_own_models_go_inactive_when_the_proxy_is_absent() { + let root = new_root("inactive-own-models"); + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + copilot: + models: + gpt-4.1: {} + vscode: + models: + gpt-4.1-mini: {} +"#, + ) + .unwrap(); + let reconciler = full_reconciler(&root).with_llm_proxy(Some(proxy_context("PAIRING-5"))); + let (applied, result) = reconciler.apply_with_report(&config); + result.expect("apply with the proxy available"); + assert_eq!( + program_outcome(&applied, "copilot").map(|outcome| outcome.state), + Some(ProgramState::Applied) + ); + assert_eq!( + program_outcome(&applied, "vscode").map(|outcome| outcome.state), + Some(ProgramState::Applied) + ); + + // A restart (or a hot reload) without the proxy: from an applied + // state, both programs go Inactive rather than being torn down as an + // ordinary removal. + let reconciler_without_proxy = full_reconciler(&root); + let (report, result) = reconciler_without_proxy.apply_with_report(&config); + result.expect("apply without the proxy still succeeds: files are removed, not written"); + for program in ["copilot", "vscode"] { + let outcome = program_outcome(&report, program) + .unwrap_or_else(|| panic!("{program} is reported")); + assert_eq!(outcome.state, ProgramState::Inactive, "{program}"); + assert!( + outcome.detail.contains("local LLM proxy not available"), + "{program}: {}", + outcome.detail + ); + } + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn vscode_github_models_goes_inactive_when_the_proxy_is_absent() { + let root = new_root("inactive-github-models"); + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com + proxyUrl: https://gateway.example.com/copilot-proxy +programs: + vscode: + copilotChat: githubModels +"#, + ) + .unwrap(); + let reconciler = full_reconciler(&root); + let (report, result) = reconciler.apply_with_report(&config); + result.expect("apply without the proxy still succeeds"); + let outcome = program_outcome(&report, "vscode").expect("vscode is reported"); + assert_eq!(outcome.state, ProgramState::Inactive); + assert!( + outcome.detail.contains("local LLM proxy not available"), + "{}", + outcome.detail + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn vscode_conflict_on_one_file_outranks_inactive_on_the_other() { + let root = new_root("vscode-conflict-outranks-inactive"); + let github_models = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com + proxyUrl: https://gateway.example.com/copilot-proxy +programs: + vscode: + copilotChat: githubModels +"#, + ) + .unwrap(); + let reconciler = full_reconciler(&root).with_llm_proxy(Some(proxy_context("PAIRING-7"))); + reconciler + .apply_with_report(&github_models) + .1 + .expect("apply githubModels with the proxy available"); + let settings_path = root.join("vscode/User/settings.json"); + assert!(settings_path.is_file(), "the override was written"); + + // Break the shape the merge left in settings.json (an object) so its + // removal below is a conflict, and switch to ownModels with no proxy + // so the other managed file (chatLanguageModels.json) is Inactive in + // the same apply: Conflict must outrank Inactive for the program. + fs::write(&settings_path, b"[]\n").unwrap(); + let own_models = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + vscode: + models: + gpt-4.1-mini: {} +"#, + ) + .unwrap(); + let reconciler = full_reconciler(&root); + let (report, result) = reconciler.apply_with_report(&own_models); + assert!(result.is_err(), "the conflict means nothing is written"); + let vscode = program_outcome(&report, "vscode").expect("vscode is reported"); + assert_eq!( + vscode.state, + ProgramState::Conflict, + "conflict outranks inactive" + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn discovery_only_and_unconfigured_idle_providers_get_no_row() { + let root = new_root("no-row-for-idle"); + let reconciler = full_reconciler(&root); + let config = parse_daemon("programs:\n claudeCode: {}\n").unwrap(); + let (report, result) = reconciler.apply_with_report(&config); + result.expect("apply succeeds"); + assert_eq!( + program_outcome(&report, "claude-code").map(|outcome| outcome.state), + Some(ProgramState::Applied) + ); + for absent in [ + "cursor", + "ollama", + "codex", + "claude-desktop", + "grok", + "copilot", + "vscode", + "opencode", + ] { + assert!( + program_outcome(&report, absent).is_none(), + "{absent} must get no row" + ); + } + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn program_detail_is_truncated_to_1024_bytes() { + let mut fixture = Fixture::new(); + // A multi-byte character straddles the 1024-byte cutoff so + // truncation must land on a char boundary, not merely a byte count. + let long_run = "a".repeat(1020); + let message = format!("{long_run}\u{1F600}{long_run}"); + fixture.reconciler.providers = std::sync::Arc::new(vec![Box::new(FailWithMessage { + message: message.clone(), + })]); + let config = parse_daemon("programs:\n claudeCode: {}\n").unwrap(); + + let (report, result) = fixture.reconciler.apply_with_report(&config); + assert!(result.is_err()); + assert_eq!( + report.programs.len(), + 1, + "the one failing provider is reported" + ); + let detail = &report.programs[0].detail; + assert!( + detail.len() <= 1024, + "detail must be truncated to 1024 bytes: {} bytes", + detail.len() + ); + assert!( + message.len() > 1024, + "the untruncated message must exceed the limit" + ); + } + + #[test] + fn conflict_and_sidecar_failure_details_never_include_the_pairing() { + const PAIRING: &str = "PAIRING-SECRET-13"; + + // (a) A foreign "agentdesktop" vendor entry is a conflict. + { + let root = new_root("no-pairing-conflict"); + let chat_models = root.join("vscode/User/chatLanguageModels.json"); + fs::create_dir_all(chat_models.parent().unwrap()).unwrap(); + fs::write( + &chat_models, + serde_json::to_vec_pretty(&serde_json::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 reconciler = full_reconciler(&root).with_llm_proxy(Some(proxy_context(PAIRING))); + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + vscode: + models: + gpt-4.1-mini: {} +"#, + ) + .unwrap(); + let (report, result) = reconciler.apply_with_report(&config); + assert!(result.is_err()); + let vscode = program_outcome(&report, "vscode").expect("vscode is reported"); + assert_eq!(vscode.state, ProgramState::Conflict); + assert!( + !vscode.detail.contains(PAIRING), + "conflict detail must not leak the pairing: {}", + vscode.detail + ); + let _ = fs::remove_dir_all(&root); + } + + // (b) A corrupted sidecar file is a plan error (Failed), not a conflict. + { + let root = new_root("no-pairing-sidecar"); + let reconciler = full_reconciler(&root).with_llm_proxy(Some(proxy_context(PAIRING))); + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + vscode: + models: + gpt-4.1-mini: {} +"#, + ) + .unwrap(); + reconciler + .apply_with_report(&config) + .1 + .expect("initial apply"); + let sidecar = root.join("vscode/User/.chatLanguageModels.json.agentdesktop"); + assert!(sidecar.is_file(), "the sidecar was written"); + fs::write(&sidecar, b"{ not json").unwrap(); + + let (report, result) = reconciler.apply_with_report(&config); + assert!(result.is_err()); + let vscode = program_outcome(&report, "vscode").expect("vscode is reported"); + assert_eq!(vscode.state, ProgramState::Failed); + assert!( + !vscode.detail.contains(PAIRING), + "sidecar parse failure detail must not leak the pairing: {}", + vscode.detail + ); + let _ = fs::remove_dir_all(&root); + } + } + + #[test] + fn two_programs_planning_the_same_path_both_fail_and_the_rest_is_blocked() { + let root = new_root("same-path"); + let shared = root.join("shared/settings.json"); + let mut reconciler = full_reconciler(&root); + reconciler.providers = std::sync::Arc::new(vec![ + Box::new(super::ClaudeCode { + settings_path: shared.clone(), + }), + Box::new(super::OpenCode { + managed_config_path: root.join("opencode/config.json"), + plugin_path: root.join("opencode/plugin.js"), + }), + Box::new(super::Codex { + managed_config_path: shared, + }), + ]); + let config = parse_daemon( + "programs:\n claudeCode: {}\n codex: {}\n openCode:\n model: m\n models:\n m: {}\n", + ) + .unwrap(); + let (report, result) = reconciler.apply_with_report(&config); + assert!( + result + .unwrap_err() + .to_string() + .contains("multiple providers plan to modify") + ); + let state = |program| program_outcome(&report, program).map(|outcome| outcome.state); + assert_eq!(state("claude-code"), Some(ProgramState::Failed)); + assert_eq!(state("codex"), Some(ProgramState::Failed)); + assert_eq!(state("opencode"), Some(ProgramState::Blocked)); + assert!(!root.exists(), "nothing is written"); + } + + #[test] + fn a_wrongly_typed_sidecar_field_does_not_put_its_value_in_the_detail() { + const PAIRING: &str = "PAIRING-IN-SIDECAR"; + let root = new_root("typed-sidecar"); + let providers = root.join("copilot/providers.json"); + fs::create_dir_all(providers.parent().unwrap()).unwrap(); + fs::write(&providers, b"{}\n").unwrap(); + // `created` must be a boolean; serde would quote the string. + fs::write( + root.join("copilot/.providers.json.agentdesktop"), + format!("{{\"created\": \"{PAIRING}\", \"before\": {{}}, \"after\": {{}}}}"), + ) + .unwrap(); + let reconciler = full_reconciler(&root).with_llm_proxy(Some(proxy_context(PAIRING))); + let config = parse_daemon( + "llmGateway:\n url: https://gateway.example.com\nprograms:\n copilot:\n models:\n gpt-4.1: {}\n", + ) + .unwrap(); + let (report, result) = reconciler.apply_with_report(&config); + assert!(result.is_err()); + let copilot = program_outcome(&report, "copilot").expect("copilot is reported"); + assert_eq!(copilot.state, ProgramState::Failed); + assert!(!copilot.detail.contains(PAIRING), "{}", copilot.detail); + let _ = fs::remove_dir_all(&root); + } + + #[cfg(unix)] + #[test] + fn a_private_directory_that_cannot_be_created_fails_its_owner_and_blocks_the_rest() { + // SAFETY: geteuid has no preconditions and does not dereference pointers. + if unsafe { libc::geteuid() } == 0 { + return; // root ignores directory permissions. + } + use std::os::unix::fs::PermissionsExt; + let root = new_root("private-dir"); + let config = parse_daemon( + "llmGateway:\n url: https://gateway.example.com\nprograms:\n claudeCode: {}\n vscode:\n models:\n gpt-4.1-mini: {}\n", + ) + .unwrap(); + let reconciler = full_reconciler(&root).with_llm_proxy(Some(proxy_context("PAIRING-PD"))); + let attributed = reconciler.plan_with_report(&config); + // The VS Code profile directory the plan wants to create owner-only + // cannot be created any more. + fs::create_dir_all(root.join("vscode")).unwrap(); + fs::set_permissions(root.join("vscode"), fs::Permissions::from_mode(0o500)).unwrap(); + let (report, result) = attributed.apply(); + fs::set_permissions(root.join("vscode"), fs::Permissions::from_mode(0o700)).unwrap(); + assert!(result.is_err()); + let state = |program| program_outcome(&report, program).map(|outcome| outcome.state); + assert_eq!(state("vscode"), Some(ProgramState::Failed)); + assert_eq!(state("claude-code"), Some(ProgramState::Blocked)); + assert!(!root.join("claude").exists(), "nothing is written"); + let _ = fs::remove_dir_all(&root); + } + + // --- No-op writes skipped, full reconciler --------------------------------- + + fn sidecar_path(path: &Path) -> PathBuf { + let name = path.file_name().unwrap().to_str().unwrap(); + path.with_file_name(format!(".{name}.agentdesktop")) + } + + #[test] + fn second_apply_of_an_unchanged_multi_program_config_writes_nothing() { + let root = new_root("second-apply-no-op"); + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + claudeCode: {} + copilot: + models: + gpt-4.1: {} + vscode: + models: + gpt-4.1-mini: {} +"#, + ) + .unwrap(); + let reconciler = + full_reconciler(&root).with_llm_proxy(Some(proxy_context("PAIRING-TICK-1"))); + reconciler + .apply_with_report(&config) + .1 + .expect("first apply succeeds"); + + let paths = [ + root.join("claude/settings.json"), + sidecar_path(&root.join("claude/settings.json")), + root.join("copilot/providers.json"), + sidecar_path(&root.join("copilot/providers.json")), + root.join("vscode/User/chatLanguageModels.json"), + sidecar_path(&root.join("vscode/User/chatLanguageModels.json")), + ]; + let before: Vec<_> = paths + .iter() + .map(|path| { + fs::metadata(path).unwrap_or_else(|error| { + panic!( + "{} must exist after the first apply: {error}", + path.display() + ) + }) + }) + .collect(); + + reconciler + .apply_with_report(&config) + .1 + .expect("second apply succeeds"); + + for (path, before) in paths.iter().zip(before) { + let after = fs::metadata(path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!( + after.ino(), + before.ino(), + "{} must not be rewritten by an unchanged apply", + path.display() + ); + } + assert_eq!( + after.modified().unwrap(), + before.modified().unwrap(), + "{} mtime must not change on an unchanged apply", + path.display() + ); + } + let _ = fs::remove_dir_all(&root); + } + + #[test] + #[cfg(unix)] + fn providers_json_at_a_looser_mode_is_tightened_on_the_next_apply() { + use std::os::unix::fs::PermissionsExt; + let root = new_root("copilot-tighten"); + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + copilot: + models: + gpt-4.1: {} +"#, + ) + .unwrap(); + let reconciler = + full_reconciler(&root).with_llm_proxy(Some(proxy_context("PAIRING-TICK-2"))); + reconciler + .apply_with_report(&config) + .1 + .expect("first apply succeeds"); + + let providers_path = root.join("copilot/providers.json"); + fs::set_permissions(&providers_path, fs::Permissions::from_mode(0o664)).unwrap(); + let before_contents = fs::read(&providers_path).unwrap(); + + reconciler + .apply_with_report(&config) + .1 + .expect("second apply succeeds"); + + assert_eq!( + fs::read(&providers_path).unwrap(), + before_contents, + "bytes must not change" + ); + let mode = fs::metadata(&providers_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "a looser providers.json must be tightened"); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn a_deleted_managed_file_is_recreated_on_the_next_apply() { + let root = new_root("recreate-deleted"); + let config = parse_daemon( + r#" +llmGateway: + url: https://gateway.example.com +programs: + copilot: + models: + gpt-4.1: {} +"#, + ) + .unwrap(); + let reconciler = + full_reconciler(&root).with_llm_proxy(Some(proxy_context("PAIRING-TICK-3"))); + reconciler + .apply_with_report(&config) + .1 + .expect("first apply succeeds"); + + let providers_path = root.join("copilot/providers.json"); + let original = fs::read(&providers_path).unwrap(); + fs::remove_file(&providers_path).unwrap(); + + let (report, result) = reconciler.apply_with_report(&config); + result.expect("second apply recreates the deleted file"); + assert_eq!( + program_outcome(&report, "copilot").map(|outcome| outcome.state), + Some(ProgramState::Applied) + ); + assert_eq!(fs::read(&providers_path).unwrap(), original); + let _ = fs::remove_dir_all(&root); + } + + // --- Serialized applies ---------------------------------------------------- + + /// A provider whose `plan` records that it was entered (in order) and, + /// only the first time, blocks until `release` is signalled. Later calls + /// return immediately, so the test can tell whether a second + /// `apply_with_report` was let into `plan` before the first released the + /// lock. + struct BlockingProbe { + order: std::sync::Arc>>, + release: std::sync::Arc<(std::sync::Mutex, std::sync::Condvar)>, + } + + #[async_trait::async_trait] + impl crate::provider::Provider for BlockingProbe { + fn id(&self) -> &'static str { + "blocking-probe" + } + + async fn discover(&self) -> agentdesktop_core::model::Discovery { + agentdesktop_core::model::Discovery { + agents: Vec::new(), + model_runtimes: Vec::new(), + } + } + + fn plan( + &self, + _ctx: &crate::provider::ReconcileContext, + _config: &agentdesktop_core::config::DaemonConfig, + ) -> anyhow::Result { + let is_first = { + let mut order = self.order.lock().unwrap(); + order.push("enter"); + order.len() == 1 + }; + if is_first { + let (lock, condvar) = &*self.release; + let mut released = lock.lock().unwrap(); + while !*released { + released = condvar.wait(released).unwrap(); + } + } + Ok(super::ReconcilePlan::default()) + } + } + + #[test] + fn a_second_apply_on_a_clone_does_not_enter_plan_until_the_first_releases_the_lock() { + let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let release = + std::sync::Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); + let mut reconciler = full_reconciler(&new_root("apply-lock")); + reconciler.providers = std::sync::Arc::new(vec![Box::new(BlockingProbe { + order: order.clone(), + release: release.clone(), + })]); + let config = parse_daemon("programs: {}").unwrap(); + + let first = reconciler.clone(); + let first_config = config.clone(); + let first_handle = std::thread::spawn(move || first.apply_with_report(&first_config)); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while order.lock().unwrap().is_empty() { + assert!( + std::time::Instant::now() < deadline, + "the first apply never entered plan" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + let second = reconciler.clone(); + let second_config = config.clone(); + let second_handle = std::thread::spawn(move || second.apply_with_report(&second_config)); + + // Give a wrongly-unserialized second call time to enter `plan`. + std::thread::sleep(std::time::Duration::from_millis(200)); + assert_eq!( + order.lock().unwrap().len(), + 1, + "a second apply_with_report on a clone must not enter plan while \ + the first holds the lock" + ); + + { + let (lock, condvar) = &*release; + *lock.lock().unwrap() = true; + condvar.notify_all(); + } + + let (_, first_result) = first_handle.join().unwrap(); + let (_, second_result) = second_handle.join().unwrap(); + assert!(first_result.is_ok(), "{first_result:?}"); + assert!(second_result.is_ok(), "{second_result:?}"); + assert_eq!( + order.lock().unwrap().len(), + 2, + "the second apply must eventually run once the lock is released" + ); + } + + // --- Tick logging dedup ---------------------------------------------------- + + fn outcome_report(state: ProgramState, detail: &str) -> ApplyReport { + ApplyReport { + programs: vec![ProgramOutcome { + program: "claude-code", + state, + detail: detail.to_owned(), + }], + } + } + + #[test] + fn should_log_with_nothing_applied_yet_always_logs() { + let current = outcome_report(ProgramState::Applied, ""); + assert!(super::should_log(None, ¤t)); + } + + #[test] + fn should_log_is_false_when_the_report_is_unchanged() { + let previous = outcome_report(ProgramState::Unchanged, ""); + let current = outcome_report(ProgramState::Unchanged, ""); + assert!(!super::should_log(Some(&previous), ¤t)); + } + + #[test] + fn should_log_is_false_when_applied_or_removed_settles_to_unchanged() { + for state in [ProgramState::Applied, ProgramState::Removed] { + let previous = outcome_report(state, ""); + let current = outcome_report(ProgramState::Unchanged, ""); + assert!( + !super::should_log(Some(&previous), ¤t), + "{state:?} -> Unchanged must not be logged" + ); + } + } + + #[test] + fn should_log_is_true_when_a_programs_detail_changes() { + let previous = outcome_report(ProgramState::Conflict, "conflict at a"); + let current = outcome_report(ProgramState::Conflict, "conflict at b"); + assert!(super::should_log(Some(&previous), ¤t)); + } + + #[test] + fn should_log_is_true_when_a_programs_state_changes_to_something_other_than_unchanged() { + let previous = outcome_report(ProgramState::Unchanged, ""); + let current = outcome_report(ProgramState::Failed, "boom"); + assert!(super::should_log(Some(&previous), ¤t)); + } + + #[test] + fn a_program_that_disappears_is_a_difference_unless_it_was_removed() { + let both = ApplyReport { + programs: vec![ + ProgramOutcome { + program: "claude-code", + state: ProgramState::Unchanged, + detail: String::new(), + }, + ProgramOutcome { + program: "copilot", + state: ProgramState::Unchanged, + detail: String::new(), + }, + ], + }; + let one = outcome_report(ProgramState::Unchanged, ""); + assert!(super::should_log(Some(&both), &one), "copilot vanished"); + let mut removed = both.clone(); + removed.programs[1].state = ProgramState::Removed; + assert!( + !super::should_log(Some(&removed), &one), + "a removed program that is no longer listed is not news" + ); + } } diff --git a/crates/agent/src/reconcile/plan.rs b/crates/agent/src/reconcile/plan.rs index f28a27c1..333a5b4f 100644 --- a/crates/agent/src/reconcile/plan.rs +++ b/crates/agent/src/reconcile/plan.rs @@ -14,7 +14,36 @@ use std::{ pub struct ReconcilePlan { observed: RefCell>>>, operations: RefCell>, + private_dirs: PrivateDirs, report: DryRunReport, + /// Why the program that owns this plan is configured but not pointed at + /// the gateway (the loopback proxy is absent); set by the provider. + inactive: RefCell>, +} + +/// What one provider's plan contains, taken before it is appended to the +/// device-wide plan, for per-program reporting. +pub(super) struct PlanSummary { + /// The plan writes or removes something that is not already so on disk, + /// or records a create/update/remove (a mode-only rewrite). + pub changes: bool, + /// One line per conflicting file. + pub conflicts: Vec, + pub inactive: Option, + /// Paths the plan read or writes. + pub paths: Vec, +} + +/// Where an apply stopped. +pub(super) enum ApplyStop { + /// A recorded conflict refused the whole plan before anything ran. + Conflict, + /// An observed file changed after planning; nothing was written. + Observed(PathBuf), + /// A private directory could not be created; nothing was written. + PrivateDir(PathBuf), + /// The operation at this index failed; the ones before it were written. + Operation(usize), } struct FileChange { @@ -23,6 +52,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() @@ -38,6 +73,62 @@ impl ReconcilePlan { /// Consume a plan so it cannot accidentally be applied twice. pub fn apply(self) -> anyhow::Result<()> { + self.apply_tracked().map_err(|(error, _)| error) + } + + /// Records that the program owning this plan uses the gateway, a gateway + /// is configured, and the loopback proxy is absent. + pub fn inactive(&self, reason: &str) { + *self.inactive.borrow_mut() = Some(reason.to_owned()); + } + + /// The reason recorded by [`ReconcilePlan::inactive`], for tests. + #[cfg(test)] + pub(crate) fn inactive_reason(&self) -> Option { + self.inactive.borrow().clone() + } + + pub(super) fn operation_count(&self) -> usize { + self.operations.borrow().len() + } + + pub(super) fn summary(&self) -> PlanSummary { + let observed = self.observed.borrow(); + let operations = self.operations.borrow(); + let changes = self.report.changes.borrow(); + let differs = operations.iter().any(|change| { + observed.get(&change.path).map(Option::as_deref) != Some(change.contents.as_deref()) + }); + let recorded = changes + .iter() + .any(|change| matches!(change.action.as_str(), "create" | "update" | "remove")); + let conflicts = changes + .iter() + .filter(|change| change.action == "conflict") + .map(|change| { + format!( + "{} at {}: conflicting or invalid existing configuration", + change.description, + change.path.display() + ) + }) + .collect(); + let mut paths: Vec = observed.keys().cloned().collect(); + for change in operations.iter() { + if !paths.contains(&change.path) { + paths.push(change.path.clone()); + } + } + PlanSummary { + changes: differs || recorded, + conflicts, + inactive: self.inactive.borrow().clone(), + paths, + } + } + + /// Applies like [`ReconcilePlan::apply`] and says where it stopped. + pub(super) fn apply_tracked(self) -> Result<(), (anyhow::Error, ApplyStop)> { if let Some(conflict) = self .report .changes @@ -45,63 +136,106 @@ impl ReconcilePlan { .iter() .find(|change| change.action == "conflict") { - anyhow::bail!( - "refusing to change {} {} at {}: conflicting or invalid existing configuration", - conflict.display_name, - conflict.description, - conflict.path.display() - ); + return Err(( + anyhow::anyhow!( + "refusing to change {} {} at {}: conflicting or invalid existing configuration", + conflict.display_name, + conflict.description, + conflict.path.display() + ), + ApplyStop::Conflict, + )); } for (path, expected) in self.observed.borrow().iter() { + let stop = || ApplyStop::Observed(path.clone()); let current = read_optional(path) - .with_context(|| format!("validate {} before applying plan", path.display()))?; - anyhow::ensure!( - ¤t == expected, - "{} changed since reconciliation was planned; retry reconciliation", - path.display() - ); + .with_context(|| format!("validate {} before applying plan", path.display())) + .map_err(|error| (error, stop()))?; + if ¤t != expected { + return Err(( + anyhow::anyhow!( + "{} changed since reconciliation was planned; retry reconciliation", + path.display() + ), + stop(), + )); + } } - for change in self.operations.into_inner() { - match change.contents { - Some(contents) => { - let parent = change - .path - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - fs::create_dir_all(parent) - .with_context(|| format!("create directory {}", parent.display()))?; - crate::secure_fs::atomic_write(&change.path, &contents, change.permissions)?; - } - None => fs::remove_file(&change.path) - .with_context(|| format!("remove {}", change.path.display()))?, + for dir in self.private_dirs.0.into_inner() { + if !dir.exists() { + crate::secure_fs::ensure_private_dir(&dir) + .map_err(|error| (error, ApplyStop::PrivateDir(dir.clone())))?; + } + } + let observed = self.observed.into_inner(); + for (index, change) in self.operations.into_inner().into_iter().enumerate() { + if let Some(contents) = &change.contents + && already_in_place(&change.path, contents, change.permissions, &observed) + { + continue; } + let result = (|| -> anyhow::Result<()> { + match change.contents { + Some(contents) => { + let parent = change + .path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent) + .with_context(|| format!("create directory {}", parent.display()))?; + crate::secure_fs::atomic_write(&change.path, &contents, change.permissions) + } + None => fs::remove_file(&change.path) + .with_context(|| format!("remove {}", change.path.display())), + } + })(); + result.map_err(|error| (error, ApplyStop::Operation(index)))?; tracing::info!(path = %change.path.display(), "applied reconciliation change"); } Ok(()) } pub(super) fn append(&mut self, other: Self) -> anyhow::Result<()> { + self.append_attributed(other).map_err(|(error, _)| error) + } + + /// Appends like [`ReconcilePlan::append`]; on failure also returns the + /// path the two plans disagree about. + pub(super) fn append_attributed( + &mut self, + other: Self, + ) -> Result<(), (anyhow::Error, PathBuf)> { let observations = self.observed.get_mut(); for (path, expected) in other.observed.into_inner() { - if let Some(previous) = observations.get(&path) { - anyhow::ensure!( - previous == &expected, - "{} changed while planning reconciliation", - path.display() - ); + if let Some(previous) = observations.get(&path) + && previous != &expected + { + return Err(( + anyhow::anyhow!("{} changed while planning reconciliation", path.display()), + path, + )); } 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!( - !operations - .iter() - .any(|existing| existing.path == change.path), - "multiple providers plan to modify {}", - change.path.display() - ); + if operations + .iter() + .any(|existing| existing.path == change.path) + { + return Err(( + anyhow::anyhow!( + "multiple providers plan to modify {}", + change.path.display() + ), + change.path, + )); + } operations.push(change); } self.report @@ -144,6 +278,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 { @@ -187,3 +326,294 @@ fn read_optional(path: &Path) -> io::Result>> { Err(error) => Err(error), } } + +// --- No-op writes skipped ---------------------------------------------------- + +/// Whether a planned write would change nothing: the file already holds the +/// planned bytes (as observed when planning, which the apply has just +/// verified) and, on Unix, grants no permission bit outside the planned +/// mode. A file looser than planned is rewritten; a stricter one is left. +fn already_in_place( + path: &Path, + contents: &[u8], + permissions: u32, + observed: &BTreeMap>>, +) -> bool { + // A file gone since it was observed is written again. + observed.get(path).and_then(Option::as_deref) == Some(contents) + && fs::metadata(path).is_ok() + && !grants_beyond(path, permissions) +} + +/// Whether the file at `path` grants a permission bit outside `mode`. +/// Unix: the current mode has a bit outside `mode`. A missing file, and any +/// file on non-Unix, is false. +pub(crate) fn grants_beyond(path: &Path, mode: u32) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & !mode & 0o777 != 0) + } + #[cfg(not(unix))] + { + let _ = (path, mode); + false + } +} + +/// Shared checks for the planners that write a managed file (C5.1). +#[cfg(all(test, unix))] +pub(crate) mod mode_repair { + use super::ReconcilePlan; + use std::{ + fs, + os::unix::fs::{MetadataExt, PermissionsExt}, + path::Path, + }; + + fn mode(path: &Path) -> u32 { + fs::metadata(path).unwrap().permissions().mode() & 0o777 + } + + fn applied(plan_fn: &dyn Fn(&ReconcilePlan) -> anyhow::Result<()>) { + let plan = ReconcilePlan::default(); + plan_fn(&plan).unwrap(); + plan.apply().unwrap(); + } + + /// `plan_fn` plans the program's managed `files`, each planned at + /// `planned_mode`. After a first apply every file is loosened to 0666 with + /// its bytes untouched; the next plan must record an `update` and the + /// apply must restore `planned_mode` on every file. + pub(crate) fn assert_identical_bytes_with_looser_mode_is_an_update( + files: &[&Path], + planned_mode: u32, + plan_fn: &dyn Fn(&ReconcilePlan) -> anyhow::Result<()>, + ) { + applied(plan_fn); + let bytes: Vec<_> = files.iter().map(|file| fs::read(file).unwrap()).collect(); + for file in files { + fs::set_permissions(file, fs::Permissions::from_mode(0o666)).unwrap(); + } + + let plan = ReconcilePlan::default(); + plan_fn(&plan).unwrap(); + let rendered = plan.render(); + assert!( + rendered.contains("UPDATE"), + "a loosened mode with identical bytes must be an update: {rendered}" + ); + plan.apply().unwrap(); + + for (file, bytes) in files.iter().zip(bytes) { + assert_eq!( + mode(file), + planned_mode, + "{} must be rewritten at the planned mode", + file.display() + ); + assert_eq!(fs::read(file).unwrap(), bytes); + } + } + + /// After a first apply every file is tightened to 0600 (within any planned + /// mode); the next plan must record no change and must not write. + pub(crate) fn assert_identical_bytes_within_mode_is_unchanged( + files: &[&Path], + plan_fn: &dyn Fn(&ReconcilePlan) -> anyhow::Result<()>, + ) { + applied(plan_fn); + for file in files { + fs::set_permissions(file, fs::Permissions::from_mode(0o600)).unwrap(); + } + let before: Vec<_> = files + .iter() + .map(|file| { + let metadata = fs::metadata(file).unwrap(); + (metadata.ino(), metadata.modified().unwrap()) + }) + .collect(); + + let plan = ReconcilePlan::default(); + plan_fn(&plan).unwrap(); + let rendered = plan.render(); + assert!( + !rendered.contains("UPDATE") && !rendered.contains("CREATE"), + "an identical file within the planned mode must stay unchanged: {rendered}" + ); + plan.apply().unwrap(); + + for (file, (ino, modified)) in files.iter().zip(before) { + let metadata = fs::metadata(file).unwrap(); + assert_eq!( + metadata.ino(), + ino, + "{} must not be replaced", + file.display() + ); + assert_eq!(metadata.modified().unwrap(), modified); + assert_eq!(mode(file), 0o600, "a stricter mode must be left"); + } + } +} + +#[cfg(test)] +mod tests { + use super::{ReconcilePlan, already_in_place, grants_beyond}; + use std::{collections::BTreeMap, fs}; + + #[test] + fn grants_beyond_is_false_for_a_missing_file() { + let dir = tempfile::tempdir().unwrap(); + assert!(!grants_beyond(&dir.path().join("absent"), 0o600)); + } + + #[test] + #[cfg(unix)] + fn grants_beyond_is_false_within_the_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("file"); + fs::write(&path, b"x").unwrap(); + for current in [0o600, 0o640, 0o644] { + fs::set_permissions(&path, fs::Permissions::from_mode(current)).unwrap(); + assert!(!grants_beyond(&path, 0o644), "{current:o} within 644"); + } + } + + #[test] + #[cfg(unix)] + fn grants_beyond_is_true_with_a_bit_outside_the_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("file"); + fs::write(&path, b"x").unwrap(); + for (current, planned) in [(0o664, 0o644), (0o644, 0o600), (0o755, 0o644)] { + fs::set_permissions(&path, fs::Permissions::from_mode(current)).unwrap(); + assert!( + grants_beyond(&path, planned), + "{current:o} beyond {planned:o}" + ); + } + } + + #[test] + fn already_in_place_still_writes_a_file_removed_after_the_observed_check() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("gone"); + let observed = BTreeMap::from([(path.clone(), Some(b"hello\n".to_vec()))]); + assert!( + !already_in_place(&path, b"hello\n", 0o644, &observed), + "a file that no longer exists must be written" + ); + } + + #[test] + fn identical_write_is_skipped_same_inode_and_mtime() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("file.txt"); + + let plan = ReconcilePlan::default(); + plan.write_file(&path, b"hello\n", 0o644).unwrap(); + plan.apply().unwrap(); + let before = fs::metadata(&path).unwrap(); + let before_mtime = before.modified().unwrap(); + #[cfg(unix)] + let before_ino = { + use std::os::unix::fs::MetadataExt; + before.ino() + }; + + // A second, identical plan must not touch the file at all. + let plan = ReconcilePlan::default(); + plan.write_file(&path, b"hello\n", 0o644).unwrap(); + plan.apply().unwrap(); + + let after = fs::metadata(&path).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!( + after.ino(), + before_ino, + "an identical write must not replace the file" + ); + } + assert_eq!( + after.modified().unwrap(), + before_mtime, + "an identical write must not touch the file's mtime" + ); + assert_eq!(fs::read(&path).unwrap(), b"hello\n"); + } + + #[test] + #[cfg(unix)] + fn same_bytes_at_a_looser_mode_are_rewritten_at_the_planned_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("file.txt"); + fs::write(&path, b"hello\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o664)).unwrap(); + + let plan = ReconcilePlan::default(); + plan.write_file(&path, b"hello\n", 0o600).unwrap(); + plan.apply().unwrap(); + + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "a file looser than planned must be tightened even with identical bytes" + ); + assert_eq!(fs::read(&path).unwrap(), b"hello\n"); + } + + #[test] + #[cfg(unix)] + fn same_bytes_at_a_stricter_mode_are_left() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("file.txt"); + fs::write(&path, b"hello\n").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let before_ino = fs::metadata(&path).unwrap().ino(); + + let plan = ReconcilePlan::default(); + plan.write_file(&path, b"hello\n", 0o644).unwrap(); + plan.apply().unwrap(); + + let after = fs::metadata(&path).unwrap(); + assert_eq!( + after.ino(), + before_ino, + "a file stricter than planned must not be rewritten" + ); + assert_eq!( + after.permissions().mode() & 0o777, + 0o600, + "a stricter mode must not be loosened to match the plan" + ); + } + + #[test] + fn removal_whose_file_vanished_before_apply_fails_the_observed_check_naming_the_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("file.txt"); + fs::write(&path, b"hello\n").unwrap(); + + let plan = ReconcilePlan::default(); + plan.remove_file(&path).unwrap(); + fs::remove_file(&path).unwrap(); + + let error = plan.apply().unwrap_err(); + let message = error.to_string(); + assert!( + message.contains(&path.display().to_string()), + "error must name the path: {message}" + ); + assert!( + message.contains("changed since reconciliation was planned"), + "unexpected error: {message}" + ); + } +} diff --git a/crates/agent/src/remote.rs b/crates/agent/src/remote.rs index d528d11a..4b41a456 100644 --- a/crates/agent/src/remote.rs +++ b/crates/agent/src/remote.rs @@ -30,9 +30,9 @@ use agentdesktop_core::{ }; use agentdesktop_proto::fleet::{ AgentMessage, ConfigState, ConfigStatus, Discovery, Heartbeat, Hello, Inventory, - LlmGatewayCredentialRequest, RenewDeviceCertificateRequest, SessionNewEvent, TelemetryEvent, - ToolUseEvent, agent_message, controller_message, fleet_agent_client::FleetAgentClient, - telemetry_event, + LlmGatewayCredentialRequest, ProgramState as ProtoProgramState, ProgramStatus, + RenewDeviceCertificateRequest, SessionNewEvent, TelemetryEvent, ToolUseEvent, agent_message, + controller_message, fleet_agent_client::FleetAgentClient, telemetry_event, }; use crate::{ @@ -42,6 +42,7 @@ use crate::{ oidc, reconcile::Reconciler, secure_fs, + tick::{CurrentConfig, TickStatus}, }; static OAUTH_REFRESH_MUTEX: Mutex<()> = Mutex::const_new(()); @@ -59,6 +60,17 @@ pub struct LogoutRequest { pub struct Requests { pub telemetry: mpsc::Receiver, pub logout: mpsc::Receiver, + /// The daemon's current configuration, replaced by each pushed + /// configuration that parses (read by the reconcile tick). + pub(crate) current: watch::Sender>, + /// Reconcile tick outcomes, when the tick is enabled. + pub(crate) tick_statuses: Option>>, +} + +/// The configuration channels shared with the reconcile tick. +struct ConfigChannels { + current: watch::Sender>, + tick_statuses: Option>>, } /// Tracks whether the daemon's connection to the controller is currently @@ -152,7 +164,13 @@ pub async fn run( let Requests { mut telemetry, mut logout, + current, + tick_statuses, } = requests; + let mut channels = ConfigChannels { + current, + tick_statuses, + }; let mut delay = INITIAL_RETRY_DELAY; loop { let started = time::Instant::now(); @@ -166,6 +184,7 @@ pub async fn run( &controller_status, &mut telemetry, &mut logout, + &mut channels, ) .await; // A session that stayed up for a while failed for a new reason; do not @@ -191,6 +210,7 @@ pub async fn run( &state_dir.join("identity.json"), &enrollment, &controller_status, + &channels.current, ) .await; delay = INITIAL_RETRY_DELAY; @@ -204,6 +224,7 @@ async fn logout_between_sessions( identity_path: &Path, enrollment: &EnrollmentState, controller_status: &ControllerConnectionState, + current: &watch::Sender>, ) { match identity::load(identity_path) { Ok(Some(identity)) => { @@ -213,6 +234,7 @@ async fn logout_between_sessions( &identity, enrollment, controller_status, + current, ) .await; } @@ -259,6 +281,7 @@ async fn run_session( controller_status: &ControllerConnectionState, telemetry: &mut mpsc::Receiver, logout: &mut mpsc::Receiver, + channels: &mut ConfigChannels, ) -> anyhow::Result { let identity_path = state_dir.join("identity.json"); loop { @@ -302,7 +325,7 @@ async fn run_session( let refresh_result = tokio::select! { result = refresh_oauth_if_needed(&mut identity, &identity_path) => result, Some(request) = logout.recv() => { - if complete_logout(request, &identity_path, &identity, enrollment, controller_status).await { + if complete_logout(request, &identity_path, &identity, enrollment, controller_status, &channels.current).await { break; } continue; @@ -342,6 +365,7 @@ async fn run_session( &identity, enrollment, controller_status, + &channels.current, ) .await { @@ -376,9 +400,10 @@ async fn run_session( reconciler, telemetry, controller_status, + channels, ) => Some(result), Some(request) = logout.recv() => { - if complete_logout(request, &identity_path, &identity, enrollment, controller_status).await { + if complete_logout(request, &identity_path, &identity, enrollment, controller_status, &channels.current).await { None } else { continue; @@ -429,6 +454,7 @@ async fn run_session( &identity, enrollment, controller_status, + &channels.current, ) .await { @@ -448,11 +474,12 @@ async fn wait_before_retry( identity: &Identity, enrollment: &EnrollmentState, controller_status: &ControllerConnectionState, + current: &watch::Sender>, ) -> bool { tokio::select! { _ = time::sleep(delay) => false, Some(request) = logout.recv() => { - complete_logout(request, identity_path, identity, enrollment, controller_status).await + complete_logout(request, identity_path, identity, enrollment, controller_status, current).await } } } @@ -514,10 +541,14 @@ async fn complete_logout( identity: &Identity, enrollment: &EnrollmentState, controller_status: &ControllerConnectionState, + current: &watch::Sender>, ) -> bool { let result = identity::delete(identity_path, &identity.device_id) .map_err(|error| format!("remove local organization identity: {error:#}")); if result.is_ok() { + // The organization's configuration is no longer enforced by the + // reconcile tick; the managed files stay until the next configuration. + current.send_replace(None); enrollment.set("starting").await; controller_status.reset().await; info!(device_id = %identity.device_id, "logged out local organization session"); @@ -594,6 +625,7 @@ pub async fn llm_gateway_credential( }) } +#[allow(clippy::too_many_arguments)] async fn connect( controller: &ControllerConnectionConfig, identity: &Identity, @@ -602,8 +634,19 @@ async fn connect( reconciler: &Reconciler, telemetry: &mut mpsc::Receiver, controller_status: &ControllerConnectionState, + channels: &mut ConfigChannels, ) -> anyhow::Result<()> { let mut client = client(controller, Some(identity)).await?; + // Tick outcomes produced before this stream are not sent on it; the + // controller's configuration push on connect reports anyway. + if let Some(statuses) = channels.tick_statuses.as_mut() { + statuses.borrow_and_update(); + } + // The revision of the last configuration received on this stream and the + // last status sent on it, for deciding whether a tick outcome is news. + let mut stream_revision = None; + let mut last_sent: Option = None; + let mut pending_persist: Option<(u64, Vec)> = None; let (sender, receiver) = mpsc::channel(16); let mut request = Request::new(ReceiverStream::new(receiver)); authenticate_request(identity, &mut request)?; @@ -658,6 +701,27 @@ async fn connect( snapshot = next_inventory(discovered) => { send_inventory(&sender, &snapshot).await?; } + tick = next_tick_status(&mut channels.tick_statuses) => { + let mut tick = tick; + if tick.error.is_none() + && tick.revision.is_some() + && tick.revision == stream_revision + && let Some((revision, yaml)) = pending_persist.take() + { + // A failed save stays visible: the tick is reported as + // failed and the save is retried on the next tick. + if let Err(error) = persist_config(state_dir, &yaml, revision) { + warn!(revision, error = %format!("{error:#}"), "saving the configuration after a reconcile tick failed; retrying on the next tick"); + tick.error = Some(format!("{error:#}")); + pending_persist = Some((revision, yaml)); + } + } + if let Some(status) = tick_config_status(last_sent.as_ref(), stream_revision, &tick) { + info!(revision = status.revision, "reporting a changed reconcile outcome"); + last_sent = Some(status.clone()); + send(&sender, agent_message::Message::ConfigStatus(status)).await?; + } + } message = inbound.message() => { let Some(message) = message.context("read controller stream")? else { return Ok(()); @@ -669,7 +733,16 @@ async fn connect( bytes = config.yaml.len(), "received daemon configuration" ); - let status = apply_daemon_config(state_dir, config, reconciler); + stream_revision = Some(config.revision); + let parsed = replace_current_config(&channels.current, &config); + let parsed_ok = parsed.is_ok(); + let status = apply_parsed(state_dir, &config, parsed, reconciler); + // A parsed configuration whose apply failed is the current + // one but not yet saved; a later tick that applies it + // saves it, so a restart does not go back to the previous one. + pending_persist = (parsed_ok && !status.error.is_empty()) + .then(|| (config.revision, config.yaml.clone())); + last_sent = Some(status.clone()); if status.error.is_empty() { info!(revision = status.revision, "applied daemon configuration"); } else { @@ -716,52 +789,160 @@ fn telemetry_to_proto(event: ModelTelemetryEvent) -> TelemetryEvent { } } +/// Parses and applies a pushed configuration in one step (the connection +/// loop parses first to update the current configuration). +#[cfg(test)] fn apply_daemon_config( state_dir: &Path, config: agentdesktop_proto::fleet::DaemonConfig, reconciler: &Reconciler, ) -> ConfigStatus { - let result = (|| -> anyhow::Result<()> { - let actual_hash = Sha256::digest(&config.yaml); - if actual_hash.as_slice() != config.sha256 { - bail!("configuration hash does not match payload"); - } - debug!( - revision = config.revision, - "verified daemon configuration hash" - ); + let parsed = parse_pushed_config(&config); + apply_parsed(state_dir, &config, parsed, reconciler) +} - let yaml = std::str::from_utf8(&config.yaml).context("configuration is not UTF-8")?; - let daemon_config = config::parse_daemon(yaml)?; +/// Applies a pushed configuration already checked by [`parse_pushed_config`] +/// and persists it once its apply succeeded. +fn apply_parsed( + state_dir: &Path, + config: &agentdesktop_proto::fleet::DaemonConfig, + parsed: anyhow::Result, + reconciler: &Reconciler, +) -> ConfigStatus { + let mut programs = Vec::new(); + let result = (|| -> anyhow::Result<()> { + let daemon_config = parsed?; debug!(revision = config.revision, "parsed daemon configuration"); - reconciler.apply(&daemon_config)?; - secure_fs::ensure_private_dir(state_dir)?; - let path = state_dir.join("remote-config.yaml"); - secure_fs::atomic_write(&path, &config.yaml, 0o600)?; + let (report, applied) = reconciler.apply_with_report(&daemon_config); + report.log(); + programs = program_statuses(&report); + applied?; + persist_config(state_dir, &config.yaml, config.revision)?; // Managed gateway policy that asks for device authorization has to start its // sign-in here: the startup path only sees the local file. Idempotent, so a // repeated or unchanged update returns the pending grant instead of a new one. gateway_oidc::start_device_login_if_configured(&daemon_config, state_dir); - info!( - revision = config.revision, - path = %path.display(), - "persisted daemon configuration" - ); Ok(()) })(); + // `programs_reported` says this agent reports per-program status; the + // list is empty when the configuration was not applied at all (hash or + // parse error), and holds the apply's outcomes otherwise, also when a + // later step (persisting the configuration) failed. + config_status( + config.revision, + programs, + result.map_err(|error| format!("{error:#}")), + ) +} - match result { - Ok(()) => ConfigStatus { - revision: config.revision, - state: ConfigState::Applied.into(), - error: String::new(), - }, - Err(error) => ConfigStatus { - revision: config.revision, - state: ConfigState::Failed.into(), - error: format!("{error:#}"), - }, +/// Saves the controller's configuration as the one to restore at startup. +fn persist_config(state_dir: &Path, yaml: &[u8], revision: u64) -> anyhow::Result<()> { + secure_fs::ensure_private_dir(state_dir)?; + let path = state_dir.join("remote-config.yaml"); + secure_fs::atomic_write(&path, yaml, 0o600)?; + info!(revision, path = %path.display(), "persisted daemon configuration"); + Ok(()) +} + +fn program_statuses(report: &crate::reconcile::ApplyReport) -> Vec { + report + .programs + .iter() + .map(|outcome| ProgramStatus { + program: outcome.program.to_owned(), + state: program_state_proto(outcome.state).into(), + detail: outcome.detail.clone(), + }) + .collect() +} + +fn config_status( + revision: u64, + programs: Vec, + result: Result<(), String>, +) -> ConfigStatus { + let (state, error) = match result { + Ok(()) => (ConfigState::Applied, String::new()), + Err(error) => (ConfigState::Failed, error), + }; + ConfigStatus { + revision, + state: state.into(), + error, + programs, + programs_reported: true, + } +} + +/// The proto value of a program's outcome. +fn program_state_proto(state: crate::reconcile::ProgramState) -> ProtoProgramState { + use crate::reconcile::ProgramState; + match state { + ProgramState::Applied => ProtoProgramState::Applied, + ProgramState::Unchanged => ProtoProgramState::Unchanged, + ProgramState::Removed => ProtoProgramState::Removed, + ProgramState::Conflict => ProtoProgramState::Conflict, + ProgramState::Inactive => ProtoProgramState::Inactive, + ProgramState::Blocked => ProtoProgramState::Blocked, + ProgramState::Failed => ProtoProgramState::Failed, + } +} + +/// The `ConfigStatus` a tick's outcome should send on this stream, if any. +/// A status is sent only when the tick's revision is `Some` and +/// equals the revision of the last `DaemonConfig` received on this stream, +/// and the device state, the error, or any program's `(program, state, +/// detail)` differs from `last_sent` (the last `ConfigStatus` sent on this +/// stream, whether by a push or an earlier tick), where a program going from +/// `applied` or `removed` to `unchanged` is not a difference. A failed tick +/// is always reported (when its revision matches) as `FAILED` with the +/// error and `programs_reported: true`. +pub(crate) fn tick_config_status( + last_sent: Option<&ConfigStatus>, + stream_revision: Option, + tick: &TickStatus, +) -> Option { + if tick.revision.is_none() || tick.revision != stream_revision { + return None; } + let status = config_status( + tick.revision.unwrap_or_default(), + program_statuses(&tick.report), + tick.error.clone().map_or(Ok(()), Err), + ); + let Some(last_sent) = last_sent else { + return Some(status); + }; + let differs = last_sent.state != status.state + || last_sent.error != status.error + || crate::reconcile::outcomes_differ( + &status_keys(&last_sent.programs), + &status_keys(&status.programs), + ); + differs.then_some(status) +} + +/// `(program, state, detail)` of each reported program, for +/// [`crate::reconcile::outcomes_differ`]. +fn status_keys(programs: &[ProgramStatus]) -> Vec<(&str, crate::reconcile::ProgramState, &str)> { + use crate::reconcile::ProgramState; + programs + .iter() + .map(|program| { + let state = match ProtoProgramState::try_from(program.state) { + Ok(ProtoProgramState::Applied) => ProgramState::Applied, + Ok(ProtoProgramState::Unchanged) => ProgramState::Unchanged, + Ok(ProtoProgramState::Removed) => ProgramState::Removed, + Ok(ProtoProgramState::Conflict) => ProgramState::Conflict, + Ok(ProtoProgramState::Inactive) => ProgramState::Inactive, + Ok(ProtoProgramState::Blocked) => ProgramState::Blocked, + Ok(ProtoProgramState::Failed | ProtoProgramState::Unspecified) | Err(_) => { + ProgramState::Failed + } + }; + (program.program.as_str(), state, program.detail.as_str()) + }) + .collect() } /// Sends the current inventory snapshot to the controller. @@ -831,6 +1012,52 @@ async fn send_inventory( Ok(()) } +/// The next reconcile tick outcome; never resolves when the tick is off or +/// has stopped. +async fn next_tick_status( + statuses: &mut Option>>, +) -> TickStatus { + if let Some(statuses) = statuses.as_mut() { + while statuses.changed().await.is_ok() { + if let Some(tick) = statuses.borrow_and_update().clone() { + return tick; + } + } + } + std::future::pending().await +} + +/// Makes a pushed configuration the current one for the reconcile tick if it +/// parses, whether or not its apply then succeeds; a push that does not +/// parse leaves the current configuration as it is. +fn replace_current_config( + current: &watch::Sender>, + config: &agentdesktop_proto::fleet::DaemonConfig, +) -> anyhow::Result { + let parsed = parse_pushed_config(config)?; + current.send_replace(Some(CurrentConfig { + revision: Some(config.revision), + config: Arc::new(parsed.clone()), + })); + Ok(parsed) +} + +/// Hash, UTF-8 and parse checks of a pushed configuration. +fn parse_pushed_config( + config: &agentdesktop_proto::fleet::DaemonConfig, +) -> anyhow::Result { + let actual_hash = Sha256::digest(&config.yaml); + if actual_hash.as_slice() != config.sha256 { + bail!("configuration hash does not match payload"); + } + debug!( + revision = config.revision, + "verified daemon configuration hash" + ); + let yaml = std::str::from_utf8(&config.yaml).context("configuration is not UTF-8")?; + config::parse_daemon(yaml) +} + /// Resolves with the next inventory snapshot, and never resolves once the /// refresher has stopped, so the controller stream keeps running without it. async fn next_inventory( @@ -1301,4 +1528,248 @@ mod tests { ControllerConnectionState::new().get().await ); } + // --- Per-program configuration status ----------------------------------- + + fn test_reconciler(root: &std::path::Path) -> crate::reconcile::Reconciler { + crate::reconcile::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")), + None, + None, + root.join("bin/agentdesktop"), + root.join("agentdesktop.sock"), + ) + } + + #[test] + fn hash_error_reports_empty_programs_and_programs_reported_true() { + let state_dir = std::env::temp_dir().join(format!( + "agentdesktop-remote-hash-error-{}-{}", + std::process::id(), + rand::random::() + )); + let reconciler = test_reconciler(&state_dir); + let config = agentdesktop_proto::fleet::DaemonConfig { + revision: 7, + yaml: b"programs: {}\n".to_vec(), + sha256: vec![0u8; 32], + }; + + let status = super::apply_daemon_config(&state_dir, config, &reconciler); + + assert_eq!( + status.state, + agentdesktop_proto::fleet::ConfigState::Failed as i32 + ); + assert!(status.error.contains("hash")); + assert!(status.programs.is_empty()); + assert!(status.programs_reported); + let _ = std::fs::remove_dir_all(&state_dir); + } + + #[test] + fn mapping_every_program_state_to_the_proto_enum() { + use crate::reconcile::ProgramState; + use agentdesktop_proto::fleet::ProgramState as ProtoProgramState; + + let cases = [ + (ProgramState::Applied, ProtoProgramState::Applied), + (ProgramState::Unchanged, ProtoProgramState::Unchanged), + (ProgramState::Removed, ProtoProgramState::Removed), + (ProgramState::Conflict, ProtoProgramState::Conflict), + (ProgramState::Inactive, ProtoProgramState::Inactive), + (ProgramState::Blocked, ProtoProgramState::Blocked), + (ProgramState::Failed, ProtoProgramState::Failed), + ]; + for (state, expected) in cases { + assert_eq!(super::program_state_proto(state), expected, "{state:?}"); + } + } + + // --- Tick reporting dedup -------------------------------------------------- + + fn program_report( + state: crate::reconcile::ProgramState, + detail: &str, + ) -> crate::reconcile::ApplyReport { + crate::reconcile::ApplyReport { + programs: vec![crate::reconcile::ProgramOutcome { + program: "claude-code", + state, + detail: detail.to_owned(), + }], + } + } + + fn sent_status(report: &crate::reconcile::ApplyReport, revision: u64) -> super::ConfigStatus { + use super::{ConfigState, ConfigStatus, ProgramStatus}; + ConfigStatus { + revision, + state: ConfigState::Applied.into(), + error: String::new(), + programs: report + .programs + .iter() + .map(|outcome| ProgramStatus { + program: outcome.program.to_owned(), + state: super::program_state_proto(outcome.state).into(), + detail: outcome.detail.clone(), + }) + .collect(), + programs_reported: true, + } + } + + fn tick_status( + revision: Option, + report: crate::reconcile::ApplyReport, + error: Option<&str>, + ) -> crate::tick::TickStatus { + use crate::tick::TickStatus; + TickStatus { + revision, + report, + error: error.map(str::to_owned), + } + } + + #[test] + fn tick_config_status_is_none_when_the_revision_is_none_or_mismatched() { + use crate::reconcile::ProgramState; + let previous = program_report(ProgramState::Unchanged, ""); + let last_sent = sent_status(&previous, 3); + let current = program_report(ProgramState::Failed, "boom"); + + assert!( + super::tick_config_status( + Some(&last_sent), + Some(3), + &tick_status(None, current.clone(), None) + ) + .is_none(), + "a tick with no revision is never reported" + ); + assert!( + super::tick_config_status( + Some(&last_sent), + Some(3), + &tick_status(Some(2), current, None) + ) + .is_none(), + "a tick whose revision does not match the stream's last push is never reported" + ); + } + + #[test] + fn tick_config_status_is_none_when_nothing_differs() { + use crate::reconcile::ProgramState; + let report = program_report(ProgramState::Unchanged, ""); + let last_sent = sent_status(&report, 4); + assert!( + super::tick_config_status( + Some(&last_sent), + Some(4), + &tick_status(Some(4), report, None) + ) + .is_none() + ); + } + + #[test] + fn tick_config_status_is_none_when_applied_or_removed_settles_to_unchanged() { + use crate::reconcile::ProgramState; + for state in [ProgramState::Applied, ProgramState::Removed] { + let previous = program_report(state, ""); + let last_sent = sent_status(&previous, 5); + let current = program_report(ProgramState::Unchanged, ""); + assert!( + super::tick_config_status( + Some(&last_sent), + Some(5), + &tick_status(Some(5), current, None) + ) + .is_none(), + "{state:?} -> Unchanged must not be reported" + ); + } + } + + #[test] + fn tick_config_status_is_some_when_a_programs_detail_changes() { + use crate::reconcile::ProgramState; + let previous = program_report(ProgramState::Conflict, "conflict at a"); + let last_sent = sent_status(&previous, 6); + let current = program_report(ProgramState::Conflict, "conflict at b"); + + let status = super::tick_config_status( + Some(&last_sent), + Some(6), + &tick_status(Some(6), current, None), + ) + .expect("a changed detail must be reported"); + assert_eq!(status.revision, 6); + assert_eq!(status.state, super::ConfigState::Applied as i32); + assert!(status.programs_reported); + } + + #[test] + fn tick_config_status_is_some_with_no_baseline_on_this_stream() { + use crate::reconcile::ProgramState; + let current = program_report(ProgramState::Applied, ""); + let status = super::tick_config_status(None, Some(1), &tick_status(Some(1), current, None)) + .expect( + "nothing sent yet on this stream must still be reported once the revision matches", + ); + assert_eq!(status.revision, 1); + } + + #[test] + fn tick_config_status_reports_a_failed_tick_as_failed_with_programs_reported() { + use crate::reconcile::ProgramState; + let previous = program_report(ProgramState::Unchanged, ""); + let last_sent = sent_status(&previous, 7); + let current = program_report(ProgramState::Unchanged, ""); + + let status = super::tick_config_status( + Some(&last_sent), + Some(7), + &tick_status(Some(7), current, Some("disk full")), + ) + .expect("a failed tick must be reported when its revision matches"); + assert_eq!(status.revision, 7); + assert_eq!(status.state, super::ConfigState::Failed as i32); + assert_eq!(status.error, "disk full"); + assert!(status.programs_reported); + } + + #[test] + fn only_a_push_that_parses_replaces_the_current_configuration() { + use sha2::{Digest, Sha256}; + let pushed = |revision: u64, yaml: &str| agentdesktop_proto::fleet::DaemonConfig { + revision, + yaml: yaml.as_bytes().to_vec(), + sha256: Sha256::digest(yaml.as_bytes()).to_vec(), + }; + let (current, receiver) = tokio::sync::watch::channel(None); + let _ = super::replace_current_config(¤t, &pushed(1, "programs: {}\n")); + assert_eq!(receiver.borrow().as_ref().and_then(|c| c.revision), Some(1)); + // Does not parse (unknown key): the current configuration stays. + let _ = super::replace_current_config(¤t, &pushed(2, "notAKey: 1\n")); + assert_eq!(receiver.borrow().as_ref().and_then(|c| c.revision), Some(1)); + // Wrong hash: stays. + let mut tampered = pushed(3, "programs: {}\n"); + tampered.sha256 = vec![0; 32]; + let _ = super::replace_current_config(¤t, &tampered); + assert_eq!(receiver.borrow().as_ref().and_then(|c| c.revision), Some(1)); + // Parses (its apply may still fail later): replaced. + let _ = super::replace_current_config(¤t, &pushed(4, "programs:\n grok: {}\n")); + assert_eq!(receiver.borrow().as_ref().and_then(|c| c.revision), Some(4)); + } } diff --git a/crates/agent/src/tick.rs b/crates/agent/src/tick.rs new file mode 100644 index 00000000..ca41f821 --- /dev/null +++ b/crates/agent/src/tick.rs @@ -0,0 +1,323 @@ +//! Periodic re-apply of the daemon's current configuration (opt-in, +//! `daemon.reconcileInterval`). +//! +//! A managed file deleted or edited by hand, a conflict the user has fixed, +//! or a managed file whose mode was loosened stay as they are until the next +//! config push or restart. [`run_tick`] repairs that by re-applying the +//! current configuration every `reconcileInterval`, without rewriting +//! anything unchanged (see [`crate::reconcile::plan`]). + +use std::{sync::Arc, time::Duration}; + +use agentdesktop_core::config::DaemonConfig; +use tokio::sync::watch; + +use crate::reconcile::{ApplyReport, Reconciler}; + +/// The daemon's current configuration: the startup configuration +/// (`revision: None`), or the latest pushed configuration that passed hash, +/// UTF-8 and parse (`revision: Some(rev)`), whether its apply then succeeded +/// or failed. A push that does not parse leaves this unchanged. +#[derive(Clone)] +pub(crate) struct CurrentConfig { + pub(crate) revision: Option, + pub(crate) config: Arc, +} + +/// One tick's outcome, forwarded to the connection loop for reporting. A +/// tick never persists anything or changes the current configuration. +#[derive(Clone)] +pub(crate) struct TickStatus { + /// The revision of the configuration this tick applied, read with it + /// under the apply lock; the connection loop reports and saves the + /// outcome only when it matches the current stream revision. + pub(crate) revision: Option, + pub(crate) report: ApplyReport, + pub(crate) error: Option, +} + +/// Applies `current`'s configuration every `interval`, first one interval +/// after start (`interval_at`, `MissedTickBehavior::Delay`), skipping a tick +/// while `current` holds `None`. Stops only when `current`'s sender is +/// dropped (daemon shutdown). `interval` must be greater than zero; a zero +/// interval is refused (logged) rather than scheduled. +pub(crate) async fn run_tick_with( + current: watch::Receiver>, + interval: Duration, + statuses: Option>>, + apply: F, +) where + F: FnMut(&DaemonConfig) -> Option<(Option, ApplyReport, anyhow::Result<()>)>, +{ + if interval.is_zero() { + tracing::warn!("reconcile interval is zero; not scheduling periodic re-applies"); + return; + } + let mut current = current; + let mut apply = apply; + let mut ticks = tokio::time::interval_at(tokio::time::Instant::now() + interval, interval); + ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + tokio::select! { + _ = ticks.tick() => {} + changed = current.changed() => { + // A new configuration is picked up at the next tick; a + // dropped sender (daemon shutdown) ends the loop. + if changed.is_err() { + return; + } + continue; + } + } + let Some(snapshot) = current.borrow_and_update().clone() else { + continue; + }; + // `None`: the closure applied nothing, so nothing is published. + let Some((revision, report, result)) = apply(&snapshot.config) else { + continue; + }; + if let Some(statuses) = &statuses { + statuses.send_replace(Some(TickStatus { + revision, + report, + error: result.err().map(|error| format!("{error:#}")), + })); + } + } +} + +/// [`run_tick_with`] applying through a real [`Reconciler`]. +pub(crate) async fn run_tick( + reconciler: Reconciler, + current: watch::Receiver>, + interval: Duration, + statuses: Option>>, +) { + let latest = current.clone(); + run_tick_with(current, interval, statuses, move |_| { + // Read again under the apply lock: a push that took the lock first + // may have replaced the configuration this tick woke up with. + let (revision, previous, report, result) = reconciler.apply_read_under_lock(|| { + latest + .borrow() + .as_ref() + .map(|current| (current.revision, Arc::clone(¤t.config))) + })?; + // Outcome lines and the failure warning only when something changed, + // so a conflict that persists does not repeat every interval. + if crate::reconcile::should_log(previous.as_ref(), &report) { + report.log(); + if let Err(error) = &result { + tracing::warn!(error = %format!("{error:#}"), "reconcile tick failed"); + } + } + Some((revision, report, result)) + }) + .await +} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use agentdesktop_core::config::DaemonConfig; + use tokio::{sync::watch, time::Duration}; + + use super::{CurrentConfig, run_tick_with}; + use crate::reconcile::ApplyReport; + + fn current_config(revision: Option) -> CurrentConfig { + CurrentConfig { + revision, + config: Arc::new(DaemonConfig::default()), + } + } + + fn counting( + count: Arc, + ) -> impl FnMut(&DaemonConfig) -> Option<(Option, ApplyReport, anyhow::Result<()>)> { + move |_config| { + count.fetch_add(1, Ordering::SeqCst); + Some((None, ApplyReport::default(), Ok(()))) + } + } + + #[tokio::test(start_paused = true)] + async fn refuses_to_schedule_a_zero_interval() { + let (_current_tx, current_rx) = watch::channel(Some(current_config(None))); + run_tick_with(current_rx, Duration::ZERO, None, |_: &DaemonConfig| { + panic!("a zero interval must not apply") + }) + .await; + } + + #[tokio::test(start_paused = true)] + async fn nothing_before_one_interval_then_one_apply_per_interval() { + let (_current_tx, current_rx) = watch::channel(Some(current_config(None))); + let (status_tx, mut status_rx) = watch::channel(None); + let count = Arc::new(AtomicUsize::new(0)); + let _ticker = tokio::spawn(run_tick_with( + current_rx, + Duration::from_secs(60), + Some(status_tx), + counting(Arc::clone(&count)), + )); + + assert!( + tokio::time::timeout(Duration::from_secs(59), status_rx.changed()) + .await + .is_err(), + "must not apply before one interval elapses" + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + + status_rx.changed().await.unwrap(); + assert_eq!( + count.load(Ordering::SeqCst), + 1, + "one apply after one interval" + ); + + status_rx.changed().await.unwrap(); + assert_eq!(count.load(Ordering::SeqCst), 2, "one apply per interval"); + } + + #[tokio::test(start_paused = true)] + async fn skips_ticks_while_the_current_configuration_is_none() { + let (current_tx, current_rx) = watch::channel(None); + let count = Arc::new(AtomicUsize::new(0)); + let ticker = tokio::spawn(run_tick_with( + current_rx, + Duration::from_secs(60), + None, + counting(Arc::clone(&count)), + )); + + tokio::time::sleep(Duration::from_secs(180)).await; + assert_eq!( + count.load(Ordering::SeqCst), + 0, + "no tick applies while the current configuration is None" + ); + + current_tx.send(Some(current_config(None))).unwrap(); + tokio::time::sleep(Duration::from_secs(70)).await; + assert!( + count.load(Ordering::SeqCst) >= 1, + "a tick applies once a configuration is set" + ); + ticker.abort(); + } + + #[tokio::test(start_paused = true)] + async fn stops_when_the_current_configuration_sender_is_dropped() { + let (current_tx, current_rx) = watch::channel(Some(current_config(None))); + let count = Arc::new(AtomicUsize::new(0)); + let ticker = tokio::spawn(run_tick_with( + current_rx, + Duration::from_secs(60), + None, + counting(Arc::clone(&count)), + )); + drop(current_tx); + + assert!( + tokio::time::timeout(Duration::from_secs(600), ticker) + .await + .expect("run_tick_with must stop once the sender is dropped") + .is_ok() + ); + } + + #[tokio::test(start_paused = true)] + async fn a_failed_apply_forwards_the_error_and_revision() { + let (_current_tx, current_rx) = watch::channel(Some(current_config(Some(9)))); + let (status_tx, mut status_rx) = watch::channel(None); + let _ticker = tokio::spawn(run_tick_with( + current_rx, + Duration::from_secs(30), + Some(status_tx), + |_: &DaemonConfig| { + Some(( + Some(9), + ApplyReport::default(), + Err(anyhow::anyhow!("disk full")), + )) + }, + )); + + status_rx.changed().await.unwrap(); + let status = status_rx + .borrow_and_update() + .clone() + .expect("a tick status must be published"); + assert_eq!(status.revision, Some(9)); + assert!( + status + .error + .as_deref() + .unwrap_or_default() + .contains("disk full"), + "{:?}", + status.error + ); + } + + #[tokio::test(start_paused = true)] + async fn a_tick_reports_the_revision_the_apply_returns() { + // The snapshot the tick woke up with is revision 3; the apply (which + // re-reads under the lock) applied revision 5. + let (_current_tx, current_rx) = watch::channel(Some(current_config(Some(3)))); + let (status_tx, mut status_rx) = watch::channel(None); + let _ticker = tokio::spawn(run_tick_with( + current_rx, + Duration::from_secs(30), + Some(status_tx), + |_: &DaemonConfig| Some((Some(5), ApplyReport::default(), Ok(()))), + )); + + status_rx.changed().await.unwrap(); + let status = status_rx + .borrow_and_update() + .clone() + .expect("a tick status must be published"); + assert_eq!( + status.revision, + Some(5), + "the published revision is the one the apply returned, not the snapshot's" + ); + assert!(status.error.is_none()); + } + + #[tokio::test(start_paused = true)] + async fn no_status_when_the_apply_returns_none() { + let (_current_tx, current_rx) = watch::channel(Some(current_config(Some(3)))); + let (status_tx, mut status_rx) = watch::channel(None); + let count = Arc::new(AtomicUsize::new(0)); + let applied = Arc::clone(&count); + let _ticker = tokio::spawn(run_tick_with( + current_rx, + Duration::from_secs(30), + Some(status_tx), + move |_: &DaemonConfig| { + applied.fetch_add(1, Ordering::SeqCst); + None + }, + )); + + assert!( + tokio::time::timeout(Duration::from_secs(100), status_rx.changed()) + .await + .is_err(), + "a closure returning None must publish nothing" + ); + assert!( + count.load(Ordering::SeqCst) >= 3, + "the closure must have run on each tick" + ); + assert!(status_rx.borrow().is_none()); + } +} 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/controller/migrations/0003_program_status.sql b/crates/controller/migrations/0003_program_status.sql new file mode 100644 index 00000000..1fd4c00a --- /dev/null +++ b/crates/controller/migrations/0003_program_status.sql @@ -0,0 +1,14 @@ +CREATE TABLE device_program_status ( + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + program TEXT NOT NULL, + position BIGINT NOT NULL, + revision BIGINT NOT NULL, + state BIGINT NOT NULL, + detail TEXT NOT NULL, + updated_at BIGINT NOT NULL, + PRIMARY KEY (device_id, program) +); + +CREATE INDEX device_program_status_device_id_idx ON device_program_status(device_id); + +ALTER TABLE device_config_status ADD COLUMN programs_reported BIGINT NOT NULL DEFAULT 0; diff --git a/crates/controller/src/database.rs b/crates/controller/src/database.rs index a1deea6e..83aa63fa 100644 --- a/crates/controller/src/database.rs +++ b/crates/controller/src/database.rs @@ -3,7 +3,9 @@ use sqlx::{AnyPool, any::AnyPoolOptions}; use std::{collections::BTreeMap, path::PathBuf}; use agentdesktop_core::model::{LocalModel, McpServer, ModelRuntime, Skill}; -use agentdesktop_proto::fleet::{ConfigStatus, Hello, Inventory, TelemetryEvent, telemetry_event}; +use agentdesktop_proto::fleet::{ + ConfigStatus, Hello, Inventory, ProgramState, TelemetryEvent, telemetry_event, +}; use serde::Serialize; #[derive(Clone)] @@ -116,6 +118,82 @@ pub struct DeviceDetail { pub discoveries: Vec, pub model_runtimes: Vec, pub recent_events: Vec, + /// Per-program configuration status, in the order the agent reported it. + pub programs: Vec, + /// `None` when this device has no configuration status at all; `Some(_)` + /// once one has been reported, reflecting the reporting agent's + /// `ConfigStatus.programs_reported`. + pub programs_reported: Option, +} + +/// Bounds on what an agent reports per program, as for telemetry fields. +pub(crate) fn validate_program_status(status: &ConfigStatus) -> anyhow::Result<()> { + anyhow::ensure!( + status.programs.len() <= 32, + "too many programs in configuration status" + ); + let mut seen = std::collections::BTreeSet::new(); + for program in &status.programs { + anyhow::ensure!( + !program.program.is_empty() + && program.program.len() <= 64 + && program + .program + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'), + "invalid program in configuration status" + ); + anyhow::ensure!( + seen.insert(program.program.as_str()), + "duplicate program in configuration status" + ); + anyhow::ensure!( + program.detail.len() <= 2048, + "program detail too long in configuration status" + ); + } + Ok(()) +} + +/// The stored name of a program state; values this controller does not know +/// are `unspecified`. +fn program_state_name(state: i64) -> &'static str { + match i32::try_from(state) + .ok() + .and_then(|state| ProgramState::try_from(state).ok()) + { + Some(ProgramState::Applied) => "applied", + Some(ProgramState::Unchanged) => "unchanged", + Some(ProgramState::Removed) => "removed", + Some(ProgramState::Conflict) => "conflict", + Some(ProgramState::Inactive) => "inactive", + Some(ProgramState::Blocked) => "blocked", + Some(ProgramState::Failed) => "failed", + Some(ProgramState::Unspecified) | None => "unspecified", + } +} + +#[derive(sqlx::FromRow)] +struct ProgramStatusRow { + program: String, + state: i64, + detail: String, + revision: i64, + updated_at: i64, +} + +/// One managed program's stored configuration status. `revision` is the +/// controller revision the report answered; it does not change across hot +/// reloads of the same revision. +#[derive(Clone, Debug, Serialize)] +pub struct ProgramStatusRecord { + pub program: String, + /// `applied`, `unchanged`, `removed`, `conflict`, `inactive`, `blocked`, + /// `failed`, or `unspecified` for a state this controller does not know. + pub state: String, + pub detail: String, + pub revision: i64, + pub updated_at: i64, } #[derive(sqlx::FromRow)] @@ -316,11 +394,38 @@ impl Database { .map(|discovery: &DeviceDiscovery| discovery.kind.clone()) .collect(); let recent_events = self.recent_telemetry(device_id, 50).await?; + let programs_reported: Option = sqlx::query_scalar( + "SELECT programs_reported FROM device_config_status WHERE device_id = $1", + ) + .bind(device_id) + .fetch_optional(&self.pool) + .await + .context("load configuration status reporting")?; + let rows: Vec = sqlx::query_as( + "SELECT program, state, detail, revision, updated_at FROM device_program_status + WHERE device_id = $1 ORDER BY position ASC", + ) + .bind(device_id) + .fetch_all(&self.pool) + .await + .context("load program configuration status")?; + let programs = rows + .into_iter() + .map(|row| ProgramStatusRecord { + program: row.program, + state: program_state_name(row.state).to_owned(), + detail: row.detail, + revision: row.revision, + updated_at: row.updated_at, + }) + .collect(); Ok(Some(DeviceDetail { device, discoveries, model_runtimes, recent_events, + programs, + programs_reported: programs_reported.map(|reported| reported != 0), })) } @@ -440,7 +545,9 @@ impl Database { Ok(()) } - pub async fn update_config_status( + /// Stores only the device-wide status (revision, state, error) and keeps + /// the program rows and `programs_reported` of the last accepted report. + pub async fn update_device_config_state( &self, device_id: &str, status: &ConfigStatus, @@ -465,6 +572,60 @@ impl Database { Ok(()) } + pub async fn update_config_status( + &self, + device_id: &str, + status: &ConfigStatus, + ) -> anyhow::Result<()> { + validate_program_status(status)?; + let now = unix_time_seconds(); + let revision = i64::try_from(status.revision).unwrap_or(i64::MAX); + let mut transaction = self.pool.begin().await?; + sqlx::query( + "INSERT INTO device_config_status + (device_id, revision, state, error, updated_at, programs_reported) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (device_id) DO UPDATE SET + revision = excluded.revision, + state = excluded.state, + error = excluded.error, + updated_at = excluded.updated_at, + programs_reported = excluded.programs_reported", + ) + .bind(device_id) + .bind(revision) + .bind(i64::from(status.state)) + .bind(&status.error) + .bind(now) + .bind(i64::from(status.programs_reported)) + .execute(&mut *transaction) + .await?; + // The report replaces the previous one: a program missing from it is + // gone from the table. + sqlx::query("DELETE FROM device_program_status WHERE device_id = $1") + .bind(device_id) + .execute(&mut *transaction) + .await?; + for (position, program) in status.programs.iter().enumerate() { + sqlx::query( + "INSERT INTO device_program_status + (device_id, program, position, revision, state, detail, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + ) + .bind(device_id) + .bind(&program.program) + .bind(i64::try_from(position).unwrap_or(i64::MAX)) + .bind(revision) + .bind(i64::from(program.state)) + .bind(&program.detail) + .bind(now) + .execute(&mut *transaction) + .await?; + } + transaction.commit().await?; + Ok(()) + } + pub async fn insert_telemetry( &self, device_id: &str, @@ -570,8 +731,8 @@ mod tests { use std::{collections::BTreeMap, path::PathBuf}; use agentdesktop_proto::fleet::{ - Discovery, Inventory, LocalModel, McpServer, ModelRuntime, Skill, TelemetryEvent, - ToolUseEvent, telemetry_event, + ConfigState, ConfigStatus, Discovery, Inventory, LocalModel, McpServer, ModelRuntime, + ProgramState, ProgramStatus, Skill, TelemetryEvent, ToolUseEvent, telemetry_event, }; use super::Database; @@ -675,4 +836,267 @@ mod tests { drop(database); let _ = std::fs::remove_file(path); } + + // --- Per-program configuration status ----------------------------------- + + async fn test_database(name: &str) -> (Database, PathBuf) { + let path = std::env::temp_dir().join(format!( + "agentdesktop-program-status-{name}-{}-{}.db", + std::process::id(), + rand::random::() + )); + let database = Database::connect(&format!("sqlite://{}?mode=rwc", path.display())) + .await + .expect("connect test database"); + database + .enroll_device("device", "host", "issuer", "subject", None) + .await + .expect("enroll device"); + (database, path) + } + + fn program_status(program: &str, state: ProgramState, detail: &str) -> ProgramStatus { + ProgramStatus { + program: program.to_owned(), + state: state.into(), + detail: detail.to_owned(), + } + } + + fn config_status(programs: Vec, programs_reported: bool) -> ConfigStatus { + ConfigStatus { + revision: 1, + state: ConfigState::Applied.into(), + error: String::new(), + programs, + programs_reported, + } + } + + #[tokio::test] + async fn program_status_rows_are_stored_replaced_and_ordered_by_position() { + let (database, path) = test_database("stores-and-replaces").await; + + database + .update_config_status( + "device", + &config_status( + vec![ + program_status( + "vscode", + ProgramState::Conflict, + "conflict at settings.json", + ), + program_status("claude-code", ProgramState::Applied, ""), + ], + true, + ), + ) + .await + .expect("store the first report"); + let device = database + .get_device("device") + .await + .expect("load device") + .expect("device exists"); + assert_eq!( + device + .programs + .iter() + .map(|program| program.program.as_str()) + .collect::>(), + vec!["vscode", "claude-code"], + "rows come back ordered by position, the order they were reported in" + ); + + // A program missing from the next report is deleted, not left stale. + database + .update_config_status( + "device", + &config_status( + vec![program_status("claude-code", ProgramState::Unchanged, "")], + true, + ), + ) + .await + .expect("store the second report"); + let device = database + .get_device("device") + .await + .expect("load device") + .expect("device exists"); + assert_eq!( + device + .programs + .iter() + .map(|program| program.program.as_str()) + .collect::>(), + vec!["claude-code"], + "vscode was replaced away, not appended to" + ); + + drop(database); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn programs_reported_is_none_without_a_status_and_the_stored_flag_once_one_exists() { + let (database, path) = test_database("programs-reported").await; + + let device = database + .get_device("device") + .await + .expect("load device") + .expect("device exists"); + assert_eq!( + device.programs_reported, None, + "no configuration status has been reported yet" + ); + + database + .update_config_status("device", &config_status(Vec::new(), false)) + .await + .expect("store a status without per-program reporting"); + let device = database + .get_device("device") + .await + .expect("load device") + .expect("device exists"); + assert_eq!( + device.programs_reported, + Some(false), + "an old agent's status carries no programs" + ); + + drop(database); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn update_config_status_rejects_out_of_bounds_program_status() { + let (database, path) = test_database("bounds-rejected").await; + + let too_many = (0..33) + .map(|index| program_status(&format!("program-{index}"), ProgramState::Applied, "")) + .collect(); + assert!( + database + .update_config_status("device", &config_status(too_many, true)) + .await + .is_err(), + "more than 32 programs must be rejected" + ); + + assert!( + database + .update_config_status( + "device", + &config_status( + vec![program_status("Not-Valid", ProgramState::Applied, "")], + true + ), + ) + .await + .is_err(), + "a program outside [a-z0-9-] must be rejected" + ); + + assert!( + database + .update_config_status( + "device", + &config_status( + vec![program_status(&"a".repeat(65), ProgramState::Applied, "")], + true + ), + ) + .await + .is_err(), + "a program longer than 64 bytes must be rejected" + ); + + assert!( + database + .update_config_status( + "device", + &config_status( + vec![program_status( + "claude-code", + ProgramState::Applied, + &"x".repeat(2049) + )], + true + ), + ) + .await + .is_err(), + "a detail longer than 2048 bytes must be rejected" + ); + + drop(database); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn unknown_program_state_round_trips_as_unspecified() { + let (database, path) = test_database("unknown-state").await; + + let mut status = program_status("claude-code", ProgramState::Applied, ""); + status.state = 99; // Not a defined ProgramState value. + database + .update_config_status("device", &config_status(vec![status], true)) + .await + .expect("store a status with a future/unknown state"); + + let device = database + .get_device("device") + .await + .expect("load device") + .expect("device exists"); + assert_eq!(device.programs[0].state, "unspecified"); + + drop(database); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn program_status_rows_cascade_on_device_delete() { + let (database, path) = test_database("cascade-delete").await; + database + .update_config_status( + "device", + &config_status( + vec![program_status("claude-code", ProgramState::Applied, "")], + true, + ), + ) + .await + .expect("store a report"); + + let count_rows = || async { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM device_program_status WHERE device_id = $1", + ) + .bind("device") + .fetch_one(&database.pool) + .await + .expect("count device_program_status rows") + }; + assert_eq!(count_rows().await, 1); + + assert!( + database + .delete_device("device") + .await + .expect("delete device") + ); + assert_eq!( + count_rows().await, + 0, + "device_program_status cascades on device delete" + ); + + drop(database); + let _ = std::fs::remove_file(path); + } } diff --git a/crates/controller/src/service.rs b/crates/controller/src/service.rs index db64a6cd..ed582093 100644 --- a/crates/controller/src/service.rs +++ b/crates/controller/src/service.rs @@ -2,9 +2,9 @@ use std::pin::Pin; use agentdesktop_proto::fleet::{ AgentMessage, BeginEnrollmentRequest, BeginEnrollmentResponse, CompleteEnrollmentRequest, - ControllerMessage, DeviceCertificateResponse, EnrollResponse, LlmGatewayCredentialRequest, - LlmGatewayCredentialResponse, RenewDeviceCertificateRequest, agent_message, controller_message, - fleet_agent_server::FleetAgent, + ConfigStatus, ControllerMessage, DeviceCertificateResponse, EnrollResponse, + LlmGatewayCredentialRequest, LlmGatewayCredentialResponse, RenewDeviceCertificateRequest, + agent_message, controller_message, fleet_agent_server::FleetAgent, }; use futures_core::Stream; use tokio::{sync::mpsc, time}; @@ -484,7 +484,7 @@ async fn handle_agent_message( ); } Some(agent_message::Message::ConfigStatus(status)) => { - database.update_config_status(device_id, &status).await?; + store_config_status(database, device_id, &status).await?; if status.error.is_empty() { info!( device_id, @@ -509,6 +509,27 @@ async fn handle_agent_message( Ok(()) } +/// Stores a device's configuration status. A per-program report the +/// controller rejects must not break the stream (the agent would reconnect, +/// receive the configuration again and send the same report), so the +/// device-wide status is stored and the last accepted program rows stay. +/// Database errors are returned as they are. +async fn store_config_status( + database: &Database, + device_id: &str, + status: &ConfigStatus, +) -> anyhow::Result<()> { + if let Err(error) = crate::database::validate_program_status(status) { + warn!( + device_id, + error = %format!("{error:#}"), + "rejected per-program configuration status; storing the device status and keeping the last accepted program status" + ); + return database.update_device_config_state(device_id, status).await; + } + database.update_config_status(device_id, status).await +} + fn bearer_credential(metadata: &tonic::metadata::MetadataMap) -> Result<&str, Status> { let value = metadata .get("authorization") @@ -658,4 +679,64 @@ mod tests { device_id ); } + + #[tokio::test] + async fn a_rejected_program_report_keeps_the_device_status() { + use agentdesktop_proto::fleet::{ConfigState, ConfigStatus, ProgramState, ProgramStatus}; + let path = std::env::temp_dir().join(format!( + "agentdesktop-rejected-report-{}-{}.db", + std::process::id(), + rand::random::() + )); + let database = Database::connect(&format!("sqlite://{}?mode=rwc", path.display())) + .await + .unwrap(); + database + .enroll_device("device", "host", "issuer", "subject", None) + .await + .unwrap(); + let accepted = ConfigStatus { + revision: 2, + state: ConfigState::Applied.into(), + error: String::new(), + programs: vec![ProgramStatus { + program: "claude-code".to_owned(), + state: ProgramState::Applied.into(), + detail: String::new(), + }], + programs_reported: true, + }; + super::store_config_status(&database, "device", &accepted) + .await + .unwrap(); + let status = ConfigStatus { + revision: 3, + state: ConfigState::Failed.into(), + error: "refusing to change VS Code settings".to_owned(), + programs: vec![ProgramStatus { + program: "Not Valid".to_owned(), + state: ProgramState::Conflict.into(), + detail: String::new(), + }], + programs_reported: true, + }; + super::store_config_status(&database, "device", &status) + .await + .expect("a rejected program report does not fail the stream"); + let device = database.get_device("device").await.unwrap().unwrap(); + assert_eq!(device.device.config_revision, Some(3)); + assert_eq!(device.device.config_state, Some(2)); + assert_eq!( + device + .programs + .iter() + .map(|program| program.program.as_str()) + .collect::>(), + vec!["claude-code"], + "the last accepted program rows stay" + ); + assert_eq!(device.programs_reported, Some(true)); + drop(database); + let _ = std::fs::remove_file(path); + } } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 53fed7af..5d9ca3f1 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -76,9 +76,31 @@ 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, + /// Opt-in interval between periodic re-applies of the current + /// configuration, which repair drift (a managed file deleted or edited by + /// hand, a fixed conflict, a loosened mode) without rewriting anything + /// unchanged. Unset means no periodic re-apply; a controller-managed + /// device still re-applies the controller's configuration on every + /// reconnect. Must be greater than zero and at most 30 days; read at + /// startup only (restart the daemon after changing it). + #[serde(default, with = "humantime_serde::option")] + #[cfg_attr(feature = "schema", schemars(with = "Option"))] + pub reconcile_interval: Option, } /// Local loopback LLM proxy settings. User mode only: the proxy hands out the @@ -118,6 +140,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))] @@ -211,6 +252,36 @@ pub struct LlmGatewayConfig { skip_serializing_if = "Option::is_none" )] pub github_oauth: Option, + /// What the programs behind the local LLM proxy (`programs.copilot`, + /// `programs.vscode`) do while the daemon runs without that proxy: + /// `failClosed` (the default) leaves their managed entries in place, so + /// the tools stay pointed at the loopback port and fail instead + /// of falling back to GitHub past the gateway (with no entries in place + /// yet, the tools keep their own settings); `failOpen` removes them + /// until the proxy is back (VS Code on `githubModels` then talks to GitHub + /// directly, the Copilot CLI and VS Code on `ownModels` lose the gateway's + /// models). + #[serde(default, skip_serializing_if = "ProxyUnavailable::is_default")] + pub when_proxy_unavailable: ProxyUnavailable, +} + +/// The `llmGateway.whenProxyUnavailable` policy. +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum ProxyUnavailable { + /// Keep the managed entries: the tools fail instead of bypassing the + /// gateway. + #[default] + FailClosed, + /// Remove the managed entries until the proxy is back. + FailOpen, +} + +impl ProxyUnavailable { + fn is_default(&self) -> bool { + *self == Self::default() + } } /// GitHub App user authorization for Copilot requests through the local proxy. @@ -565,6 +636,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 +652,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 +780,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))] @@ -798,6 +1193,9 @@ pub fn parse_daemon(contents: &str) -> anyhow::Result { Ok(config) } +/// The longest accepted `daemon.reconcileInterval`. +const MAX_RECONCILE_INTERVAL: Duration = Duration::from_secs(30 * 24 * 60 * 60); + fn parse_local_daemon(contents: &str) -> anyhow::Result { let config: DaemonConfig = crate::serdes::yamlviajson::from_str(contents).context("parse daemon configuration")?; @@ -809,6 +1207,16 @@ fn parse_local_daemon(contents: &str) -> anyhow::Result { if config.inventory_interval.is_zero() { anyhow::bail!("inventoryInterval must be greater than zero"); } + if config + .daemon + .as_ref() + .and_then(|daemon| daemon.reconcile_interval) + .is_some_and(|interval| interval.is_zero() || interval > MAX_RECONCILE_INTERVAL) + { + anyhow::bail!( + "daemon.reconcileInterval must be greater than zero and at most 30 days (leave it unset for no periodic re-apply)" + ); + } validate_daemon( config.llm_gateway.as_ref(), config.sandbox.as_ref(), @@ -842,6 +1250,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 +1450,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(()) } @@ -1132,6 +1576,51 @@ programs: { claudeCode: { useLlmGateway: false } } ); } + #[test] + fn daemon_reconcile_interval_is_unset_by_default_and_parses_humantime() { + let default = + super::parse_local_daemon("programs: {}").expect("valid daemon configuration"); + assert!(default.daemon.is_none()); + + let configured = super::parse_local_daemon("daemon:\n reconcileInterval: 90s\n") + .expect("valid daemon configuration"); + assert_eq!( + configured.daemon.unwrap().reconcile_interval, + Some(std::time::Duration::from_secs(90)) + ); + } + + #[test] + fn daemon_reconcile_interval_rejects_zero() { + let error = super::parse_local_daemon("daemon:\n reconcileInterval: 0s\n") + .expect_err("a zero reconcile interval is not schedulable"); + assert!( + error.to_string().contains("daemon.reconcileInterval"), + "unexpected error: {error}" + ); + } + + #[test] + fn daemon_reconcile_interval_rejects_more_than_thirty_days() { + assert!(super::parse_local_daemon("daemon:\n reconcileInterval: 30days\n").is_ok()); + let error = super::parse_local_daemon("daemon:\n reconcileInterval: 31days\n") + .expect_err("an interval beyond 30 days is refused"); + assert!( + error.to_string().contains("daemon.reconcileInterval"), + "{error}" + ); + } + + #[test] + fn daemon_reconcile_interval_is_rejected_in_pushed_configuration() { + let error = parse_daemon("daemon:\n reconcileInterval: 5m\nprograms: {}") + .expect_err("daemon.* is local-only, like every other startup setting"); + assert!( + error.to_string().contains("only allowed in the local"), + "unexpected error: {error}" + ); + } + #[test] fn daemon_controller_requires_https() { let plaintext = r#" diff --git a/crates/proto/proto/fleet.proto b/crates/proto/proto/fleet.proto index 36909325..d9b9f0f7 100644 --- a/crates/proto/proto/fleet.proto +++ b/crates/proto/proto/fleet.proto @@ -168,6 +168,14 @@ message ConfigStatus { uint64 revision = 1; ConfigState state = 2; string error = 3; + // Per-program outcomes of this apply, in the agent's provider order. Empty + // when the configuration was not applied at all (hash or parse error), and + // from agents that do not report per-program status: `programs_reported` + // distinguishes the two. + repeated ProgramStatus programs = 4; + // Whether this agent version reports per-program status at all. An older + // agent never sets this field, so it reads back as `false`. + bool programs_reported = 5; } enum ConfigState { @@ -175,3 +183,24 @@ enum ConfigState { CONFIG_STATE_APPLIED = 1; CONFIG_STATE_FAILED = 2; } + +// One managed program's outcome from an apply. `program` is the provider ID +// (for example `claude-code`, `vscode`); `detail` is at most 1024 bytes and +// carries paths, fixed reasons and error chains of the kind sent in +// `ConfigStatus.error`, which do not quote managed file content. +message ProgramStatus { + string program = 1; + ProgramState state = 2; + string detail = 3; +} + +enum ProgramState { + PROGRAM_STATE_UNSPECIFIED = 0; + PROGRAM_STATE_APPLIED = 1; + PROGRAM_STATE_UNCHANGED = 2; + PROGRAM_STATE_REMOVED = 3; + PROGRAM_STATE_CONFLICT = 4; + PROGRAM_STATE_INACTIVE = 5; + PROGRAM_STATE_BLOCKED = 6; + PROGRAM_STATE_FAILED = 7; +} diff --git a/examples/copilot/README.md b/examples/copilot/README.md new file mode 100644 index 00000000..6d11ba4c --- /dev/null +++ b/examples/copilot/README.md @@ -0,0 +1,199 @@ +# GitHub Copilot through Agentgateway + +This example points the GitHub Copilot CLI and VS Code Copilot Chat at a local +Agentgateway through Agentdesktop, without a controller. It is also the test +guide for the Copilot programs: each step says what to check. + +Agentdesktop runs a loopback LLM proxy (`daemon.llmProxy.listen`) and writes the +client files that point the tools at it. The proxy adds the developer's +identity (here a Dex OIDC token) to every request, and the gateway checks it. +Two paths go through the same gateway: + +| Path | Clients | Models | Billed by | +| --- | --- | --- | --- | +| The gateway's own models (`llm` in `agentgateway.yaml`) | Copilot CLI (`programs.copilot`), VS Code on own models (`programs.vscode`) | The models the gateway serves | Your provider key | +| GitHub's models (`copilot-proxy` route) | VS Code on GitHub's models (`copilotChat: githubModels`) | GitHub's models, as your Copilot plan and policy allow | The developer's Copilot seat | + +## Prerequisites + +- Linux with Docker Compose (the example was run on Ubuntu 24.04; macOS and + Windows paths are covered by unit tests only) +- `agentdesktop` on your `PATH` (see the repository README) +- An OpenAI API key for the gateway's own models +- For step 3: the Copilot CLI (`copilot`); for steps 4 and 5: VS Code with + GitHub Copilot Chat. The example was run with both signed in to GitHub with a + Copilot Business seat; without a sign-in it was not tried. Steps 3 and 4 + need the organisation's BYOK policy enabled (see + [github-side.md](github-side.md)); step 5 needs a Copilot Business or + Enterprise seat. +- A user account where no other Agentdesktop daemon runs or ran. A daemon + that ran before left sidecar files next to the files it managed; this + example's daemon removes what that earlier setup added (for example Claude + Code or OpenCode entries). Stop the other daemon first, and expect to + re-apply its configuration afterwards. +- Nothing else listening on 127.0.0.1:4001 (gateway), :4010 (the loopback + proxy), :5557 (Dex) and :51327 (the login callback) + +## 1. Start Dex and the gateway + +From the repository root: + +```sh +export OPENAI_API_KEY=sk-... +docker compose -f examples/copilot/compose.yaml up -d +curl -sI http://127.0.0.1:4001/ | head -1 # HTTP/1.1 200 OK +``` + +The gateway fetches Dex's keys at start and restarts until Dex answers: if the +check prints nothing, wait a few seconds and run it again +(`docker compose -f examples/copilot/compose.yaml ps` shows both services +`Up`). Dex has one user, `admin@example.com` with password `password`. + +## 2. Run the daemon + +```sh +agentdesktop daemon --config examples/copilot/config.yaml +``` + +`config.yaml` sets `daemon.user: true`, so `--user` is not needed. The daemon +stays in the foreground and logs there; run the checks below in a second +terminal, and stop it with Ctrl+C (later steps restart it this way). There is +no `--dry-run` preview for this example: the loopback proxy cannot run in a +one-shot run. + +The first start prints `Open this URL to connect Agentdesktop:` with +`http://127.0.0.1:51327/` and opens a browser; sign in to Dex there. Without a +browser on this machine, open that URL in a browser on the same machine; over +ssh, forward ports 51327 and 5557 first. Later restarts reuse the login. Then: + +```sh +agentdesktop --socket "$XDG_RUNTIME_DIR/agentdesktop.sock" status # ok +curl -s --unix-socket "$XDG_RUNTIME_DIR/agentdesktop.sock" http://localhost/v1/daemon-info +``` + +`llmProxy.bound` is `true` and `listen` is `127.0.0.1:4010`. The daemon log has +`applied reconciliation change` lines for `~/.copilot/providers.json` and the VS +Code `chatLanguageModels.json` (`~/.config/Code/User/`). Both files are +owner-only (600) and carry a pairing value that the proxy requires on every +request; other local users and browser pages cannot use the proxy without it. +The daemon keeps a small sidecar next to each file +(`.providers.json.agentdesktop`, `.chatLanguageModels.json.agentdesktop`) that +records what it added, so it can take out exactly that later. + +## 3. Copilot CLI on the gateway's models + +```sh +copilot -p "Reply with exactly one word: PONG" --model agentdesktop/gpt-4.1 +``` + +The answer comes from the gateway: its log (`docker compose -f +examples/copilot/compose.yaml logs agentgateway`) shows the request with +`user="admin@example.com"`. `~/.copilot/providers.json` holds the providers +`agentdesktop` (OpenAI-shaped) and `agentdesktop-anthropic`; your own providers +in that file are kept. The CLI reads the file at start: restart a running +session after the first apply. + +## 4. VS Code on the gateway's models + +Restart VS Code, open Copilot Chat and pick `gpt-4.1-mini (agentdesktop)` in the +model picker. A prompt answers through the gateway (same log line as above). +Your own entries in `chatLanguageModels.json` are kept. + +## 5. VS Code on GitHub's models (Copilot Business or Enterprise) + +Replace the `vscode` block in `config.yaml` with `copilotChat: githubModels` +(the comment there shows it) and restart the daemon (Ctrl+C, then the command +of step 2). Then: + +- The user `settings.json` gains `github.copilot.advanced.debug.overrideCapiUrl` + pointing at `http://127.0.0.1:4010/vscode-copilot-capi/` and two + entries in `settingsSync.ignoredSettings`, so Settings Sync does not carry the + override to other machines. Nothing else in the file changes: comments, key + order and formatting stay. The `agentdesktop` entry in + `chatLanguageModels.json` is removed. +- Restart VS Code. The picker shows GitHub's models (Auto, and the models your + organisation's policy enables). A chat turn answers; the gateway log shows + `POST /copilot-proxy/chat/completions` with status 200 and the Dex user, and + an agent-mode turn opens `GET /copilot-proxy/responses` with status 101 (the + gateway logs it when the tunnel closes). + +This step was run against Solo Enterprise for AgentGateway with the same route; +with the OSS gateway image of this example the route configuration is accepted +(`--validate-only`) but the step has not been run end to end yet. The route +targets `api.business.githubcopilot.com`. Other plans use another host, which +VS Code's Copilot Chat log shows (the `_ping` requests): change both +occurrences in `agentgateway.yaml` (`urlRewrite.authority.full` and the backend +`host`) and restart the gateway (`docker compose -f +examples/copilot/compose.yaml restart agentgateway`). + +While the daemon is not running, or runs without the proxy under the default +`llmGateway.whenProxyUnavailable: failClosed`, the override stays and Copilot +Chat fails instead of reaching GitHub past the gateway; with `failOpen` a +daemon without the proxy removes it and VS Code uses GitHub directly. The +failing requests still carry VS Code's Copilot tokens to the loopback port: +use this on single-user machines, and switch back to own models (or stop VS +Code) before removing the daemon. + +## 6. Drift, conflicts and removal + +Each item starts from the `config.yaml` of step 4 (own models); undo each +change before the next. + +- **Drift.** With `daemon.reconcileInterval` set (one minute in this example; + the tick is off when it is left out), delete `~/.copilot/providers.json`: + within one interval it is back with the same managed entries (your own + entries were in the deleted file and do not come back). `chmod 644` on a + managed file: back to 600. When nothing changed, the daemon writes nothing. +- **Conflict.** Put `// x` on the first line of `chatLanguageModels.json`. The + daemon refuses to rewrite a file it cannot parse and logs `program + configuration outcome` with `state="conflict"` and the path for `vscode`, and + `state="blocked"` (`not applied: vscode conflicted`) for the other programs + that had changes; it writes no managed file on the device until the comment + is gone (VS Code's `settings.json` is the exception that accepts comments and + trailing commas). Remove the comment: within one interval everything applies + again (without the tick: at the next restart). +- **Proxy gone.** Remove `daemon.llmProxy` and restart: the log says + `state="inactive"` with the reason, and the Copilot and VS Code entries stay + (`llmGateway.whenProxyUnavailable: failClosed`, the default), so the CLI's + `agentdesktop/gpt-4.1` fails instead of answering. Add + `whenProxyUnavailable: failOpen` under `llmGateway` in `config.yaml` and + restart: the entries are removed. +- **Removal.** Remove a program from `programs` and restart: its entries are + taken out, your own entries stay, and a file the daemon created is deleted + when nothing else is left. + +## 7. With a controller + +The same programs can be delivered by the controller instead of the local +file; see the managed quickstart in the repository README. The controller's +device page then lists each managed program with its outcome (applied, +unchanged, removed, conflict, inactive, blocked, failed), and `GET +/api/v1/devices/{id}` returns the same list. With a controller, the gateway +checks the controller's short-lived JWT (issuer `agentdesktop-controller`) +instead of Dex, and the proxy uses the client IDs `copilot-cli` and +`vscode-copilot`, which must be in `llmGateway.authentication.allowedClientIds`. + +## 8. GitHub-side settings + +The Copilot plan, the BYOK policy, model policies, default models and egress +rules are set by the GitHub organisation or enterprise owner; see +[github-side.md](github-side.md). + +## 9. Clean up + +Stop the daemon (Ctrl+C). To take the managed entries out, remove `programs` +from `config.yaml` and start the daemon once more (or delete the files if the +daemon created them). Then `docker compose -f examples/copilot/compose.yaml +down`, and remove `~/.local/state/agentdesktop` for a fresh login next time. +Dex keeps its data in memory, so after `down` and `up` the daemon needs a new +login. + +## Known limits + +- Inline completions and next edit suggestions go directly to GitHub; this + example routes chat and agent mode only. +- `github.copilot.advanced.debug.overrideCapiUrl` is an undocumented VS Code + setting; the fallback is VS Code's HTTP proxy setting pointed at the gateway. +- Tested on Linux with VS Code 1.139, Copilot CLI 1.0.88 and a Copilot Business + seat: steps 1 to 4 and 6 with this example's OSS gateway; step 5 with Solo + Enterprise for AgentGateway. diff --git a/examples/copilot/agentgateway.yaml b/examples/copilot/agentgateway.yaml new file mode 100644 index 00000000..90470e87 --- /dev/null +++ b/examples/copilot/agentgateway.yaml @@ -0,0 +1,81 @@ +# yaml-language-server: $schema=https://agentgateway.dev/schema/config +# Two paths for GitHub Copilot through one gateway: +# - llm: the gateway's own models (Copilot CLI, VS Code on own models), billed by your provider key +# - routes/copilot-proxy: GitHub's models (VS Code on GitHub's models), the developer's Copilot +# token passed through, billed by the Copilot seat +# Both require the developer's Dex token, which Agentdesktop adds on the loopback proxy. +frontendPolicies: + logging: + add: + user: jwt.email + +gateways: + gateway: + port: 4001 + +routes: +- name: reachability + gateways: gateway + matches: + - method: HEAD + path: + exact: / + policies: + directResponse: + status: 200 + +# llmGateway.proxyUrl points here. Agentdesktop sends the developer's identity in +# Authorization and VS Code's own Copilot token in x-llm-token; the route checks the +# identity, restores the Copilot token as the bearer and forwards to GitHub. +- name: copilot-proxy + gateways: gateway + matches: + - path: + pathPrefix: /copilot-proxy + policies: + jwtAuth: + mode: strict + issuer: http://127.0.0.1:5557/dex + audiences: [agentdesktop-local] + jwks: + url: http://dex:5557/dex/keys + urlRewrite: + authority: + # Copilot Business; other plans use another host (VS Code's Copilot Chat log shows it) + 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 VS Code uses cannot travel over HTTP/2 + backendAuth: + passthrough: {} + transformations: + request: + set: + authorization: '"Bearer " + request.headers["x-llm-token"]' + remove: + - x-llm-token + +llm: + gateways: gateway + policies: + jwtAuth: + mode: strict + issuer: http://127.0.0.1:5557/dex + audiences: [agentdesktop-local] + jwks: + url: http://dex:5557/dex/keys + models: + - name: "gpt-4.1-mini" + provider: openai + params: + apiKey: $OPENAI_API_KEY + - name: "gpt-4.1" + provider: openai + params: + apiKey: $OPENAI_API_KEY diff --git a/examples/copilot/compose.yaml b/examples/copilot/compose.yaml new file mode 100644 index 00000000..b82fc609 --- /dev/null +++ b/examples/copilot/compose.yaml @@ -0,0 +1,23 @@ +# Dex (OIDC for the developer) and Agentgateway (the LLM gateway) for the Copilot example. +services: + dex: + image: ghcr.io/dexidp/dex:v2.45.1 + command: dex serve /etc/dex/config.yaml + ports: + - 127.0.0.1:5557:5557 + volumes: + - ./dex.yaml:/etc/dex/config.yaml:ro + + agentgateway: + image: cr.agentgateway.dev/agentgateway:v1.4.1 + command: -f /etc/agentgateway/config.yaml + depends_on: + - dex + # The gateway fetches Dex's keys at start; retry until Dex is up. + restart: on-failure + ports: + - 127.0.0.1:4001:4001 + environment: + OPENAI_API_KEY: + volumes: + - ./agentgateway.yaml:/etc/agentgateway/config.yaml:ro diff --git a/examples/copilot/config.yaml b/examples/copilot/config.yaml new file mode 100644 index 00000000..6ef39341 --- /dev/null +++ b/examples/copilot/config.yaml @@ -0,0 +1,39 @@ +# yaml-language-server: $schema=../../schema/daemon-config.json +# Agentdesktop daemon configuration for the Copilot example (user mode, no controller). +llmGateway: + url: http://127.0.0.1:4001 + # The pass-through route for GitHub's models (VS Code copilotChat: githubModels). + proxyUrl: http://127.0.0.1:4001/copilot-proxy + authentication: + type: oidc + issuer: http://127.0.0.1:5557/dex + clientId: agentdesktop-local + scopes: [openid, email, offline_access] + allowInsecure: true + +daemon: + user: true + llmProxy: + # The loopback proxy the managed client files point at. + listen: 127.0.0.1:4010 + # Opt-in: re-apply every minute, so deleted or edited managed files come back and fixed + # conflicts apply. Leave it out for no periodic re-apply. + reconcileInterval: 1m + +programs: + # GitHub Copilot CLI: ~/.copilot/providers.json gains the providers "agentdesktop" and + # "agentdesktop-anthropic"; run `copilot --model agentdesktop/gpt-4.1`. + copilot: + models: + gpt-4.1: {} + # VS Code Copilot Chat on the gateway's models: chatLanguageModels.json gains the vendor + # entry "agentdesktop"; the picker shows "gpt-4.1-mini (agentdesktop)". + vscode: + models: + gpt-4.1-mini: + maxInputTokens: 128000 + maxOutputTokens: 16000 + # VS Code Copilot Chat on GitHub's models through the gateway instead (Copilot Business or + # Enterprise seat): replace the vscode block above with + # vscode: + # copilotChat: githubModels diff --git a/examples/copilot/dex.yaml b/examples/copilot/dex.yaml new file mode 100644 index 00000000..76f4d1ff --- /dev/null +++ b/examples/copilot/dex.yaml @@ -0,0 +1,24 @@ +issuer: http://127.0.0.1:5557/dex + +storage: + type: memory + +web: + http: 0.0.0.0:5557 + +oauth2: + skipApprovalScreen: true + +staticClients: +- id: agentdesktop-local + name: Agentdesktop Copilot example + public: true + redirectURIs: + - http://127.0.0.1:51327/callback + +enablePasswordDB: true +staticPasswords: +- email: admin@example.com + username: admin + userID: local-admin + hash: $2y$10$cA5xff5AaaAoxB8VZjwXUeXZIlH8V0sLf/JkIBXHip6gnQsyKJiHW diff --git a/examples/copilot/github-side.md b/examples/copilot/github-side.md new file mode 100644 index 00000000..09ae3493 --- /dev/null +++ b/examples/copilot/github-side.md @@ -0,0 +1,37 @@ +# GitHub Copilot: what the GitHub organisation or enterprise owner configures + +Agentdesktop points the Copilot CLI and VS Code Copilot Chat at the local LLM proxy, and the proxy sends their traffic through the gateway with the developer's identity attached. Some settings that decide whether that traffic flows, and whether developers can go around it, live on the GitHub side and belong to your GitHub organisation or enterprise owner and your network team. This page lists them per client mode. + +## The two VS Code modes and the Copilot CLI + +| | Copilot CLI (`programs.copilot`) | VS Code, own models (`copilotChat: ownModels`) | VS Code, GitHub's models (`copilotChat: githubModels`) | +|---|---|---|---| +| Models | The gateway's models | The gateway's models | GitHub's models, as the Copilot plan and model policy allow | +| Billed by | Your model provider, per token | Your model provider, per token | The Copilot seat (included AI credits, then metered) | +| GitHub BYOK policy | Not verified whether it applies | Must stay enabled | Not needed | +| Gateway sees | The user; with a controller also device and client; full request | The user; with a controller also device and client; full request | The user; with a controller also device and client; the request as VS Code sends it to GitHub | + +## Settings on the GitHub side + +| Item | What to set | Applies to | Verified | +|---|---|---|---| +| Copilot plan | Business or Enterprise seats. BYOK is admin-controllable only on these plans. | all | Business seat, tested | +| BYOK policy | "Bring Your Own Language Model Key in select IDEs" must stay enabled, otherwise VS Code's custom endpoint is switched off for every seat. Whether the Copilot CLI's BYOK follows the same policy is not verified. | own models, CLI | GitHub docs | +| Model availability policy (optional, enforcement) | Hosted chat models "Disabled for every user and agent app" leaves only the gateway models in the VS Code picker, where GitHub's models are greyed out with "Contact your admin". The CLI still offered gpt-4.1 in the lab with the enterprise's default availability disabled; the base model's own status is undocumented. Do not use with `githubModels`, which needs GitHub's models enabled. | own models, CLI | tested | +| CLI default model | Managed settings `{"model": "agentdesktop/"}`: Linux `/etc/github-copilot/managed-settings.json`, macOS `/Library/Application Support/GitHubCopilot/`, Windows `%ProgramFiles%\GitHubCopilot\`. Precedence: native MDM, then server-managed (`.github-private` repository), then the file. A default only: the user's own `config.json` overrides it. Managed settings have no provider, URL or credential keys. | CLI | GitHub docs, tested | +| VS Code default model | `chat.defaultModel` (also the `ChatDefaultModel` enterprise policy), for example `customendpoint/agentdesktop/gpt-4.1-mini`. A default only. Agentdesktop does not write it. | own models | GitHub docs, tested | +| Egress deny (optional, enforcement) | Deny `api.githubcopilot.com`, `api.{individual,business,enterprise}.githubcopilot.com` and the model providers' hosts from developer machines; allow the gateway, `api.github.com`, and the completion hosts `proxy..githubcopilot.com` and `copilot-proxy.githubusercontent.com`. With `githubModels`, the gateway (not the device) reaches `api..githubcopilot.com`, so the deny on devices still holds. Roll out only after the fleet view shows every device configured. | all | GitHub docs, tested | +| Seat-side observability | The Copilot usage metrics API stays the source for completions and hosted-model use; the gateway sees the traffic routed through it. | all | GitHub docs | +| Not needed | Enterprise custom models ("Editor preview features" policy). | | | + +## What each mode leaves outside the gateway + +- **Inline completions and next edit suggestions** go straight to `proxy..githubcopilot.com` in every mode; Agentdesktop does not route them. +- **GitHub's models mode** relies on `github.copilot.advanced.debug.overrideCapiUrl`, an undocumented Copilot Chat setting. If a VS Code release drops it, the fallback is VS Code's HTTP proxy setting with the gateway as the proxy. +- **Defaults are not enforcement.** A developer can change the default model or remove the managed file entries; only the model policy and egress rules above make the gateway path non-optional. + +## Not verified + +- Whether the Copilot CLI's BYOK is gated by the VS Code BYOK policy. +- The Enterprise Cloud host (`api.enterprise.githubcopilot.com`) for `githubModels`; tested with a Business seat only. +- macOS and Windows (firewall prompts for the loopback listener, paths beyond unit tests); corporate proxies (`NO_PROXY` for 127.0.0.1). diff --git a/frontend/controller/src/stories/fixtures.ts b/frontend/controller/src/stories/fixtures.ts index 6cc1dce6..98cfe594 100644 --- a/frontend/controller/src/stories/fixtures.ts +++ b/frontend/controller/src/stories/fixtures.ts @@ -208,6 +208,30 @@ const deviceSkills = [ export const deviceDetail: DeviceDetail = { ...macDevice, + programs: [ + { + program: "claude-code", + state: "applied", + detail: "", + revision: 12, + updated_at: now - 60, + }, + { + program: "vscode", + state: "unchanged", + detail: "", + revision: 12, + updated_at: now - 60, + }, + { + program: "copilot", + state: "removed", + detail: "", + revision: 12, + updated_at: now - 60, + }, + ], + programs_reported: true, discoveries: [ { kind: "vscode", @@ -254,6 +278,51 @@ export const deviceDetail: DeviceDetail = { export const failedDeviceDetail: DeviceDetail = { ...deviceDetail, ...failedDevice, + programs: [ + { + program: "claude-code", + state: "blocked", + detail: "not applied: vscode conflicted", + revision: 12, + updated_at: now - 60, + }, + { + program: "copilot", + state: "inactive", + detail: + "local LLM proxy not available; see llmProxy.error in daemon-info, or set daemon.llmProxy.listen; the managed entries stay and the tool fails until the proxy is back (whenProxyUnavailable: failClosed)", + revision: 12, + updated_at: now - 60, + }, + { + program: "vscode", + state: "conflict", + detail: + "settings at /Users/developer/Library/Application Support/Code/User/settings.json: conflicting or invalid existing configuration", + revision: 12, + updated_at: now - 60, + }, + { + program: "codex", + state: "failed", + detail: "multiple providers plan to modify /etc/codex/config.toml", + revision: 12, + updated_at: now - 60, + }, + { + program: "grok", + state: "unspecified", + detail: "", + revision: 12, + updated_at: now - 60, + }, + ], +}; + +export const oldAgentDeviceDetail: DeviceDetail = { + ...deviceDetail, + programs: [], + programs_reported: false, }; export const emptyDeviceDetail: DeviceDetail = { @@ -261,6 +330,8 @@ export const emptyDeviceDetail: DeviceDetail = { discoveries: [], model_runtimes: [], recent_events: [], + programs: [], + programs_reported: true, }; export const controllerSettings: ControllerSettings = { @@ -283,6 +354,19 @@ export const activeDaemonConfig: DaemonConfigDocument = { }, }; +export const copilotFailOpenDaemonConfig: DaemonConfigDocument = { + llmGateway: { + url: "https://gateway.example.internal", + proxyUrl: "https://gateway.example.internal/copilot-proxy", + whenProxyUnavailable: "failOpen", + authentication: { type: "controllerJwt", audience: "agentgateway" }, + }, + programs: { + copilot: { models: { "gpt-4.1": {} } }, + vscode: { copilotChat: "githubModels" }, + }, +}; + export const sandboxDaemonConfig: DaemonConfigDocument = { sandbox: { network: { diff --git a/frontend/controller/src/styles.css b/frontend/controller/src/styles.css index 50b5824b..f0f22543 100644 --- a/frontend/controller/src/styles.css +++ b/frontend/controller/src/styles.css @@ -487,6 +487,23 @@ code { color: var(--muted); } +.badge.warning { + color: var(--warning-strong); + border-color: var(--warning-border); +} + +.program-state { + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.program-state code { + min-width: 0; + overflow-wrap: anywhere; +} + .config-summary { padding-bottom: 14px; } @@ -869,6 +886,7 @@ code { .toggle-row input { width: 16px; height: 16px; + flex-shrink: 0; accent-color: var(--accent-button); } @@ -1230,6 +1248,11 @@ code { text-align: center; } +.empty-state.compact { + min-height: 0; + padding: 16px; +} + .empty-state h3, .empty-state h2 { margin: 10px 0 5px; diff --git a/frontend/controller/src/types.ts b/frontend/controller/src/types.ts index d3bb0aeb..6a152abe 100644 --- a/frontend/controller/src/types.ts +++ b/frontend/controller/src/types.ts @@ -16,7 +16,25 @@ export type Device = { installed_tools: string[]; }; +export type ProgramStatus = { + program: string; + state: + | "applied" + | "unchanged" + | "removed" + | "conflict" + | "inactive" + | "blocked" + | "failed" + | "unspecified"; + detail: string; + revision: number; + updated_at: number; +}; + export type DeviceDetail = Device & { + programs: ProgramStatus[]; + programs_reported: boolean | null; discoveries: Array<{ kind: string; version: string; @@ -74,7 +92,9 @@ export type AgentKind = | "claudeDesktop" | "codex" | "openCode" - | "grok"; + | "grok" + | "copilot" + | "vscode"; export type AgentDraft = { kind: AgentKind; @@ -95,6 +115,8 @@ export type SandboxConfigDocument = { export type DaemonConfigDocument = { llmGateway?: { url: string; + proxyUrl?: string; + whenProxyUnavailable?: "failClosed" | "failOpen"; authentication?: { type: string; audience?: string; diff --git a/frontend/controller/src/views/ConfigurationView.stories.tsx b/frontend/controller/src/views/ConfigurationView.stories.tsx index 5c56bbe2..8009e356 100644 --- a/frontend/controller/src/views/ConfigurationView.stories.tsx +++ b/frontend/controller/src/views/ConfigurationView.stories.tsx @@ -2,7 +2,11 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, fn, userEvent } from "storybook/test"; import { ControllerStoryFrame } from "../stories/ControllerStoryFrame"; -import { activeDaemonConfig, sandboxDaemonConfig } from "../stories/fixtures"; +import { + activeDaemonConfig, + copilotFailOpenDaemonConfig, + sandboxDaemonConfig, +} from "../stories/fixtures"; import { ConfigurationView } from "./ConfigurationView"; const meta = { @@ -52,6 +56,31 @@ export const ActiveSandbox: Story = { }, }; +export const CopilotFailOpen: Story = { + args: { initialConfig: copilotFailOpenDaemonConfig }, + play: async ({ canvas, canvasElement }) => { + const toggle = canvas.getByRole("checkbox", { + name: /Fail open without the local proxy/, + }); + await expect(toggle).toBeChecked(); + const output = canvasElement.querySelector(".output-card code"); + await expect(output).toHaveTextContent("whenProxyUnavailable: failOpen"); + await userEvent.click(toggle); + await expect(output).not.toHaveTextContent("whenProxyUnavailable"); + }, +}; + +export const NoProxyPolicyWithoutCopilot: Story = { + args: { initialConfig: activeDaemonConfig }, + play: async ({ canvas }) => { + await expect( + canvas.queryByRole("checkbox", { + name: /Fail open without the local proxy/, + }), + ).toBeNull(); + }, +}; + export const GeneratedYamlScrolls: Story = { args: { initialConfig: activeDaemonConfig }, play: async ({ canvasElement }) => { diff --git a/frontend/controller/src/views/ConfigurationView.tsx b/frontend/controller/src/views/ConfigurationView.tsx index ad4ad3f3..0722225e 100644 --- a/frontend/controller/src/views/ConfigurationView.tsx +++ b/frontend/controller/src/views/ConfigurationView.tsx @@ -24,6 +24,8 @@ 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 [proxyFailOpen, setProxyFailOpen] = useState(false); const [controllerJwt, setControllerJwt] = useState(true); const [audience, setAudience] = useState("agentgateway"); const [sessionNewTelemetry, setSessionNewTelemetry] = useState(false); @@ -45,9 +47,12 @@ export function ConfigurationView({ return [definition?.label ?? agent.kind]; }); const sandboxUnavailable = incompatibleSandboxAgentNames.length > 0; + const usesLocalProxy = agents.some((agent) => proxyAgents.has(agent.kind)); const yaml = daemonConfigYaml({ gateway, gatewayUrl, + gatewayProxyUrl, + proxyFailOpen: proxyFailOpen && usesLocalProxy, controllerJwt, audience, sandboxEnabled, @@ -75,6 +80,8 @@ export function ConfigurationView({ setGateway(Boolean(llmGateway)); if (llmGateway) { setGatewayUrl(llmGateway.url); + setGatewayProxyUrl(llmGateway.proxyUrl ?? ""); + setProxyFailOpen(llmGateway.whenProxyUnavailable === "failOpen"); setControllerJwt(llmGateway.authentication?.type === "controllerJwt"); setAudience(llmGateway.authentication?.audience ?? "agentgateway"); } @@ -177,6 +184,41 @@ export function ConfigurationView({ onChange={(event) => setGatewayUrl(event.target.value)} /> + + {usesLocalProxy && ( + + )}