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..dea1dcc2 100644 --- a/README.md +++ b/README.md @@ -186,9 +186,12 @@ paths on the prefix-less route: | `/copilot-cli/` | `copilot-cli` | gateway identity only | `llmGateway.url` | | `/vscode-copilot/` | `vscode-copilot` | gateway identity only | `llmGateway.url` | | `/vscode-copilot-passthrough/` | `vscode-copilot` | gateway identity in `Authorization`, the client's own bearer token in `x-llm-token` | `llmGateway.proxyUrl` (required) | +| `/vscode-copilot-capi//` | `vscode-copilot` | as the pass-through route; a credential-less `GET /_ping` is forwarded without `x-llm-token`; the only route that tunnels WebSocket upgrades | `llmGateway.proxyUrl` (required) | **Pairing.** Every route requires the per-device pairing value in the -`x-agentdesktop-pairing` header. The daemon creates it in its state directory +`x-agentdesktop-pairing` header, except `/vscode-copilot-capi/`, which takes it +as the first path segment after the prefix (VS Code cannot add a header there) +and ignores the header. The daemon creates it in its state directory (`llm-proxy-pairing`, owner-only) on first use and keeps it across restarts; a manual setup copies it from there. The state directory in `--user` mode is `$XDG_STATE_HOME/agentdesktop`, by default `~/.local/state/agentdesktop` (the @@ -207,7 +210,13 @@ buffered (32 MiB cap) so a request can be sent once more with a fresh credential when the gateway rejects a cached one with 401 (only when no client token was forwarded); responses stream as they arrive, with a 600 s limit on the response headers and no total limit on the body; a non-streaming completion -sends nothing until it is done, so the header limit bounds it too. +sends nothing until it is done, so the header limit bounds it too. A WebSocket +tunnel on `/vscode-copilot-capi/` is outside the header limit: it runs until a +side closes it or the gateway credential used at the upgrade expires, so a +tunnel outlives a revocation or logout for up to that credential's lifetime. +A WebSocket upgrade on any other route is refused with `400 +upgrade_not_supported`; other `Upgrade` tokens are stripped and the request is +forwarded as plain HTTP. Controller-issued credentials are cached in the daemon for at most 60 s per client ID, and are not used within 30 s of their own expiry, nor after a logout or re-enrollment. Concurrent requests that find no cached credential share one @@ -225,8 +234,8 @@ fetch (20 s) and a second header limit. Refused and failed requests return an OpenAI-style JSON body, `{"error": {"message", "type", "code"}}`, so the LLM clients display the message. Codes: `host_not_allowed`, `pairing_invalid`, `browser_not_allowed`, `method_not_allowed`, `path_invalid`, `route_unknown`, -`client_credential_missing`, `body_too_large`, `body_invalid`, -`agentdesktop_credential` (no gateway credential for this device: not enrolled, +`client_credential_missing`, `upgrade_not_supported`, `body_too_large`, +`body_invalid`, `agentdesktop_credential` (no gateway credential for this device: not enrolled, revoked, controller unreachable or slow), `agentdesktop_unavailable`, `gateway_unreachable`, `gateway_timeout`. @@ -262,6 +271,344 @@ device authorization again. Non-expiring App user tokens are also supported. Request bodies remain untouched. The existing Claude credential-helper flow is unchanged; the local proxy always uses the gateway identity in `Authorization`. +### Managed program: GitHub Copilot CLI + +`programs.copilot` makes the daemon write the Copilot CLI's BYOK provider +registry so the CLI talks to the proxy's `/copilot-cli` route with client ID +`copilot-cli`; nothing is edited by hand. User mode only (the file lives in the +user's Copilot directory), and it needs `daemon.llmProxy.listen`, a configured +`llmGateway`, and `copilot-cli` in `allowedClientIds`: + +```yaml +programs: + copilot: + models: + gpt-4.1: # the name the CLI shows: --model agentdesktop/gpt-4.1 + wireModel: gpt-4.1-mini # what is sent to the gateway (defaults to modelId, which defaults to the key) + claude-haiku-4.5: + provider: agentdesktop-anthropic + wireModel: claude-haiku-4-5 +``` + +The file is `providers.json` in `$COPILOT_HOME` (default `~/.copilot`), or the +path in `COPILOT_PROVIDERS_CONFIG` when set. The daemon reads those variables +from its own environment, which for a service-started daemon is not the +user's shell: if the CLI is pointed elsewhere by a shell profile, set +`daemon.copilot.config` in the local startup file to the same path. The daemon +adds two provider entries, `agentdesktop` (OpenAI-compatible, base URL +`http:///copilot-cli/v1`) and `agentdesktop-anthropic` (Anthropic, +base URL `http:///copilot-cli`), both carrying the pairing header, +plus one model entry per configured model (`id` is the map key, `provider` +and `modelId` are typed fields, other keys such as `wireModel` are passed +through to the CLI). Providers and +models the user added themselves are kept, including models of their own +under other providers with the same IDs. The two provider names and the +models under them belong to the daemon once it has written the file: it +replaces them on every apply, so an edit made by hand to one of them is +undone. Before the daemon has written the file, a user provider under one of +those names (without a pairing header), or a model under such a name, is a conflict: +the file is left alone and, as for every managed file, the daemon applies +nothing on the device until the conflict is resolved (a conflict present at +startup stops the daemon from starting, as for the other programs). If the +daemon's sidecar next to the file is gone, entries under the managed names +that carry a pairing header are replaced on the next apply, and those that +carry this daemon's own pairing value are removed when the program goes +away; entries with another pairing value are left alone. The file is written owner-only. 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`, setting `useLlmGateway: false`, or running without the +proxy takes the managed entries out again and restores the user's file with +its previous mode (deleted only if the daemon created it and nothing else is +left). + +Upgrade note: `programs.copilot` is a new field. Upgrade the controller first +(an older controller rejects the configuration), then the daemons (an older +daemon marks a pushed configuration that carries the field as failed); push +the program only to devices that run in user mode, since a system-mode daemon +fails the whole apply for the program it cannot manage. + +Pick the model with `copilot --model agentdesktop/gpt-4.1` or the `model` key +in the CLI's `settings.json` (`/model` in the CLI); the daemon does not write +that key. There is no `--dry-run` preview for this file: the proxy cannot run +in one-shot mode, so `daemon.llmProxy.listen` is refused together with +`--once` or `--dry-run`, and without the listener the daemon has nothing to +point the CLI at. A running CLI session does not pick up a changed `providers.json`; +start a new one. Each device's `providers.json` is tied to that device's +pairing value, so after a re-enrollment the daemon rewrites it on the next +apply. Discovery reports the CLI's version from the `@github/copilot` npm +manifest next to the launcher; a standalone binary shows no version. The +program cannot be applied with `--once`. + +### Managed program: VS Code Copilot Chat on own models + +`programs.vscode` makes the daemon write VS Code's custom language-model file +(`chatLanguageModels.json`, read by Copilot Chat's built-in **Custom +Endpoint** provider) so the model picker offers the configured models through +the proxy's `/vscode-copilot` route with client ID `vscode-copilot`. User mode +only; needs `daemon.llmProxy.listen`, a configured `llmGateway`, and +`vscode-copilot` (not the proxy's default `vscode`) in `allowedClientIds`: + +```yaml +programs: + vscode: + copilotChat: ownModels # or githubModels, see the next section + models: + gpt-4.1-mini: # the model name sent to the gateway + name: GPT-4.1 mini (agentdesktop) # picker label, defaults to " (agentdesktop)" + maxInputTokens: 128000 + maxOutputTokens: 16000 +``` + +The file lives in the VS Code user profile directory: Linux +`~/.config/Code/User`, macOS `~/Library/Application Support/Code/User`, +Windows `\AppData\Roaming\Code\User` (the daemon derives it from the +home directory, not from `APPDATA`); the local startup setting +`daemon.vscode.config` overrides it. Insiders and VSCodium profiles are not +managed. + +The daemon writes one vendor entry named `agentdesktop` (`vendor: +customendpoint`, `apiType: chat-completions`) with one model per configured +entry: `id`, `name`, the `url` on the proxy, `toolCalling` (default true), +`vision` (default false), the token limits when set, and the pairing value in +`requestHeaders`; other keys are passed through. VS Code keeps the file's +`apiKey` in the OS keyring and does not send it reliably, so the placeholder +`apiKey: unused` stays and the header carries the pairing; no secret is +written. + +Other vendor entries in the file are the user's and are kept. The +`agentdesktop` entry belongs to the daemon once it has written the file: it is +replaced as a whole on every apply (a hand edit, or a model added by hand +inside that entry, is undone) and removed by name on removal. Before the +first write, a user entry with that name and no pairing header is a conflict, +as for the Copilot CLI file. The file is written owner-only, plan reports +carry no content, there is no `--dry-run` preview and `--once` refuses the +program, and the upgrade note for `programs.copilot` applies (controller +first, then daemons, user-mode devices only). + +VS Code reads the file at startup: restart it after an apply that changes the +file (first apply, model changes, re-pairing after a re-enrollment). +`chat.defaultModel` is not written by the daemon; set it by policy if a +default is wanted. The file must stay plain JSON: a file with comments or +trailing commas is reported as a conflict rather than rewritten without +them, and a conflict blocks every managed file on the device until it is +resolved (and stops the daemon at startup, as for the other programs); an +empty file is filled. If the daemon's sidecar next to the file is gone, an +`agentdesktop` entry carrying a pairing header is replaced on the next apply, +and one carrying this daemon's own pairing value is removed when the program +goes away. Pass-through +model keys are written as given, so a misspelt key reaches VS Code unchanged. +The daemon's sidecar next to the file (`.chatLanguageModels.json.agentdesktop`, +owner-only) keeps a copy of the file as last written, including the user's +own entries, until the next apply. Non-default VS Code profiles and +`XDG_CONFIG_HOME` are not considered, matching the MCP inventory. + +### Managed program: VS Code Copilot Chat on GitHub's models through the gateway + +With `copilotChat: githubModels`, VS Code keeps GitHub's own models and its own +Copilot sign-in, and the daemon routes Copilot Chat's traffic to GitHub through +the gateway. The daemon writes two keys into the VS Code user `settings.json`: +`github.copilot.advanced.debug.overrideCapiUrl`, pointing Copilot Chat's API +endpoint at the proxy's `/vscode-copilot-capi/` route, and the two +entries `github.copilot.advanced.debug.overrideCapiUrl` and +`github.copilot.internal.capiUrl` in `settingsSync.ignoredSettings`, so +Settings Sync does not carry the override to other machines. The managed +`chatLanguageModels.json` entry is removed under this value; `models` must be +empty. Needs `llmGateway.proxyUrl` (a gateway route that forwards to GitHub with +the client's own token, see the example below), `daemon.llmProxy.listen`, user +mode, and `vscode-copilot` in `allowedClientIds`: + +```yaml +llmGateway: + url: https://gateway.example + proxyUrl: https://gateway.example/copilot-proxy + authentication: + type: controllerJwt + audience: agentgateway + allowedClientIds: [vscode-copilot] +programs: + vscode: + copilotChat: githubModels +``` + +VS Code sends its Copilot session token (`tid=...`) as the bearer token on chat +requests and the raw GitHub OAuth token (`gho_...`) on `/agents/*`; the proxy +moves either into `x-llm-token`, puts the gateway identity into `Authorization` +and forwards to `proxyUrl`. The pairing value travels in the URL path on this +route, because VS Code sends these requests itself and has no setting that +adds a header to them; the segment is checked in constant time and removed +before forwarding, and appears in no log or plan report. Copilot Chat's health +check, `GET /_ping` every few seconds, carries no credential and is forwarded +without `x-llm-token`. Auto and agent mode open a WebSocket (`GET /responses`) +that the proxy tunnels: the gateway identity is checked once, at the upgrade, +and the tunnel stays open until either side closes it or the gateway +credential it was opened with expires (its own expiry, typically the +controller JWT's; a re-enrollment or logout does not cut an open +conversation). 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, no `proxyUrl`, or no +proxy, with a warning) takes the key and the two entries out and deletes the +file only if the daemon created it. Restart VS Code after an apply that changes +the file (first apply, re-pairing after a re-enrollment). There is no +`--dry-run` preview: a dry run has no proxy and plans a removal; `--once` +refuses the program. + +Caveats. `github.copilot.advanced.debug.overrideCapiUrl` is an undocumented +setting of the Copilot Chat extension; if a VS Code release drops it, the +fallback is VS Code's HTTP proxy setting with the gateway as the proxy, which +this program does not manage. While the daemon is not running, the override +stays in `settings.json` and VS Code keeps sending its requests to the +loopback port: they fail, and they carry the user's Copilot session token +(`tid=`), the GitHub OAuth token (`gho_`, on `/agents/*`) and the pairing +value, so another local user who binds that port in the meantime receives +them. Use the program on devices with a single VS Code user, stop VS Code (or +switch the program to `ownModels` and let the daemon apply it) before stopping +or removing the daemon, and delete the key by hand if the daemon is removed +with the override in place: Copilot Chat fails until then. Which of GitHub's +models the picker offers is decided by the organisation's Copilot policy, not +by the daemon. + +Upgrade note: `copilotChat: githubModels` (which requires the existing +`llmGateway.proxyUrl`) and `daemon.vscode.settings` are new. Upgrade the +controller first, then the daemons, then push the value; an older controller +rejects the value. + +A gateway route for `proxyUrl` (Solo Enterprise for AgentGateway standalone), +verified with Business seats against `api.business.githubcopilot.com`; other +plans use another host, which a signed-in VS Code shows in its Copilot Chat log +(the `_ping` requests), so take it from there before writing the route. The +route validates the controller JWT, restores the client's token as the bearer +for GitHub and forwards. The backend must speak HTTP/1.1 to GitHub (`alpn`): +with the default ALPN, GitHub answers the forwarded upgrade with `400 +websocket: the client is not using the websocket protocol` and VS Code falls +back to plain `POST /responses` (turns still answer, without the WebSocket). +Its `requestTimeout` bounds request/response exchanges; an established +WebSocket tunnel is not cut by it. + +```yaml +routes: +- name: copilot-proxy + gateways: gateway + matches: + - path: + pathPrefix: /copilot-proxy + policies: + jwtAuth: + mode: strict + issuer: agentdesktop-controller + audiences: [agentgateway] + jwks: + url: https:///.well-known/jwks.json + urlRewrite: + authority: + full: api.business.githubcopilot.com + path: + prefix: / + timeout: + requestTimeout: 120s + backends: + - host: api.business.githubcopilot.com:443 + policies: + backendTLS: + alpn: [http/1.1] # the WebSocket upgrade cannot travel over HTTP/2 + backendAuth: + passthrough: {} + transformations: + request: + set: + authorization: '"Bearer " + request.headers["x-llm-token"]' + remove: + - x-llm-token +``` + +## 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 +621,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), so its files point nowhere and were removed. | +| `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 +688,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..e42ed01e --- /dev/null +++ b/crates/agent/src/provider/copilot/reconcile.rs @@ -0,0 +1,299 @@ +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 { + 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); removing the managed Copilot CLI providers" + ); + plan.inactive(crate::reconcile::PROXY_ABSENT_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)? + && let Some(pairing) = pairing + { + plan_remove_orphaned(path, pairing, plan)?; + } + Ok(()) +} + +/// Before agentdesktop has written the file (no sidecar), an entry under a +/// managed key is the user's: a provider with one of the managed names that +/// carries no pairing header, or a model under a managed provider name whose +/// provider entry was not written by an agentdesktop daemon (absent, or +/// without a pairing header). Once the sidecar exists, the managed keys are +/// agentdesktop's and their entries are replaced in place. +fn foreign_managed_entry( + path: &Path, + state_path: &Path, + managed: &Value, + plan: &ReconcilePlan, +) -> anyhow::Result> { + if json_merge::managed_after(state_path, DISPLAY_NAME, plan)?.is_some() { + return Ok(None); + } + let Some(current) = read_object(path, plan)? else { + return Ok(None); + }; + let managed_providers: Vec<&str> = managed["providers"] + .as_array() + .into_iter() + .flatten() + .filter_map(|provider| provider["name"].as_str()) + .collect(); + let written_by_agentdesktop: Vec<&str> = providers(¤t) + .filter(|provider| !provider["headers"][crate::llm_proxy::PAIRING_HEADER].is_null()) + .filter_map(|provider| provider["name"].as_str()) + .collect(); + for provider in providers(¤t) { + let Some(name) = provider["name"].as_str() else { + continue; + }; + if managed_providers.contains(&name) && !written_by_agentdesktop.contains(&name) { + return Ok(Some(format!( + "provider {name} exists but was not written by agentdesktop" + ))); + } + } + for model in current + .get("models") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let (Some(provider), Some(id)) = (model["provider"].as_str(), model["id"].as_str()) + && managed_providers.contains(&provider) + && !written_by_agentdesktop.contains(&provider) + { + return Ok(Some(format!( + "model {provider}/{id} exists under a managed provider name that was not written by agentdesktop" + ))); + } + } + Ok(None) +} + +/// Removal without a sidecar: entries under the managed names that carry +/// this daemon's own pairing value were written for this daemon (before its +/// sidecar was deleted), so they and the models under them are taken out by +/// key; the file keeps its mode and is never deleted here. Entries with +/// another pairing value, or none, are left alone, and a file that cannot be +/// read is skipped with a warning rather than failing the whole apply. +fn plan_remove_orphaned(path: &Path, pairing: &str, plan: &ReconcilePlan) -> anyhow::Result<()> { + let current = match read_object(path, plan) { + Ok(Some(current)) => current, + Ok(None) => return Ok(()), + Err(error) => { + warn!(error = %format!("{error:#}"), "skipping the Copilot CLI providers file"); + return Ok(()); + } + }; + let orphaned: Vec = providers(¤t) + .filter(|provider| provider["headers"][crate::llm_proxy::PAIRING_HEADER] == pairing) + .filter_map(|provider| provider["name"].as_str().map(str::to_owned)) + .filter(|name| name == PROVIDER_OPENAI || name == PROVIDER_ANTHROPIC) + .collect(); + if orphaned.is_empty() { + return Ok(()); + } + let mut remaining = Value::Object(current); + // 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..2975bab1 --- /dev/null +++ b/crates/agent/src/provider/copilot/tests.rs @@ -0,0 +1,1083 @@ +//! Tests for the GitHub Copilot CLI provider. + +use std::{ + collections::{BTreeMap, HashMap}, + ffi::OsString, + fs, + net::SocketAddr, +}; + +use agentdesktop_core::config::{CopilotConfig, CopilotModel, CopilotProvider, LlmGatewayConfig}; +use serde_json::{Value, json}; + +use super::discovery; +use super::reconcile::{managed_document, plan, providers_path}; +use crate::reconcile::ReconcilePlan; + +fn fixture() -> Value { + serde_json::from_str(include_str!("testdata/providers.lab.json")) + .expect("fixture is valid JSON") +} + +fn sample_config() -> CopilotConfig { + let mut models = BTreeMap::new(); + models.insert( + "gpt-4.1".to_owned(), + CopilotModel { + provider: CopilotProvider::Agentdesktop, + model_id: None, + extra: BTreeMap::from([("wireModel".to_owned(), json!("gpt-4.1-mini"))]), + }, + ); + models.insert( + "claude-haiku-4.5".to_owned(), + CopilotModel { + provider: CopilotProvider::AgentdesktopAnthropic, + model_id: None, + extra: BTreeMap::from([("wireModel".to_owned(), json!("claude-haiku-4-5"))]), + }, + ); + CopilotConfig { + use_llm_gateway: true, + models, + } +} + +fn sample_gateway() -> LlmGatewayConfig { + LlmGatewayConfig { + url: "https://gateway.example.com".parse().unwrap(), + authentication: None, + proxy_url: None, + github_oauth: None, + } +} + +const LISTEN: &str = "127.0.0.1:18095"; +const PAIRING: &str = "PAIRING-FIXTURE"; + +fn listen_addr() -> SocketAddr { + LISTEN.parse().unwrap() +} + +fn find_provider<'a>(document: &'a Value, name: &str) -> &'a Value { + document["providers"] + .as_array() + .unwrap_or_else(|| panic!("providers must be an array in {document}")) + .iter() + .find(|provider| provider["name"] == name) + .unwrap_or_else(|| panic!("provider {name} not found in {document}")) +} + +fn assert_user_entries_present(document: &Value) { + assert!( + document["providers"] + .as_array() + .unwrap() + .iter() + .any(|provider| provider["name"] == "my-own-openai"), + "user provider missing from {document}" + ); + assert!( + document["models"] + .as_array() + .unwrap() + .iter() + .any(|model| model["id"] == "gpt-mine"), + "user model missing from {document}" + ); +} + +fn our_provider_count(document: &Value) -> usize { + document["providers"] + .as_array() + .unwrap() + .iter() + .filter(|provider| { + matches!( + provider["name"].as_str(), + Some("agentdesktop") | Some("agentdesktop-anthropic") + ) + }) + .count() +} + +fn write_user_owned_document(path: &std::path::Path) { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .unwrap(); + } + fs::write( + path, + serde_json::to_vec_pretty(&json!({ + "providers": [{ + "name": "my-own-openai", + "type": "openai", + "baseUrl": "https://api.example.com/v1", + "apiKey": "sk-user", + }], + "models": [{"provider": "my-own-openai", "id": "gpt-mine"}], + })) + .unwrap(), + ) + .unwrap(); +} + +// --- Config-level model validation, at parse time --------------------------- + +fn parse_programs(yaml: &str) -> anyhow::Result { + agentdesktop_core::config::parse_daemon(yaml) +} + +#[test] +fn model_documents_default_provider_and_model_id() { + let config = parse_programs( + "llmGateway:\n url: https://gateway.example\nprograms:\n copilot:\n models:\n gpt-4.1:\n wireModel: gpt-4.1-mini\n", + ) + .unwrap(); + let models = config.programs.copilot.unwrap().model_documents(); + assert_eq!(models[0]["id"], "gpt-4.1"); + assert_eq!(models[0]["provider"], "agentdesktop"); + assert_eq!(models[0]["modelId"], "gpt-4.1"); + assert_eq!(models[0]["wireModel"], "gpt-4.1-mini"); +} + +#[test] +fn config_rejects_an_unknown_provider() { + let error = parse_programs( + "programs:\n copilot:\n models:\n x:\n provider: not-ours\n", + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("provider"), "{error:#}"); +} + +#[test] +fn config_rejects_a_reserved_or_secret_pass_through_key() { + for (key, needle) in [("id", "set by agentdesktop"), ("apiKey", "not allowed")] { + let error = parse_programs(&format!( + "programs:\n copilot:\n models:\n x:\n {key}: y\n" + )) + .unwrap_err(); + assert!(format!("{error:#}").contains(needle), "{key}: {error:#}"); + } +} + +#[test] +fn config_requires_a_model_with_a_gateway() { + let error = + parse_programs("llmGateway:\n url: https://gateway.example\nprograms:\n copilot: {}\n") + .unwrap_err(); + assert!(format!("{error:#}").contains("at least one"), "{error:#}"); + parse_programs("programs:\n copilot: {}\n").expect("no gateway, no model needed"); +} + +#[test] +fn config_rejects_sandbox_with_copilot() { + let error = + parse_programs("sandbox:\n filesystem: {}\nprograms:\n copilot: {}\n").unwrap_err(); + assert!(format!("{error:#}").contains("sandbox"), "{error:#}"); +} + +// --- Version discovery ------------------------------------------------------ + +#[test] +fn npm_version_reads_the_package_manifest_next_to_the_launcher() { + let prefix = tempfile::tempdir().unwrap(); + let package = prefix.path().join("lib/node_modules/@github/copilot"); + fs::create_dir_all(package.join("bin")).unwrap(); + fs::write( + package.join("package.json"), + r#"{"name":"@github/copilot","version":"1.0.88"}"#, + ) + .unwrap(); + fs::write(package.join("bin/copilot"), "#!/bin/sh\n").unwrap(); + fs::create_dir_all(prefix.path().join("bin")).unwrap(); + let launcher = prefix.path().join("bin/copilot"); + #[cfg(unix)] + std::os::unix::fs::symlink(package.join("bin/copilot"), &launcher).unwrap(); + #[cfg(not(unix))] + fs::copy(package.join("bin/copilot"), &launcher).unwrap(); + assert_eq!(discovery::npm_version(&launcher).as_deref(), Some("1.0.88")); +} + +#[test] +fn npm_version_ignores_a_manifest_of_another_package() { + let dir = tempfile::tempdir().unwrap(); + fs::write( + dir.path().join("package.json"), + r#"{"name":"@openai/codex","version":"9.9.9"}"#, + ) + .unwrap(); + fs::write(dir.path().join("copilot"), "#!/bin/sh\n").unwrap(); + assert_eq!(discovery::npm_version(&dir.path().join("copilot")), None); +} + +#[test] +fn npm_version_is_none_for_a_standalone_binary() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("copilot"), "#!/bin/sh\n").unwrap(); + assert_eq!(discovery::npm_version(&dir.path().join("copilot")), None); +} + +// --- File location precedence ----------------------------------------------- + +#[test] +fn providers_path_prefers_explicit_config_env_var() { + let mut vars = HashMap::new(); + vars.insert("COPILOT_PROVIDERS_CONFIG", "/explicit/providers.json"); + vars.insert("COPILOT_HOME", "/copilot-home"); + vars.insert("HOME", "/home/user"); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert_eq!( + providers_path(&lookup).unwrap(), + std::path::PathBuf::from("/explicit/providers.json") + ); +} + +#[test] +fn providers_path_falls_back_to_copilot_home() { + let mut vars = HashMap::new(); + vars.insert("COPILOT_HOME", "/copilot-home"); + vars.insert("HOME", "/home/user"); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert_eq!( + providers_path(&lookup).unwrap(), + std::path::PathBuf::from("/copilot-home/providers.json") + ); +} + +#[test] +fn providers_path_falls_back_to_home_dot_copilot() { + let mut vars = HashMap::new(); + vars.insert("HOME", "/home/user"); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert_eq!( + providers_path(&lookup).unwrap(), + std::path::PathBuf::from("/home/user/.copilot/providers.json") + ); +} + +#[test] +fn providers_path_falls_back_to_userprofile_when_home_is_unset() { + let mut vars = HashMap::new(); + vars.insert("USERPROFILE", r"C:\Users\user"); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert_eq!( + providers_path(&lookup).unwrap(), + std::path::PathBuf::from(r"C:\Users\user").join(".copilot/providers.json") + ); +} + +#[test] +fn providers_path_treats_empty_values_as_unset() { + let mut vars = HashMap::new(); + vars.insert("COPILOT_PROVIDERS_CONFIG", ""); + vars.insert("COPILOT_HOME", ""); + vars.insert("HOME", ""); + vars.insert("USERPROFILE", "/profile"); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert_eq!( + providers_path(&lookup).unwrap(), + std::path::PathBuf::from("/profile/.copilot/providers.json") + ); +} + +#[test] +fn providers_path_errors_without_any_home() { + let vars: HashMap<&str, &str> = HashMap::new(); + let lookup = |key: &str| vars.get(key).map(OsString::from); + assert!(providers_path(&lookup).is_err()); +} + +// --- managed_document shape ------------------------------------------------- + +#[test] +fn managed_document_matches_the_lab_fixture_shape() { + let config = sample_config(); + let document = managed_document(&config, listen_addr(), PAIRING).expect("managed document"); + let fixture = fixture(); + assert_eq!(document["providers"], fixture["providers"]); + assert_eq!(document["models"], fixture["models"]); +} + +// --- plan(): fresh create --------------------------------------------------- + +#[test] +fn fresh_file_is_created_matching_the_fixture_and_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .expect("plan succeeds"); + assert!(!changes.has_conflicts()); + changes.apply().expect("apply succeeds"); + + let written: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + let fixture = fixture(); + assert_eq!(written["providers"], fixture["providers"]); + assert_eq!(written["models"], fixture["models"]); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "a file we create must be owner-only" + ); + } + assert!( + super::super::json_merge::state_path(&path).exists(), + "a merge sidecar must be written alongside the created file" + ); +} + +// A missing Copilot directory is created owner-only along with the file; an +// existing directory keeps its mode. +#[test] +fn fresh_directory_is_created_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".copilot").join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + assert!(path.is_file()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + let existing = tempfile::tempdir().unwrap(); + let open_dir = existing.path().join("open"); + fs::create_dir(&open_dir).unwrap(); + fs::set_permissions(&open_dir, fs::Permissions::from_mode(0o755)).unwrap(); + let path = open_dir.join("providers.json"); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + assert_eq!( + fs::metadata(&open_dir).unwrap().permissions().mode() & 0o777, + 0o755, + "an existing directory keeps its mode" + ); + } +} + +// --- plan(): user entries survive, re-pairing replaces without duplicating --- + +#[test] +fn user_provider_and_model_survive_reapply_and_repairing_without_duplicates() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + // A user hand-adds their own BYOK provider and model. + let mut document: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + document["providers"].as_array_mut().unwrap().push(json!({ + "name": "my-own-openai", + "type": "openai", + "baseUrl": "https://api.example.com/v1", + "apiKey": "sk-user", + })); + document["models"] + .as_array_mut() + .unwrap() + .push(json!({"provider": "my-own-openai", "id": "gpt-mine"})); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + + // Re-apply unchanged: both the user's and our entries stay, none duplicated. + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + let after_reapply: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_user_entries_present(&after_reapply); + assert_eq!(our_provider_count(&after_reapply), 2); + + // Re-pairing (new listen address and pairing value, e.g. FRESH=1 + // re-enrollment): our entries are replaced in place, not duplicated; the + // user's entries are untouched. + let new_listen: SocketAddr = "127.0.0.1:18099".parse().unwrap(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((new_listen, "PAIRING-NEW")), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + let after_repair: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_user_entries_present(&after_repair); + assert_eq!( + our_provider_count(&after_repair), + 2, + "re-pairing must replace our providers, not duplicate them" + ); + let openai = find_provider(&after_repair, "agentdesktop"); + assert_eq!(openai["baseUrl"], "http://127.0.0.1:18099/copilot-cli/v1"); + assert_eq!(openai["headers"]["x-agentdesktop-pairing"], "PAIRING-NEW"); +} + +// --- plan(): removal -------------------------------------------------------- + +#[test] +fn removal_deletes_the_file_only_when_we_created_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + assert!(path.exists()); + + // programs.copilot absent from the pushed config. + let changes = ReconcilePlan::default(); + plan(&path, Some((listen_addr(), PAIRING)), None, &changes).unwrap(); + changes.apply().unwrap(); + + assert!( + !path.exists(), + "a file we created must be removed once nothing else remains" + ); + assert!(!super::super::json_merge::state_path(&path).exists()); +} + +#[test] +fn removal_keeps_a_preexisting_user_owned_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + write_user_owned_document(&path); + + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + let changes = ReconcilePlan::default(); + plan(&path, Some((listen_addr(), PAIRING)), None, &changes).unwrap(); + changes.apply().unwrap(); + + assert!( + path.exists(), + "a file we did not create must not be deleted" + ); + let remaining: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(remaining["providers"].as_array().unwrap().len(), 1); + assert_eq!(remaining["providers"][0]["name"], "my-own-openai"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "removal must not loosen the mode of a file that holds the user's keys" + ); + } +} + +#[test] +fn removal_when_use_llm_gateway_is_effectively_false() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + // `useLlmGateway: false`, or no top-level `llmGateway`: the caller passes + // the program's config with the gateway filtered to `None`. + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, None)), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + assert!(!path.exists()); +} + +#[test] +fn removal_when_the_loopback_proxy_is_unavailable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + // `ctx.llm_proxy` is `None` (bind failed, proxy off, no pairing) even + // though the program is still configured with `useLlmGateway: true`. + let changes = ReconcilePlan::default(); + plan(&path, None, Some((&config, Some(&gateway))), &changes).unwrap(); + changes.apply().unwrap(); + + assert!( + !path.exists(), + "our entries must be removed when the proxy is unavailable, even if programs.copilot is still configured" + ); +} + +// --- plan(): conflicts ------------------------------------------------------ + +#[test] +fn conflict_on_a_foreign_provider_named_agentdesktop() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "providers": [{ + "name": "agentdesktop", + "type": "openai", + "baseUrl": "https://not-ours.example.com/v1", + "apiKey": "sk-user", + }], + "models": [], + })) + .unwrap(), + ) + .unwrap(); + let before = fs::read(&path).unwrap(); + + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + + assert!(changes.has_conflicts()); + assert!(changes.render().contains("CONFLICT")); + assert!(changes.apply().is_err()); + assert_eq!( + fs::read(&path).unwrap(), + before, + "a conflict must write nothing" + ); +} + +#[test] +fn conflict_on_a_foreign_provider_named_agentdesktop_that_carries_a_model() { + // The user's own provider happens to be called "agentdesktop" (no pairing + // header) and carries a model: both are the user's, so the merge is refused. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "providers": [{"name": "agentdesktop", "type": "openai", "baseUrl": "https://example.invalid/v1"}], + "models": [{"provider": "agentdesktop", "id": "gpt-4.1"}], + })) + .unwrap(), + ) + .unwrap(); + let before = fs::read(&path).unwrap(); + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!(changes.has_conflicts()); + assert!( + changes.render().contains("provider agentdesktop exists"), + "{}", + changes.render() + ); + assert!(changes.apply().is_err()); + assert_eq!(fs::read(&path).unwrap(), before); +} + +#[test] +fn conflict_on_a_non_object_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + fs::write(&path, b"[]\n").unwrap(); + + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + + assert!(changes.has_conflicts()); + assert!(changes.apply().is_err()); + assert_eq!(fs::read(&path).unwrap(), b"[]\n"); +} + +// --- Provider::plan: system-mode rejection ---------------------------------- + +#[test] +fn system_mode_rejects_a_configured_program_before_writing_anything() { + use super::Copilot; + use crate::provider::{Provider, ReconcileContext}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = parse_programs( + "llmGateway:\n url: https://gateway.example\nprograms:\n copilot:\n models:\n gpt-4.1: {}\n", + ) + .unwrap(); + let context = |merge_user_settings: bool| ReconcileContext { + merge_user_settings, + credential_helper: dir.path().join("agentdesktop"), + socket: dir.path().join("agentdesktop.sock"), + llm_proxy: None, + }; + // System mode: no providers path, or a path but no user settings. + for provider in [ + Copilot { + providers_path: None, + }, + Copilot { + providers_path: Some(path.clone()), + }, + ] { + let error = match provider.plan(&context(false), &config) { + Ok(_) => panic!("system mode with programs.copilot configured must fail"), + Err(error) => error, + }; + assert!(error.to_string().contains("--user"), "{error}"); + assert!(error.to_string().contains("GitHub Copilot CLI"), "{error}"); + } + assert!(!path.exists(), "preflight failure must not write any files"); + // System mode with the program absent does nothing. + let absent = parse_programs("llmGateway:\n url: https://gateway.example\n").unwrap(); + let plan = Copilot { + providers_path: None, + } + .plan(&context(false), &absent) + .unwrap(); + assert!( + plan.render().trim().ends_with("changed"), + "{}", + plan.render() + ); + // User mode without the proxy: a removal is planned, nothing to remove here. + Copilot { + providers_path: Some(path.clone()), + } + .plan(&context(true), &config) + .unwrap() + .apply() + .unwrap(); + assert!(!path.exists()); +} + +// --- keyed ownership, created flag, redaction ------------------------------ + +fn apply_managed(path: &std::path::Path, listen: SocketAddr, pairing: &str) { + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + path, + Some((listen, pairing)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!(!changes.has_conflicts(), "{}", changes.render()); + changes.apply().unwrap(); +} + +fn apply_removal(path: &std::path::Path) -> ReconcilePlan { + let changes = ReconcilePlan::default(); + plan(path, Some((listen_addr(), PAIRING)), None, &changes).unwrap(); + changes +} + +fn read(path: &std::path::Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +#[test] +fn an_edited_managed_entry_is_replaced_and_removed_by_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + apply_managed(&path, listen_addr(), PAIRING); + // The user edits our model and our provider in place. + let mut document = read(&path); + for model in document["models"].as_array_mut().unwrap() { + if model["id"] == "gpt-4.1" { + model["wireModel"] = json!("gpt-4.1-edited"); + } + } + for provider in document["providers"].as_array_mut().unwrap() { + if provider["name"] == "agentdesktop" { + provider["baseUrl"] = json!("http://127.0.0.1:1/edited"); + } + } + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + // Re-apply with a new pairing: ours are replaced, not duplicated, edits gone. + apply_managed(&path, listen_addr(), "PAIRING-NEW"); + let after = read(&path); + assert_eq!(our_provider_count(&after), 2); + let openai = find_provider(&after, "agentdesktop"); + assert_eq!(openai["baseUrl"], "http://127.0.0.1:18095/copilot-cli/v1"); + assert_eq!(openai["headers"]["x-agentdesktop-pairing"], "PAIRING-NEW"); + let models = after["models"].as_array().unwrap(); + assert_eq!(models.iter().filter(|m| m["id"] == "gpt-4.1").count(), 1); + assert_eq!( + models.iter().find(|m| m["id"] == "gpt-4.1").unwrap()["wireModel"], + "gpt-4.1-mini" + ); + // Edit again, then remove the program: the edited entries go too. + let mut document = read(&path); + document["models"][0]["wireModel"] = json!("edited-again"); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + apply_removal(&path).apply().unwrap(); + assert!(!path.exists(), "nothing of ours may remain"); +} + +#[test] +fn a_user_model_with_a_managed_id_under_another_provider_is_not_a_conflict() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "providers": [{"name": "my-own", "type": "openai", "baseUrl": "https://example.invalid/v1"}], + "models": [{"provider": "my-own", "id": "gpt-4.1"}], + })) + .unwrap(), + ) + .unwrap(); + apply_managed(&path, listen_addr(), PAIRING); + let after = read(&path); + let gpt = after["models"] + .as_array() + .unwrap() + .iter() + .filter(|m| m["id"] == "gpt-4.1") + .count(); + assert_eq!( + gpt, 2, + "the user's my-own/gpt-4.1 and our agentdesktop/gpt-4.1 coexist" + ); + // A user model under OUR provider with another id is kept across re-apply. + let mut document = read(&path); + document["models"] + .as_array_mut() + .unwrap() + .push(json!({"provider": "agentdesktop", "id": "gpt-user"})); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + apply_managed(&path, listen_addr(), PAIRING); + assert!( + read(&path)["models"] + .as_array() + .unwrap() + .iter() + .any(|m| m["id"] == "gpt-user") + ); +} + +#[test] +fn an_orphaned_managed_provider_with_a_pairing_header_is_replaced_not_a_conflict() { + // The sidecar is gone (deleted, or the file came from another device), but + // the entry carries a pairing header: an agentdesktop wrote it, so it is + // replaced and owned again. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + apply_managed(&path, listen_addr(), "PAIRING-OLD"); + fs::remove_file(super::super::json_merge::state_path(&path)).unwrap(); + apply_managed(&path, listen_addr(), PAIRING); + let after = read(&path); + assert_eq!(our_provider_count(&after), 2); + assert_eq!( + find_provider(&after, "agentdesktop")["headers"]["x-agentdesktop-pairing"], + PAIRING + ); + assert!(super::super::json_merge::state_path(&path).exists()); +} + +#[test] +fn 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:#}" + ); +} 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..4272daf5 --- /dev/null +++ b/crates/agent/src/provider/vscode/reconcile.rs @@ -0,0 +1,268 @@ +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 { + 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); removing the managed chat language models" + ); + plan.inactive(crate::reconcile::PROXY_ABSENT_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 +/// this daemon's pairing value when there is none. +fn remove( + path: &Path, + state_path: &Path, + pairing: Option<&str>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + if !json_merge::plan_remove_with( + path, + state_path, + DESCRIPTION, + VsCode::DISPLAY_NAME, + options(), + plan, + )? && let Some(pairing) = pairing + { + plan_remove_orphaned(path, pairing, plan)?; + } + Ok(()) +} + +/// Whether a vendor entry carries the pairing header on one of its models, +/// which only an agentdesktop daemon writes. With `Some(value)`, only that +/// pairing value counts. +fn carries_pairing(vendor: &Value, value: Option<&str>) -> bool { + vendor["models"] + .as_array() + .into_iter() + .flatten() + .any(|model| { + let header = &model["requestHeaders"][crate::llm_proxy::PAIRING_HEADER]; + match value { + Some(value) => header == value, + None => !header.is_null(), + } + }) +} + +/// Before agentdesktop has written the file (no sidecar), a vendor entry named +/// `agentdesktop` without a pairing header on any model is the user's. +fn foreign_managed_entry( + path: &Path, + state_path: &Path, + plan: &ReconcilePlan, +) -> anyhow::Result> { + if json_merge::managed_after(state_path, VsCode::DISPLAY_NAME, plan)?.is_some() { + return Ok(None); + } + let Some(current) = read_array(path, plan)? else { + return Ok(None); + }; + for vendor in ¤t { + if vendor["name"] == VsCodeConfig::VENDOR_NAME && !carries_pairing(vendor, None) { + return Ok(Some(format!( + "vendor entry {} exists but was not written by agentdesktop", + VsCodeConfig::VENDOR_NAME + ))); + } + } + Ok(None) +} + +/// Removal without a sidecar: the vendor entry named `agentdesktop` whose +/// models carry this daemon's own pairing value was written for this daemon, +/// so it is taken out; the file keeps its mode and is never deleted here. A +/// file that cannot be read is skipped with a warning. +fn plan_remove_orphaned(path: &Path, pairing: &str, plan: &ReconcilePlan) -> anyhow::Result<()> { + let current = match read_array(path, plan) { + Ok(Some(current)) => current, + Ok(None) => return Ok(()), + Err(error) => { + warn!(error = %format!("{error:#}"), "skipping the VS Code chat language models file"); + return Ok(()); + } + }; + let remaining: Vec = current + .iter() + .filter(|vendor| { + !(vendor["name"] == VsCodeConfig::VENDOR_NAME && carries_pairing(vendor, Some(pairing))) + }) + .cloned() + .collect(); + if remaining.len() == current.len() { + return Ok(()); + } + let existing = plan.read(path)?; + let mut contents = serde_json::to_vec_pretty(&Value::Array(remaining)) + .with_context(|| format!("serialize {} {DESCRIPTION}", VsCode::DISPLAY_NAME))?; + contents.push(b'\n'); + if contents == existing { + return Ok(()); + } + plan.record_diff( + VsCode::DISPLAY_NAME, + DESCRIPTION, + "update", + path, + None, + None, + ); + let mode = json_merge::current_mode(path).unwrap_or(FILE_MODE); + plan.write_file(path, &contents, mode) +} + +fn read_array(path: &Path, plan: &ReconcilePlan) -> anyhow::Result>> { + let existing = match plan.read(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error).with_context(|| { + format!( + "read {} {DESCRIPTION} from {}", + VsCode::DISPLAY_NAME, + path.display() + ) + }); + } + }; + // Anything that is not a JSON array is left to the merge, which reports it. + Ok(match serde_json::from_slice::(&existing) { + Ok(Value::Array(array)) => Some(array), + _ => None, + }) +} diff --git a/crates/agent/src/provider/vscode/settings.rs b/crates/agent/src/provider/vscode/settings.rs new file mode 100644 index 00000000..5e0cfc21 --- /dev/null +++ b/crates/agent/src/provider/vscode/settings.rs @@ -0,0 +1,683 @@ +//! 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 (with a warning when the +/// proxy is the missing part), the same decision shape as `reconcile::plan`. +pub(super) fn plan( + path: &Path, + proxy: Option<(SocketAddr, &str)>, + configured: Option<(&VsCodeConfig, Option<&LlmGatewayConfig>)>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + let state_path = json_merge::state_path(path); + let active = matches!( + configured, + Some((config, Some(gateway))) + if config.copilot_chat == VsCodeCopilotChat::GithubModels && gateway.proxy_url.is_some() + ); + if !active { + return remove(path, &state_path, proxy, plan); + } + let Some((listen, pairing)) = proxy else { + 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); removing the managed settings" + ); + plan.inactive(crate::reconcile::PROXY_ABSENT_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(()) +} + +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..61bed628 --- /dev/null +++ b/crates/agent/src/provider/vscode/settings_tests.rs @@ -0,0 +1,1633 @@ +//! Tests for VS Code's user `settings.json` management under the +//! `githubModels` variant of `copilotChat`, mirroring `provider::vscode::tests` +//! (`chatLanguageModels.json`) wherever the shape is shared. +//! `github_models_removes_the_managed_chat_language_models_entry_through_reconcile_plan` +//! exercises `reconcile::plan` with a `githubModels` config, which must plan a +//! removal of the custom-models entry. + +use std::{collections::BTreeMap, fs, net::SocketAddr, path::PathBuf}; + +use agentdesktop_core::config::{LlmGatewayConfig, VsCodeConfig, VsCodeCopilotChat, VsCodeModel}; +use jsonc_parser::{ParseOptions, cst::CstRootNode}; +use serde_json::{Value, json}; + +use super::settings::{ + Removal, SETTINGS_STATE_VERSION, SettingsConflict, SettingsState, edit_settings, plan, + read_state, remove_settings, settings_path, +}; +use crate::reconcile::ReconcilePlan; + +/// The two keys the daemon owns in `settings.json`. +const OVERRIDE_KEY: &str = "github.copilot.advanced.debug.overrideCapiUrl"; +const CAPI_ALIAS_KEY: &str = "github.copilot.internal.capiUrl"; +const IGNORED_SETTINGS_KEY: &str = "settingsSync.ignoredSettings"; + +const LISTEN: &str = "127.0.0.1:18095"; +const PAIRING: &str = "PAIRING-FIXTURE"; + +fn listen_addr() -> SocketAddr { + LISTEN.parse().unwrap() +} + +fn override_url(listen: SocketAddr, pairing: &str) -> String { + format!("http://{listen}/vscode-copilot-capi/{pairing}") +} + +fn github_models_config() -> VsCodeConfig { + VsCodeConfig { + use_llm_gateway: true, + copilot_chat: VsCodeCopilotChat::GithubModels, + models: BTreeMap::new(), + } +} + +fn own_models_config() -> VsCodeConfig { + let mut models = BTreeMap::new(); + models.insert("gpt-4.1-mini".to_owned(), VsCodeModel::default()); + VsCodeConfig { + use_llm_gateway: true, + copilot_chat: VsCodeCopilotChat::OwnModels, + models, + } +} + +fn gateway_with_proxy() -> LlmGatewayConfig { + LlmGatewayConfig { + url: "https://gateway.example.com".parse().unwrap(), + authentication: None, + proxy_url: Some("https://gateway.example.com/copilot-proxy".parse().unwrap()), + github_oauth: None, + } +} + +fn gateway_without_proxy_url() -> LlmGatewayConfig { + LlmGatewayConfig { + proxy_url: None, + ..gateway_with_proxy() + } +} + +fn read(path: &std::path::Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +fn ignored_settings(document: &Value) -> Vec { + document[IGNORED_SETTINGS_KEY] + .as_array() + .unwrap_or_else(|| panic!("{IGNORED_SETTINGS_KEY} missing or not an array in {document}")) + .iter() + .map(|value| value.as_str().unwrap().to_owned()) + .collect() +} + +fn apply_managed( + path: &std::path::Path, + listen: SocketAddr, + pairing: &str, + config: &VsCodeConfig, + gateway: &LlmGatewayConfig, +) { + let changes = ReconcilePlan::default(); + plan( + path, + Some((listen, pairing)), + Some((config, Some(gateway))), + &changes, + ) + .unwrap(); + assert!(!changes.has_conflicts(), "{}", changes.render()); + changes.apply().unwrap(); +} + +fn apply_removal( + path: &std::path::Path, + proxy: Option<(SocketAddr, &str)>, + configured: Option<(&VsCodeConfig, Option<&LlmGatewayConfig>)>, +) -> ReconcilePlan { + let changes = ReconcilePlan::default(); + plan(path, proxy, configured, &changes).unwrap(); + changes +} + +fn write_user_settings(path: &std::path::Path, document: &Value) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, serde_json::to_vec_pretty(document).unwrap()).unwrap(); +} + +// --- Helpers for the pure-function (`edit_settings`/`remove_settings`) +// tests: the strict VS Code JSONC grammar (comments and trailing commas, +// nothing else of JSON5's permissive defaults) and a comment-tolerant way to +// check semantic content without caring about formatting. + +fn vscode_parse_options() -> ParseOptions { + ParseOptions { + allow_comments: true, + allow_trailing_commas: true, + allow_loose_object_property_names: false, + allow_missing_commas: false, + allow_single_quoted_strings: false, + allow_hexadecimal_numbers: false, + allow_unary_plus_numbers: false, + allow_bare_decimal_point_numbers: false, + allow_non_finite_numbers: false, + allow_extended_string_escapes: false, + } +} + +/// The semantic content of a (possibly commented) `settings.json` text, for +/// assertions that do not care about comments or formatting. +fn parse_jsonc(text: &str) -> Value { + CstRootNode::parse(text, &vscode_parse_options()) + .unwrap_or_else(|error| panic!("valid VS Code JSONC: {error}\n{text}")) + .to_serde_value() + .unwrap_or_else(|| panic!("object or array root:\n{text}")) +} + +/// Every line of `original` still appears, in the same relative order +/// (insertions between them are allowed), in `edited`: the +/// byte-identical-preservation check for a fixture with comments and a +/// trailing comma, where the exact bytes of the *inserted* lines are an +/// implementation detail. Splitting on `'\n'` (not `str::lines`, which +/// strips a trailing `\r`) keeps a CRLF line ending part of what must match. +fn assert_original_lines_preserved_in_order(original: &str, edited: &str) { + let mut edited_lines = edited.split('\n'); + for line in original.split('\n') { + assert!( + edited_lines.any(|candidate| candidate == line), + "line {line:?} missing or reordered after the edit:\n--- original ---\n{original}\n--- edited ---\n{edited}" + ); + } +} + +const MANAGED_URL: &str = "http://127.0.0.1:18095/vscode-copilot-capi/PAIRING-FIXTURE"; +const OTHER_URL: &str = "http://127.0.0.1:18099/vscode-copilot-capi/PAIRING-OTHER"; + +// --- Fresh apply ------------------------------------------------------- + +#[test] +fn fresh_file_gets_both_managed_keys_and_is_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let written = read(&path); + assert_eq!(written[OVERRIDE_KEY], override_url(listen_addr(), PAIRING)); + let ignored = ignored_settings(&written); + assert!(ignored.contains(&OVERRIDE_KEY.to_owned()), "{written}"); + assert!(ignored.contains(&CAPI_ALIAS_KEY.to_owned()), "{written}"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "settings.json carries the pairing and must be owner-only" + ); + } + assert!( + super::super::json_merge::state_path(&path).exists(), + "a merge sidecar must be written alongside the created file" + ); +} + +// --- User content survives re-apply and re-pairing -------------------- + +#[test] +fn user_keys_and_a_user_ignored_settings_entry_survive_reapply_and_repairing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings( + &path, + &json!({ + "editor.fontSize": 14, + IGNORED_SETTINGS_KEY: ["some.other.userSetting"], + }), + ); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let after_first = read(&path); + assert_eq!(after_first["editor.fontSize"], 14); + let ignored = ignored_settings(&after_first); + assert!(ignored.contains(&"some.other.userSetting".to_owned())); + assert!(ignored.contains(&OVERRIDE_KEY.to_owned())); + assert!(ignored.contains(&CAPI_ALIAS_KEY.to_owned())); + + // Re-apply unchanged: nothing lost, nothing duplicated. + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let after_reapply = read(&path); + assert_eq!(after_reapply["editor.fontSize"], 14); + let ignored = ignored_settings(&after_reapply); + assert_eq!( + ignored + .iter() + .filter(|entry| *entry == &"some.other.userSetting".to_owned()) + .count(), + 1 + ); + assert_eq!( + ignored + .iter() + .filter(|entry| *entry == &OVERRIDE_KEY.to_owned()) + .count(), + 1 + ); + + // Re-pairing (new listen address and pairing value, e.g. FRESH=1 + // re-enrollment): the URL changes, everything else is unchanged. + let new_listen: SocketAddr = "127.0.0.1:18099".parse().unwrap(); + apply_managed(&path, new_listen, "PAIRING-NEW", &config, &gateway); + let after_repair = read(&path); + assert_eq!(after_repair["editor.fontSize"], 14); + assert_eq!( + after_repair[OVERRIDE_KEY], + override_url(new_listen, "PAIRING-NEW") + ); + let ignored = ignored_settings(&after_repair); + assert!(ignored.contains(&"some.other.userSetting".to_owned())); +} + +// --- Removal: one test per trigger, each keeping the user's keys ----- + +#[test] +fn removal_program_absent_keeps_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `programs.vscode` removed entirely. + apply_removal(&path, Some((listen_addr(), PAIRING)), None) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_own_models_keeps_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let github_models = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &github_models, &gateway); + + // `copilotChat` switched back to `ownModels`: settings.json has nothing + // of this variant's to keep, even though the program is still configured + // and the gateway is still on. + let own_models = own_models_config(); + apply_removal( + &path, + Some((listen_addr(), PAIRING)), + Some((&own_models, Some(&gateway))), + ) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_use_llm_gateway_false_keeps_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `useLlmGateway: false`: the caller passes the program's config with the + // gateway filtered to `None`. + apply_removal(&path, Some((listen_addr(), PAIRING)), Some((&config, None))) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_no_top_level_gateway_keeps_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // No top-level `llmGateway` at all: same call shape as `useLlmGateway: + // false` (the caller filters either way), a distinct real-world trigger. + apply_removal(&path, Some((listen_addr(), PAIRING)), Some((&config, None))) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_no_proxy_url_keeps_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `llmGateway.proxyUrl` unset: the gateway is still configured and used, + // but this route has nowhere to forward to. + let gateway_without_proxy = gateway_without_proxy_url(); + apply_removal( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway_without_proxy))), + ) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_no_proxy_available_keeps_user_keys_with_a_warning() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `ctx.llm_proxy` is `None` (bind failed, proxy off) even though the + // program is still configured with a usable gateway. + apply_removal(&path, None, Some((&config, Some(&gateway)))) + .apply() + .unwrap(); + + let after = read(&path); + assert_eq!(after["editor.fontSize"], 14); + assert!(after.get(OVERRIDE_KEY).is_none(), "{after}"); +} + +#[test] +fn removal_of_a_created_and_emptied_file_deletes_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + assert!(path.exists()); + + // Emptied by hand afterwards: a file the merge created is removed whole, + // not left as an empty object. + fs::write(&path, "").unwrap(); + apply_removal(&path, Some((listen_addr(), PAIRING)), None) + .apply() + .unwrap(); + + assert!( + !path.exists(), + "a settings.json we created and the user then emptied must be removed" + ); + assert!(!super::super::json_merge::state_path(&path).exists()); +} + +// --- File mode --------------------------------------------------------- + +#[cfg(unix)] +#[test] +fn mode_0644_becomes_0600_on_merge_and_removal_keeps_the_mode_it_finds() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings(&path, &json!({"editor.fontSize": 14})); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "an existing 0644 settings.json must be tightened on merge" + ); + + // The user (or another tool) sets an unusual mode after the merge; + // removal must not loosen or tighten it further. + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + apply_removal(&path, Some((listen_addr(), PAIRING)), None) + .apply() + .unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640, + "removal must keep the mode it finds, not the merge's own 0600" + ); +} + +// --- Redaction --------------------------------------------------------- + +#[test] +fn redaction_keeps_the_pairing_out_of_render() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!( + !changes.render().contains(PAIRING), + "the plan report must not leak the pairing value: {}", + changes.render() + ); +} + +// --- Pre-existing user override (json_merge rollback) ----------------- + +#[test] +fn preexisting_user_override_capi_url_is_overwritten_and_restored() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + write_user_settings( + &path, + &json!({ OVERRIDE_KEY: "http://someone-else.example:9999/other" }), + ); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let after_merge = read(&path); + assert_eq!( + after_merge[OVERRIDE_KEY], + override_url(listen_addr(), PAIRING) + ); + + apply_removal(&path, Some((listen_addr(), PAIRING)), None) + .apply() + .unwrap(); + let after_removal = read(&path); + assert_eq!( + after_removal[OVERRIDE_KEY], "http://someone-else.example:9999/other", + "the user's own overrideCapiUrl, set before agentdesktop ever wrote the file, must be restored" + ); +} + +// --- Comments and trailing commas are preserved, not a conflict ------ +// +// The in-place edit keeps comments and trailing commas, so applying a file +// that has them succeeds. + +#[test] +fn comments_and_trailing_commas_are_preserved() { + let config = github_models_config(); + let gateway = gateway_with_proxy(); + for (name, contents) in [ + ( + "comment", + "{\n // a user comment\n \"editor.fontSize\": 14\n}\n", + ), + ("trailing comma", "{\n \"editor.fontSize\": 14,\n}\n"), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + fs::write(&path, contents).unwrap(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!(!changes.has_conflicts(), "{name}: {}", changes.render()); + changes.apply().unwrap(); + + let written = fs::read_to_string(&path).unwrap(); + assert_original_lines_preserved_in_order(contents, &written); + let document = parse_jsonc(&written); + assert_eq!( + document[OVERRIDE_KEY], + override_url(listen_addr(), PAIRING), + "{name}: {document}" + ); + } +} + +// --- settings_path per OS ---------------------------------------------- + +#[cfg(target_os = "linux")] +#[test] +fn settings_path_on_linux() { + assert_eq!( + settings_path(std::path::Path::new("/home/user")), + PathBuf::from("/home/user/.config/Code/User/settings.json") + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn settings_path_on_macos() { + assert_eq!( + settings_path(std::path::Path::new("/Users/user")), + PathBuf::from("/Users/user/Library/Application Support/Code/User/settings.json") + ); +} + +#[cfg(windows)] +#[test] +fn settings_path_on_windows() { + assert_eq!( + settings_path(std::path::Path::new(r"C:\Users\user")), + PathBuf::from(r"C:\Users\user").join("AppData/Roaming/Code/User/settings.json") + ); +} + +// --- githubModels removes the managed chatLanguageModels.json entry, through +// the existing reconcile::plan. ----------------------------------------- + +#[test] +fn github_models_removes_the_managed_chat_language_models_entry_through_reconcile_plan() { + use super::reconcile::{chat_models_path, plan as chat_models_plan}; + + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + let path = chat_models_path(home); + let mut own_models = own_models_config(); + own_models.copilot_chat = VsCodeCopilotChat::OwnModels; + let gateway = gateway_with_proxy(); + + // The `ownModels` entry exists (a previous revision had it configured). + let changes = ReconcilePlan::default(); + chat_models_plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&own_models, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + assert!(path.exists()); + + // `copilotChat` switches to `githubModels`: `reconcile::plan` is called + // with `configured = None`, since this file has nothing to manage under + // that variant. It must plan (and apply) a removal. + let changes = ReconcilePlan::default(); + chat_models_plan(&path, Some((listen_addr(), PAIRING)), None, &changes).unwrap(); + assert!(!changes.has_conflicts(), "{}", changes.render()); + changes.apply().unwrap(); + + assert!( + !path.exists(), + "chatLanguageModels.json must be cleaned up once githubModels is active" + ); +} + +// Owned override: these exercise `edit_settings`/`remove_settings` directly, +// chained the way `plan` chains them across a re-apply. + +// A hand edit of the managed URL after the first apply is drift, not the +// user's value; a re-apply replaces it and removal must not put it back. +#[test] +fn hand_edited_override_is_replaced_on_reapply_and_not_restored_on_removal() { + let fixture = "{\n \"editor.fontSize\": 14\n}\n"; + + let (text1, state1) = edit_settings(Some(fixture), MANAGED_URL, None).unwrap(); + assert_eq!(state1.override_before, None, "no user override existed"); + + // Hand edit of our URL, then a daemon restart (re-apply against the + // sidecar from the first apply). + let text2 = text1.replacen(MANAGED_URL, OTHER_URL, 1); + assert_ne!(text2, text1, "the fixture must contain MANAGED_URL once"); + let (text3, state2) = edit_settings(Some(&text2), MANAGED_URL, Some(&state1)).unwrap(); + assert_eq!( + parse_jsonc(&text3)[OVERRIDE_KEY], + MANAGED_URL, + "hand edit replaced: {text3}" + ); + assert_eq!( + state2.override_before, None, + "still no user override to restore" + ); + + // Removal: the key goes entirely; the hand-edited value was never "the + // user's" to restore. + let removed = remove_settings(&text3, Some(&state2), None).unwrap(); + let Removal::Write(remaining_text) = removed else { + panic!("editor.fontSize must survive: {removed:?}"); + }; + let remaining = parse_jsonc(&remaining_text); + assert!( + remaining.get(OVERRIDE_KEY).is_none(), + "stale override restored: {remaining}" + ); + assert!( + remaining.get(IGNORED_SETTINGS_KEY).is_none(), + "the ignore array we created must go too: {remaining}" + ); + assert_eq!(remaining["editor.fontSize"], 14); +} + +// The user's own override from before the first apply survives a hand edit of +// the managed URL and comes back on removal. +#[test] +fn user_override_from_before_the_first_apply_survives_a_hand_edit_and_returns_on_removal() { + let users_own = "https://capi.example.invalid"; + let fixture = format!("{{\n \"{OVERRIDE_KEY}\": \"{users_own}\"\n}}\n"); + + let (text1, state1) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + assert_eq!( + state1.override_before, + Some(Value::String(users_own.to_owned())), + "the pre-existing user override must be captured" + ); + assert_eq!(parse_jsonc(&text1)[OVERRIDE_KEY], MANAGED_URL); + + let text2 = text1.replacen(MANAGED_URL, OTHER_URL, 1); + let (text3, state2) = edit_settings(Some(&text2), MANAGED_URL, Some(&state1)).unwrap(); + assert_eq!( + parse_jsonc(&text3)[OVERRIDE_KEY], + MANAGED_URL, + "hand edit replaced: {text3}" + ); + assert_eq!( + state2.override_before, + Some(Value::String(users_own.to_owned())), + "overrideBefore is kept across the re-apply, not recomputed from the hand edit" + ); + + let removed = remove_settings(&text3, Some(&state2), None).unwrap(); + let Removal::Write(remaining_text) = removed else { + panic!("the user's own override must survive: {removed:?}"); + }; + assert_eq!( + parse_jsonc(&remaining_text)[OVERRIDE_KEY], + users_own, + "the user's own value comes back: {remaining_text}" + ); +} + +// ========================================================================= +// Pure-function tests on `edit_settings`/`remove_settings`/`read_state`, no +// filesystem. +// ========================================================================= + +/// Comments (line and block), a trailing comma, CRLF, and a user key on +/// either side of where the override will be appended (no override or +/// `settingsSync.ignoredSettings` yet). +const FIXTURE_NO_OVERRIDE: &str = "{\r\n // top comment\r\n \"a.userSetting\": 1,\r\n /* block comment */\r\n \"z.userSetting\": true,\r\n}\r\n"; + +/// A pre-existing override, with a same-line comment, sandwiched between two +/// user keys. +const FIXTURE_WITH_OVERRIDE: &str = "{\n \"a.userSetting\": 1,\n \"github.copilot.advanced.debug.overrideCapiUrl\": \"https://old.example/capi\", // old\n \"z.userSetting\": true\n}\n"; +const FIXTURE_WITH_OVERRIDE_VALUE: &str = "https://old.example/capi"; + +// --- edit_settings: fresh apply ---------------------------------------- + +#[test] +fn an_absent_file_is_created_as_the_exact_golden_text() { + let (edited, state) = edit_settings(None, MANAGED_URL, None).unwrap(); + assert_eq!( + edited, + format!( + "{{\n \"{OVERRIDE_KEY}\": \"{MANAGED_URL}\",\n \"{IGNORED_SETTINGS_KEY}\": [\"{OVERRIDE_KEY}\", \"{CAPI_ALIAS_KEY}\"]\n}}\n" + ), + "the golden text is fixed exactly" + ); + assert!(state.created, "the file did not exist before this apply"); + assert!(state.ignored_created); + assert_eq!( + state.added_ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + assert_eq!(state.override_before, None); + assert_eq!(state.version, SETTINGS_STATE_VERSION); +} + +#[test] +fn an_empty_or_whitespace_only_existing_file_is_filled_like_absent_but_is_not_created() { + for text in ["", " \n\t"] { + let (edited, state) = edit_settings(Some(text), MANAGED_URL, None).unwrap(); + assert!( + !state.created, + "the file existed (just empty), unlike an absent one: {text:?}" + ); + let document = parse_jsonc(&edited); + assert_eq!(document[OVERRIDE_KEY], MANAGED_URL); + assert_eq!( + ignored_settings(&document), + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + } +} + +#[test] +fn byte_identical_preservation_for_a_commented_crlf_trailing_comma_fixture_with_the_override_appended() + { + let (edited, state) = edit_settings(Some(FIXTURE_NO_OVERRIDE), MANAGED_URL, None).unwrap(); + assert_original_lines_preserved_in_order(FIXTURE_NO_OVERRIDE, &edited); + + let document = parse_jsonc(&edited); + assert_eq!(document[OVERRIDE_KEY], MANAGED_URL); + assert_eq!( + ignored_settings(&document), + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + assert!(!state.created); + assert!(state.ignored_created); + assert_eq!(state.override_before, None); +} + +#[test] +fn override_is_replaced_in_place_at_its_position_keeping_its_same_line_comment() { + let (edited, state) = edit_settings(Some(FIXTURE_WITH_OVERRIDE), MANAGED_URL, None).unwrap(); + assert!( + edited.contains(&format!("\"{OVERRIDE_KEY}\": \"{MANAGED_URL}\", // old")), + "the value changes but the same-line comment travels with it: {edited}" + ); + let a_index = edited.find("\"a.userSetting\"").unwrap(); + let override_index = edited.find(&format!("\"{OVERRIDE_KEY}\": ")).unwrap(); + let z_index = edited.find("\"z.userSetting\"").unwrap(); + assert!( + a_index < override_index && override_index < z_index, + "the override must stay between the two user keys, only its value changing: {edited}" + ); + assert_eq!( + state.override_before, + Some(Value::String(FIXTURE_WITH_OVERRIDE_VALUE.to_owned())) + ); +} + +#[test] +fn ignore_array_created_extended_and_user_entries_kept_in_order() { + // Absent entirely: created fresh, with only our two entries. + let (edited, state) = edit_settings(Some("{\n \"a\": 1\n}\n"), MANAGED_URL, None).unwrap(); + let ignored = ignored_settings(&parse_jsonc(&edited)); + assert_eq!( + ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + assert!(state.ignored_created); + assert_eq!(state.added_ignored, ignored); + + // Present with a user entry: extended, the user's entry kept first. + let fixture = + format!("{{\n \"a\": 1,\n \"{IGNORED_SETTINGS_KEY}\": [\"custom.setting\"]\n}}\n"); + let (edited, state) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + let ignored = ignored_settings(&parse_jsonc(&edited)); + assert_eq!( + ignored, + vec![ + "custom.setting".to_owned(), + OVERRIDE_KEY.to_owned(), + CAPI_ALIAS_KEY.to_owned(), + ] + ); + assert!(!state.ignored_created, "the array already existed"); + assert_eq!( + state.added_ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); +} + +#[test] +fn a_user_held_ignore_entry_of_ours_is_not_added_and_survives_removal() { + let fixture = + format!("{{\n \"a\": 1,\n \"{IGNORED_SETTINGS_KEY}\": [\"{OVERRIDE_KEY}\"]\n}}\n"); + let (edited, state) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + assert_eq!( + state.added_ignored, + vec![CAPI_ALIAS_KEY.to_owned()], + "the override key was already user-held, only the alias is ours to add" + ); + let ignored = ignored_settings(&parse_jsonc(&edited)); + assert_eq!( + ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + + let removed = remove_settings(&edited, Some(&state), None).unwrap(); + let Removal::Write(remaining) = removed else { + panic!("editor.fontSize and the user's own entry must survive: {removed:?}"); + }; + let document = parse_jsonc(&remaining); + assert_eq!( + ignored_settings(&document), + vec![OVERRIDE_KEY.to_owned()], + "the user's own entry must survive removal, the alias we added must not: {document}" + ); +} + +#[test] +fn a_users_own_empty_ignored_settings_array_comes_back_as_empty() { + let fixture = format!("{{\n \"a\": 1,\n \"{IGNORED_SETTINGS_KEY}\": []\n}}\n"); + let (edited, state) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + assert!( + !state.ignored_created, + "the property already existed, empty" + ); + assert_eq!( + state.added_ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + + let removed = remove_settings(&edited, Some(&state), None).unwrap(); + let Removal::Write(remaining) = removed else { + panic!("{removed:?}"); + }; + let document = parse_jsonc(&remaining); + assert_eq!( + document[IGNORED_SETTINGS_KEY], + json!([]), + "the user's own empty array must come back, not be removed: {document}" + ); +} + +#[test] +fn a_null_override_is_restored_as_null() { + let fixture = format!("{{\n \"{OVERRIDE_KEY}\": null\n}}\n"); + let (edited, state) = edit_settings(Some(&fixture), MANAGED_URL, None).unwrap(); + assert_eq!( + state.override_before, + Some(Value::Null), + "an explicit null must be kept as Some(Value::Null), not treated as absent" + ); + assert_eq!(parse_jsonc(&edited)[OVERRIDE_KEY], MANAGED_URL); + + let removed = remove_settings(&edited, Some(&state), None).unwrap(); + let Removal::Write(remaining) = removed else { + panic!("{removed:?}"); + }; + assert_eq!( + parse_jsonc(&remaining)[OVERRIDE_KEY], + Value::Null, + "null must be restored, not the key removed" + ); +} + +// --- remove_settings: restoring what edit_settings changed -------------- + +#[test] +fn removal_restores_the_pre_apply_fixture_byte_for_byte() { + let (edited, state) = edit_settings(Some(FIXTURE_NO_OVERRIDE), MANAGED_URL, None).unwrap(); + let removed = remove_settings(&edited, Some(&state), None).unwrap(); + assert_eq!(removed, Removal::Write(FIXTURE_NO_OVERRIDE.to_owned())); +} + +#[test] +fn users_own_override_is_restored_with_its_same_line_comment() { + let (edited, state) = edit_settings(Some(FIXTURE_WITH_OVERRIDE), MANAGED_URL, None).unwrap(); + assert_eq!( + state.override_before, + Some(Value::String(FIXTURE_WITH_OVERRIDE_VALUE.to_owned())) + ); + + let removed = remove_settings(&edited, Some(&state), None).unwrap(); + assert_eq!(removed, Removal::Write(FIXTURE_WITH_OVERRIDE.to_owned())); +} + +#[test] +fn user_deleted_the_override_after_apply_removal_appends_override_before() { + let (edited, state) = edit_settings(Some(FIXTURE_WITH_OVERRIDE), MANAGED_URL, None).unwrap(); + assert_eq!( + state.override_before, + Some(Value::String(FIXTURE_WITH_OVERRIDE_VALUE.to_owned())) + ); + + // The user deletes the whole override property line by hand; whatever we + // appended (the ignore entries) stays. + let without_override: String = edited + .lines() + .filter(|line| { + !line + .trim_start() + .starts_with(&format!("\"{OVERRIDE_KEY}\":")) + }) + .collect::>() + .join("\n") + + "\n"; + assert!( + parse_jsonc(&without_override).get(OVERRIDE_KEY).is_none(), + "the fixture must no longer have the property" + ); + + let removed = remove_settings(&without_override, Some(&state), None).unwrap(); + let Removal::Write(remaining) = removed else { + panic!("{removed:?}"); + }; + let document = parse_jsonc(&remaining); + assert_eq!( + document[OVERRIDE_KEY], FIXTURE_WITH_OVERRIDE_VALUE, + "the property must be appended back, not left missing: {document}" + ); +} + +#[test] +fn a_created_file_is_deleted_when_only_braces_or_nothing_remain_but_kept_with_a_comment() { + let state = SettingsState { + version: SETTINGS_STATE_VERSION, + created: true, + override_before: None, + added_ignored: vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()], + ignored_created: true, + }; + for remaining in ["{}\n", "", " \n"] { + assert_eq!( + remove_settings(remaining, Some(&state), None).unwrap(), + Removal::Delete, + "{remaining:?}" + ); + } + // A comment is something to keep, even though nothing else is left. + let with_comment = "{\n // still here\n}\n"; + let removed = remove_settings(with_comment, Some(&state), None).unwrap(); + assert!( + !matches!(removed, Removal::Delete), + "a remaining comment must keep the file: {removed:?}" + ); +} + +// --- Conflicts ----------------------------------------------------------- + +#[test] +fn edit_settings_conflicts() { + let cases: Vec<(&str, String, SettingsConflict)> = vec![ + ( + "parse error", + "{ \"a\": }".to_owned(), + SettingsConflict::Parse, + ), + ( + "missing comma", + "{ \"a\": 1 \"b\": 2 }".to_owned(), + SettingsConflict::Parse, + ), + ( + "single-quoted string", + "{ 'a': 1 }".to_owned(), + SettingsConflict::Parse, + ), + ( + "non-object root", + "[1, 2]".to_owned(), + SettingsConflict::NotAnObject, + ), + ( + "non-array ignoredSettings", + format!("{{ \"{IGNORED_SETTINGS_KEY}\": 5 }}"), + SettingsConflict::IgnoredNotArray, + ), + ( + "duplicate override key", + format!("{{ \"{OVERRIDE_KEY}\": \"a\", \"{OVERRIDE_KEY}\": \"b\" }}"), + SettingsConflict::DuplicateKey, + ), + ]; + for (name, text, expected) in cases { + assert_eq!( + edit_settings(Some(&text), MANAGED_URL, None), + Err(expected), + "{name}: {text}" + ); + } +} + +#[test] +fn remove_settings_without_a_sidecar_treats_an_unparseable_file_as_unchanged_not_a_conflict() { + // Matches today's `plan_remove_orphaned` (settings.rs:157-163): skipped + // with a debug line, not a hard conflict, since there is no sidecar to + // say anything was ever ours. + assert_eq!( + remove_settings("{ \"a\": }", None, Some(MANAGED_URL)), + Ok(Removal::Unchanged) + ); +} + +#[test] +fn remove_settings_with_a_sidecar_treats_an_unparseable_file_as_a_conflict() { + let state = SettingsState { + version: SETTINGS_STATE_VERSION, + created: false, + override_before: None, + added_ignored: vec![OVERRIDE_KEY.to_owned()], + ignored_created: false, + }; + assert_eq!( + remove_settings("{ \"a\": }", Some(&state), None), + Err(SettingsConflict::Parse) + ); +} + +// --- read_state: the earlier whole-document sidecar upgraded -------------- + +#[test] +fn a_v1_sidecar_is_read_and_upgraded() { + let before = json!({ "a": 1, OVERRIDE_KEY: FIXTURE_WITH_OVERRIDE_VALUE }); + let after = json!({ + "a": 1, + OVERRIDE_KEY: MANAGED_URL, + IGNORED_SETTINGS_KEY: [OVERRIDE_KEY, CAPI_ALIAS_KEY], + }); + let v1 = json!({ "created": false, "before": before, "after": after }); + let bytes = serde_json::to_vec(&v1).unwrap(); + + let state = read_state(&bytes).expect("a v1 sidecar (no `version` field) must be recognized"); + assert_eq!(state.version, SETTINGS_STATE_VERSION); + assert!(!state.created); + assert_eq!( + state.override_before, + Some(Value::String(FIXTURE_WITH_OVERRIDE_VALUE.to_owned())), + "override_before = before[OVERRIDE_KEY]" + ); + assert_eq!( + state.added_ignored, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()], + "our two entries were not in `before.settingsSync.ignoredSettings`" + ); + assert!( + state.ignored_created, + "`before.settingsSync.ignoredSettings` was absent" + ); +} + +#[test] +fn read_state_is_none_for_bytes_that_are_neither_v1_nor_v2() { + assert!(read_state(b"not json at all").is_none()); +} + +// --- SettingsState's own serde shape (exact camelCase, deny_unknown_fields, +// an explicit null override_before kept distinct from an absent one) ----- + +#[test] +fn settings_state_serializes_camelcase_and_keeps_an_explicit_null_override_before() { + let with_null = SettingsState { + version: SETTINGS_STATE_VERSION, + created: false, + override_before: Some(Value::Null), + added_ignored: vec![OVERRIDE_KEY.to_owned()], + ignored_created: true, + }; + let json = serde_json::to_value(&with_null).unwrap(); + assert_eq!( + json, + json!({ + "version": 2, + "created": false, + "overrideBefore": null, + "addedIgnored": [OVERRIDE_KEY], + "ignoredCreated": true, + }) + ); + let round_tripped: SettingsState = serde_json::from_value(json).unwrap(); + assert_eq!(round_tripped, with_null); + + // Absent (no user override existed before the first apply): the field is + // omitted entirely, not written as `null`. + let without = SettingsState { + override_before: None, + ..with_null.clone() + }; + let json = serde_json::to_value(&without).unwrap(); + assert!( + json.get("overrideBefore").is_none(), + "skip_serializing_if must omit an absent override: {json}" + ); + let round_tripped: SettingsState = serde_json::from_value(json).unwrap(); + assert_eq!(round_tripped.override_before, None); + + // deny_unknown_fields: a stray field must not silently pass through. + let mut with_extra = serde_json::to_value(&without).unwrap(); + with_extra["unexpectedField"] = json!(true); + assert!(serde_json::from_value::(with_extra).is_err()); +} + +// ========================================================================= +// Plan-level tests (existing helpers): the v2 sidecar and the in-place edit +// through `plan`/`apply`. +// ========================================================================= + +#[test] +fn unchanged_reapply_writes_nothing_and_leaves_the_sidecar_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let settings_before = fs::read(&path).unwrap(); + let state_path = super::super::json_merge::state_path(&path); + let sidecar_before = fs::read(&state_path).unwrap(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + assert_eq!( + fs::read(&path).unwrap(), + settings_before, + "settings.json must not be rewritten when nothing changes" + ); + assert_eq!( + fs::read(&state_path).unwrap(), + sidecar_before, + "the sidecar must only be rewritten when its content changes" + ); +} + +#[cfg(unix)] +#[test] +fn unchanged_reapply_on_a_0664_file_is_byte_identical_but_tightened_to_0600() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let bytes_before = fs::read(&path).unwrap(); + + // VS Code's own save: same bytes, looser mode. + fs::set_permissions(&path, fs::Permissions::from_mode(0o664)).unwrap(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + assert_eq!( + fs::read(&path).unwrap(), + bytes_before, + "bytes must be identical" + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "a file looser than 0600 must be tightened even when nothing else changed" + ); +} + +#[test] +fn v2_sidecar_has_the_exact_camelcase_fields_after_a_fresh_apply() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("settings.json"); + let config = github_models_config(); + let gateway = gateway_with_proxy(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let state_path = super::super::json_merge::state_path(&path); + let sidecar: Value = serde_json::from_slice(&fs::read(&state_path).unwrap()).unwrap(); + assert_eq!(sidecar["version"], 2, "{sidecar}"); + assert_eq!(sidecar["created"], true, "{sidecar}"); + assert!(sidecar.get("overrideBefore").is_none(), "{sidecar}"); + assert_eq!(sidecar["ignoredCreated"], true, "{sidecar}"); + let added: Vec = sidecar["addedIgnored"] + .as_array() + .unwrap_or_else(|| panic!("{sidecar}")) + .iter() + .map(|value| value.as_str().unwrap().to_owned()) + .collect(); + assert_eq!( + added, + vec![OVERRIDE_KEY.to_owned(), CAPI_ALIAS_KEY.to_owned()] + ); + + let typed: SettingsState = serde_json::from_value(sidecar).unwrap(); + assert_eq!(typed.version, SETTINGS_STATE_VERSION); +} + +/// 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); +} diff --git a/crates/agent/src/provider/vscode/testdata/Dockerfile b/crates/agent/src/provider/vscode/testdata/Dockerfile index 75eb70a0..23fd6e3a 100644 --- a/crates/agent/src/provider/vscode/testdata/Dockerfile +++ b/crates/agent/src/provider/vscode/testdata/Dockerfile @@ -12,7 +12,9 @@ RUN case "$(uname -m)" in x86_64) arch=x64 ;; aarch64) arch=arm64 ;; *) exit 1 ; && mkdir -p /opt/vscode \ && tar -xzf /tmp/code.tar.gz --strip-components=1 -C /opt/vscode \ && rm /tmp/code.tar.gz \ - && ln -s /opt/vscode/bin/code /usr/local/bin/code -ENV HOME=/home/tester + && ln -s /opt/vscode/bin/code /usr/local/bin/code \ + && mkdir -p /run/user/1000 && chown tester:tester /run/user/1000 +ENV HOME=/home/tester \ + XDG_RUNTIME_DIR=/run/user/1000 WORKDIR /home/tester/project CMD ["sleep", "infinity"] diff --git a/crates/agent/src/provider/vscode/testdata/chatLanguageModels.lab.json b/crates/agent/src/provider/vscode/testdata/chatLanguageModels.lab.json new file mode 100644 index 00000000..a9cb21b4 --- /dev/null +++ b/crates/agent/src/provider/vscode/testdata/chatLanguageModels.lab.json @@ -0,0 +1,20 @@ +[ + { + "name": "agentdesktop", + "vendor": "customendpoint", + "apiKey": "unused", + "apiType": "chat-completions", + "models": [ + { + "id": "gpt-4.1-mini", + "name": "gpt-4.1-mini (agentdesktop)", + "url": "http://127.0.0.1:18095/vscode-copilot/v1/chat/completions", + "toolCalling": true, + "vision": false, + "maxInputTokens": 128000, + "maxOutputTokens": 16000, + "requestHeaders": { "x-agentdesktop-pairing": "PAIRING-FIXTURE" } + } + ] + } +] diff --git a/crates/agent/src/provider/vscode/tests.rs b/crates/agent/src/provider/vscode/tests.rs new file mode 100644 index 00000000..f8439319 --- /dev/null +++ b/crates/agent/src/provider/vscode/tests.rs @@ -0,0 +1,732 @@ +//! Tests for the VS Code Copilot Chat (own models) provider. Closely mirrors +//! `provider::copilot::tests`, which this feature builds on. + +use std::{collections::BTreeMap, fs, net::SocketAddr, path::PathBuf}; + +use agentdesktop_core::config::{LlmGatewayConfig, VsCodeConfig, VsCodeModel}; +use serde_json::{Value, json}; + +use super::reconcile::{chat_models_path, managed_document, plan}; +use crate::reconcile::ReconcilePlan; + +fn fixture() -> Value { + serde_json::from_str(include_str!("testdata/chatLanguageModels.lab.json")) + .expect("fixture is valid JSON") +} + +fn sample_config() -> VsCodeConfig { + let mut models = BTreeMap::new(); + models.insert( + "gpt-4.1-mini".to_owned(), + VsCodeModel { + name: None, + tool_calling: true, + vision: false, + max_input_tokens: Some(128_000), + max_output_tokens: Some(16_000), + extra: BTreeMap::new(), + }, + ); + VsCodeConfig { + use_llm_gateway: true, + copilot_chat: Default::default(), + models, + } +} + +fn sample_gateway() -> LlmGatewayConfig { + LlmGatewayConfig { + url: "https://gateway.example.com".parse().unwrap(), + authentication: None, + proxy_url: None, + github_oauth: None, + } +} + +const LISTEN: &str = "127.0.0.1:18095"; +const PAIRING: &str = "PAIRING-FIXTURE"; + +fn listen_addr() -> SocketAddr { + LISTEN.parse().unwrap() +} + +fn find_vendor<'a>(document: &'a Value, name: &str) -> &'a Value { + document + .as_array() + .unwrap_or_else(|| panic!("chatLanguageModels.json root must be an array in {document}")) + .iter() + .find(|vendor| vendor["name"] == name) + .unwrap_or_else(|| panic!("vendor {name} not found in {document}")) +} + +fn our_vendor_count(document: &Value) -> usize { + document + .as_array() + .unwrap() + .iter() + .filter(|vendor| vendor["name"] == VsCodeConfig::VENDOR_NAME) + .count() +} + +fn write_user_owned_document(path: &std::path::Path) { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .unwrap(); + } + fs::write( + path, + serde_json::to_vec_pretty(&json!([{ + "name": "my-own-vendor", + "vendor": "customendpoint", + "apiKey": "sk-user", + "apiType": "chat-completions", + "models": [{ + "id": "gpt-mine", + "url": "https://example.com/v1/chat/completions", + }], + }])) + .unwrap(), + ) + .unwrap(); +} + +fn apply_managed( + path: &std::path::Path, + listen: SocketAddr, + pairing: &str, + config: &VsCodeConfig, + gateway: &LlmGatewayConfig, +) { + let changes = ReconcilePlan::default(); + plan( + path, + Some((listen, pairing)), + Some((config, Some(gateway))), + &changes, + ) + .unwrap(); + assert!(!changes.has_conflicts(), "{}", changes.render()); + changes.apply().unwrap(); +} + +fn apply_removal(path: &std::path::Path) -> ReconcilePlan { + let changes = ReconcilePlan::default(); + plan(path, Some((listen_addr(), PAIRING)), None, &changes).unwrap(); + changes +} + +fn read(path: &std::path::Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +fn parse_programs(yaml: &str) -> anyhow::Result { + agentdesktop_core::config::parse_daemon(yaml) +} + +// --- Config-level validation, at parse time --------------------------------- + +#[test] +fn config_rejects_a_reserved_pass_through_key() { + for key in ["id", "url", "requestHeaders"] { + let error = parse_programs(&format!( + "programs:\n vscode:\n models:\n x:\n {key}: y\n" + )) + .unwrap_err(); + assert!( + format!("{error:#}").contains("set by agentdesktop"), + "{key}: {error:#}" + ); + } +} + +#[test] +fn config_requires_a_model_with_a_gateway() { + let error = + parse_programs("llmGateway:\n url: https://gateway.example\nprograms:\n vscode: {}\n") + .unwrap_err(); + assert!(format!("{error:#}").contains("at least one"), "{error:#}"); + parse_programs("programs:\n vscode: {}\n").expect("no gateway, no model needed"); +} + +#[test] +fn config_rejects_sandbox_with_vscode() { + let error = + parse_programs("sandbox:\n filesystem: {}\nprograms:\n vscode: {}\n").unwrap_err(); + assert!(format!("{error:#}").contains("sandbox"), "{error:#}"); +} + +#[test] +fn daemon_vscode_config_overrides_the_default_path() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.yaml"); + fs::write( + &path, + "daemon:\n user: true\n vscode:\n config: /custom/chatLanguageModels.json\n", + ) + .unwrap(); + let config = agentdesktop_core::config::load_daemon(&path).unwrap(); + assert_eq!( + config.daemon.unwrap().vscode.config, + Some(PathBuf::from("/custom/chatLanguageModels.json")) + ); +} + +// --- githubModels config validation ----------------------------------------- + +#[test] +fn config_rejects_github_models_with_a_non_empty_models_map() { + let error = parse_programs( + "programs:\n vscode:\n copilotChat: githubModels\n models:\n x: {}\n", + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("not allowed when copilotChat is githubModels"), + "{error:#}" + ); + parse_programs("programs:\n vscode:\n copilotChat: githubModels\n") + .expect("githubModels with no models is valid"); +} + +#[test] +fn config_rejects_github_models_without_proxy_url_only_when_the_gateway_is_used() { + // The gateway is used (useLlmGateway defaults to true) and configured, + // but llmGateway.proxyUrl is unset: rejected. + let error = parse_programs( + "llmGateway:\n url: https://gateway.example\nprograms:\n vscode:\n copilotChat: githubModels\n", + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("llmGateway.proxyUrl"), + "{error:#}" + ); + + // useLlmGateway: false: the gateway is configured but not used by this + // program, so no proxyUrl is required. + parse_programs( + "llmGateway:\n url: https://gateway.example\nprograms:\n vscode:\n copilotChat: githubModels\n useLlmGateway: false\n", + ) + .expect("githubModels with useLlmGateway: false needs no proxyUrl"); + + // No top-level llmGateway at all: same, no proxyUrl required. + parse_programs("programs:\n vscode:\n copilotChat: githubModels\n") + .expect("githubModels with no llmGateway needs no proxyUrl"); +} + +#[test] +fn config_accepts_github_models_when_both_the_gateway_and_proxy_url_are_set() { + // Both values matched in validate_daemon: use_llm_gateway true, gateway + // configured, and llmGateway.proxyUrl set. + parse_programs( + "llmGateway:\n url: https://gateway.example\n proxyUrl: https://gateway.example/copilot-proxy\nprograms:\n vscode:\n copilotChat: githubModels\n", + ) + .expect("githubModels with llmGateway.proxyUrl set is valid"); +} + +// --- chat_models_path per OS ------------------------------------------------ + +#[cfg(target_os = "linux")] +#[test] +fn chat_models_path_on_linux() { + assert_eq!( + chat_models_path(std::path::Path::new("/home/user")), + PathBuf::from("/home/user/.config/Code/User/chatLanguageModels.json") + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn chat_models_path_on_macos() { + assert_eq!( + chat_models_path(std::path::Path::new("/Users/user")), + PathBuf::from("/Users/user/Library/Application Support/Code/User/chatLanguageModels.json") + ); +} + +#[cfg(windows)] +#[test] +fn chat_models_path_on_windows() { + assert_eq!( + chat_models_path(std::path::Path::new(r"C:\Users\user")), + PathBuf::from(r"C:\Users\user").join("AppData/Roaming/Code/User/chatLanguageModels.json") + ); +} + +// --- managed_document shape ------------------------------------------------- + +#[test] +fn managed_document_matches_the_lab_fixture_shape() { + let config = sample_config(); + let document = managed_document(&config, listen_addr(), PAIRING).expect("managed document"); + assert_eq!(document, fixture()); +} + +// --- plan(): fresh create --------------------------------------------------- + +#[test] +fn fresh_file_is_created_matching_the_fixture_and_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .expect("plan succeeds"); + assert!(!changes.has_conflicts()); + changes.apply().expect("apply succeeds"); + + let written = read(&path); + assert_eq!(written, fixture()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "a file we create must be owner-only" + ); + } + assert!( + super::super::json_merge::state_path(&path).exists(), + "a merge sidecar must be written alongside the created file" + ); +} + +// --- plan(): user entries survive, re-pairing replaces without duplicating --- + +#[test] +fn user_vendor_entry_survives_reapply_and_repairing_without_duplicates() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // A user hand-adds their own custom-endpoint vendor entry. + let mut document = read(&path); + document.as_array_mut().unwrap().push(json!({ + "name": "my-own-vendor", + "vendor": "customendpoint", + "apiKey": "sk-user", + "apiType": "chat-completions", + "models": [{"id": "gpt-mine", "url": "https://example.com/v1/chat/completions"}], + })); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + + // Re-apply unchanged: both the user's and our entry stay, ours not duplicated. + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let after_reapply = read(&path); + assert!( + after_reapply + .as_array() + .unwrap() + .iter() + .any(|vendor| vendor["name"] == "my-own-vendor"), + "user vendor missing from {after_reapply}" + ); + assert_eq!(our_vendor_count(&after_reapply), 1); + + // Re-pairing (new listen address and pairing value, e.g. FRESH=1 + // re-enrollment): our entry is replaced in place, not duplicated. + let new_listen: SocketAddr = "127.0.0.1:18099".parse().unwrap(); + apply_managed(&path, new_listen, "PAIRING-NEW", &config, &gateway); + let after_repair = read(&path); + assert!( + after_repair + .as_array() + .unwrap() + .iter() + .any(|vendor| vendor["name"] == "my-own-vendor") + ); + assert_eq!( + our_vendor_count(&after_repair), + 1, + "re-pairing must replace our vendor entry, not duplicate it" + ); + let ours = find_vendor(&after_repair, VsCodeConfig::VENDOR_NAME); + assert_eq!( + ours["models"][0]["url"], + "http://127.0.0.1:18099/vscode-copilot/v1/chat/completions" + ); + assert_eq!( + ours["models"][0]["requestHeaders"]["x-agentdesktop-pairing"], + "PAIRING-NEW" + ); +} + +// --- plan(): hand edits and dropped models are undone ----------------------- + +#[test] +fn a_hand_edit_to_the_managed_vendor_entry_is_replaced() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + let mut document = read(&path); + for vendor in document.as_array_mut().unwrap() { + if vendor["name"] == VsCodeConfig::VENDOR_NAME { + vendor["models"][0]["name"] = json!("edited-by-hand"); + vendor["apiType"] = json!("edited"); + } + } + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + + // Re-apply with a new pairing: the whole vendor entry is replaced, not + // merged field by field; the edit is gone and no duplicate appears. + apply_managed(&path, listen_addr(), "PAIRING-NEW", &config, &gateway); + let after = read(&path); + assert_eq!(our_vendor_count(&after), 1); + let ours = find_vendor(&after, VsCodeConfig::VENDOR_NAME); + assert_eq!(ours["apiType"], "chat-completions"); + assert_eq!(ours["models"][0]["name"], "gpt-4.1-mini (agentdesktop)"); +} + +#[test] +fn a_dropped_model_disappears() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let mut config = sample_config(); + config.models.insert( + "gpt-4.1".to_owned(), + VsCodeModel { + name: None, + tool_calling: true, + vision: false, + max_input_tokens: None, + max_output_tokens: None, + extra: BTreeMap::new(), + }, + ); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let first = read(&path); + let ours = find_vendor(&first, VsCodeConfig::VENDOR_NAME); + assert_eq!(ours["models"].as_array().unwrap().len(), 2); + + // The admin drops the extra model from the config. + config.models.remove("gpt-4.1"); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + let after = read(&path); + let ours = find_vendor(&after, VsCodeConfig::VENDOR_NAME); + let models = ours["models"].as_array().unwrap(); + assert_eq!(models.len(), 1, "{after}"); + assert!(!models.iter().any(|model| model["id"] == "gpt-4.1")); +} + +// --- plan(): removal -------------------------------------------------------- + +#[test] +fn removal_deletes_the_file_only_when_we_created_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + assert!(path.exists()); + + apply_removal(&path).apply().unwrap(); + + assert!( + !path.exists(), + "a file we created must be removed once nothing else remains" + ); + assert!(!super::super::json_merge::state_path(&path).exists()); +} + +#[test] +fn removal_keeps_a_preexisting_user_owned_file_and_its_mode() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + write_user_owned_document(&path); + + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + apply_removal(&path).apply().unwrap(); + + assert!( + path.exists(), + "a file we did not create must not be deleted" + ); + let remaining = read(&path); + assert_eq!(remaining.as_array().unwrap().len(), 1); + assert_eq!(remaining[0]["name"], "my-own-vendor"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "removal must not loosen the mode of a file that may hold the user's own keys" + ); + } +} + +#[test] +fn removal_when_use_llm_gateway_is_effectively_false() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `useLlmGateway: false`, or no top-level `llmGateway`: the caller passes + // the program's config with the gateway filtered to `None`. + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, None)), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + + assert!(!path.exists()); +} + +#[test] +fn removal_when_the_loopback_proxy_is_unavailable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + + // `ctx.llm_proxy` is `None` (bind failed, proxy off, no pairing) even + // though the program is still configured with `useLlmGateway: true`. + let changes = ReconcilePlan::default(); + plan(&path, None, Some((&config, Some(&gateway))), &changes).unwrap(); + changes.apply().unwrap(); + + assert!( + !path.exists(), + "our vendor entry must be removed when the proxy is unavailable, even if programs.vscode is still configured" + ); +} + +#[test] +fn removal_without_a_sidecar_strips_entries_that_carry_a_pairing_header() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + write_user_owned_document(&path); + let config = sample_config(); + let gateway = sample_gateway(); + apply_managed(&path, listen_addr(), PAIRING, &config, &gateway); + fs::remove_file(super::super::json_merge::state_path(&path)).unwrap(); + + let removal = apply_removal(&path); + assert!(!removal.render().contains(PAIRING)); + removal.apply().unwrap(); + let after = read(&path); + assert_eq!(our_vendor_count(&after), 0, "{after}"); + assert!( + after + .as_array() + .unwrap() + .iter() + .all(|vendor| vendor["name"] == "my-own-vendor") + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } +} + +// --- plan(): conflicts ------------------------------------------------------ + +#[test] +fn conflict_on_a_foreign_vendor_entry_named_agentdesktop() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!([{ + "name": "agentdesktop", + "vendor": "customendpoint", + "apiKey": "sk-user", + "apiType": "chat-completions", + "models": [{"id": "not-ours", "url": "https://not-ours.example.com/v1/chat/completions"}], + }])) + .unwrap(), + ) + .unwrap(); + let before = fs::read(&path).unwrap(); + + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + + assert!(changes.has_conflicts()); + assert!(changes.render().contains("CONFLICT")); + assert!(changes.apply().is_err()); + assert_eq!( + fs::read(&path).unwrap(), + before, + "a conflict must write nothing" + ); +} + +#[test] +fn conflict_on_a_non_array_non_object_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + // The spec's conflict shape is neither array nor object; an object root + // (`{}`) is deliberately not used here, since it is the merge's own + // "nothing here yet" representation for an object-shaped managed + // document and would not exercise the same code path. + fs::write(&path, b"42\n").unwrap(); + + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + + assert!(changes.has_conflicts()); + assert!(changes.apply().is_err()); + assert_eq!(fs::read(&path).unwrap(), b"42\n"); +} + +// --- Provider::plan: system-mode rejection ---------------------------------- + +#[test] +fn system_mode_rejects_a_configured_program_before_writing_anything() { + use super::VsCode; + use crate::provider::{Provider, ReconcileContext}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("chatLanguageModels.json"); + let config = parse_programs( + "llmGateway:\n url: https://gateway.example\nprograms:\n vscode:\n models:\n gpt-4.1-mini: {}\n", + ) + .unwrap(); + let context = |merge_user_settings: bool| ReconcileContext { + merge_user_settings, + credential_helper: dir.path().join("agentdesktop"), + socket: dir.path().join("agentdesktop.sock"), + llm_proxy: None, + }; + // System mode: no chat models path, or a path but no user settings. + for provider in [ + VsCode { + chat_models_path: None, + settings_path: None, + }, + VsCode { + chat_models_path: Some(path.clone()), + settings_path: Some(path.with_file_name("settings.json")), + }, + ] { + let error = match provider.plan(&context(false), &config) { + Ok(_) => panic!("system mode with programs.vscode configured must fail"), + Err(error) => error, + }; + assert!(error.to_string().contains("--user"), "{error}"); + assert!(error.to_string().contains("VS Code"), "{error}"); + } + assert!(!path.exists(), "preflight failure must not write any files"); +} + +#[test] +fn config_rejects_reserved_and_secret_keys_in_any_case() { + for key in ["ID", "Url", "RequestHeaders", "apikey", "APIKEY", "ApiKey"] { + let error = parse_programs(&format!( + "programs:\n vscode:\n models:\n x:\n {key}: y\n" + )) + .unwrap_err(); + assert!( + format!("{error:#}").contains("models.x."), + "{key}: {error:#}" + ); + } + parse_programs("programs:\n vscode:\n models:\n x:\n apiKeyHint: y\n") + .expect("a key that merely starts like a reserved one passes"); +} + +#[test] +fn github_models_through_the_provider_removes_the_chat_models_entry_and_writes_settings() { + use super::VsCode; + use crate::provider::{Provider, ReconcileContext}; + let dir = tempfile::tempdir().unwrap(); + let chat_models = dir.path().join("chatLanguageModels.json"); + let settings = dir.path().join("settings.json"); + let pairing: std::sync::Arc = "PAIRING-FIXTURE".into(); + let context = ReconcileContext { + merge_user_settings: true, + credential_helper: dir.path().join("agentdesktop"), + socket: dir.path().join("agentdesktop.sock"), + llm_proxy: Some(crate::llm_proxy::LlmProxyContext { + address: "127.0.0.1:4000".parse().unwrap(), + pairing: pairing.clone(), + }), + }; + let provider = VsCode { + chat_models_path: Some(chat_models.clone()), + settings_path: Some(settings.clone()), + }; + // First `ownModels`: the custom-models entry exists. + let own = parse_programs( + "llmGateway:\n url: https://gateway.example\n proxyUrl: https://gateway.example/copilot-proxy\nprograms:\n vscode:\n models:\n gpt-4.1-mini: {}\n", + ) + .unwrap(); + provider.plan(&context, &own).unwrap().apply().unwrap(); + assert!(chat_models.exists()); + assert!(!settings.exists()); + // Then `githubModels`: the entry goes (the file was ours, so it is deleted) + // and the settings override arrives. + let github = parse_programs( + "llmGateway:\n url: https://gateway.example\n proxyUrl: https://gateway.example/copilot-proxy\nprograms:\n vscode:\n copilotChat: githubModels\n", + ) + .unwrap(); + provider.plan(&context, &github).unwrap().apply().unwrap(); + assert!( + !chat_models.exists(), + "the daemon-created custom-models file is removed under githubModels" + ); + let written: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); + assert_eq!( + written["github.copilot.advanced.debug.overrideCapiUrl"], + "http://127.0.0.1:4000/vscode-copilot-capi/PAIRING-FIXTURE" + ); + // And back to `ownModels`: the override goes, the entry returns. + provider.plan(&context, &own).unwrap().apply().unwrap(); + assert!(chat_models.exists()); + assert!( + !settings.exists(), + "a settings file the daemon created and emptied is deleted" + ); +} diff --git a/crates/agent/src/reconcile/mod.rs b/crates/agent/src/reconcile/mod.rs index d546bdd1..3ca87d01 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,446 @@ 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 reason recorded for a program that uses the gateway while the local +/// LLM proxy is absent. +pub(crate) const PROXY_ABSENT_REASON: &str = "local LLM proxy not available; see llmProxy.error in daemon-info, or set daemon.llmProxy.listen"; + +/// 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 +684,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 +737,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 +778,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 +790,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 +988,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 +1032,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 +1185,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..f8092388 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,56 @@ 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()); + } + + 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 +130,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 +272,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 +320,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..ffd5c6b0 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))] @@ -565,6 +606,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 +622,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 +750,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 +1163,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 +1177,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 +1220,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 +1420,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 +1546,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/frontend/controller/src/stories/fixtures.ts b/frontend/controller/src/stories/fixtures.ts index 6cc1dce6..bcac53d6 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", + 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 = { diff --git a/frontend/controller/src/styles.css b/frontend/controller/src/styles.css index 50b5824b..abd2d664 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; } @@ -1230,6 +1247,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..60e103a1 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,7 @@ export type SandboxConfigDocument = { export type DaemonConfigDocument = { llmGateway?: { url: string; + proxyUrl?: string; authentication?: { type: string; audience?: string; diff --git a/frontend/controller/src/views/ConfigurationView.tsx b/frontend/controller/src/views/ConfigurationView.tsx index ad4ad3f3..351fa0a3 100644 --- a/frontend/controller/src/views/ConfigurationView.tsx +++ b/frontend/controller/src/views/ConfigurationView.tsx @@ -24,6 +24,7 @@ export function ConfigurationView({ const initializedFromController = useRef(false); const [gateway, setGateway] = useState(true); const [gatewayUrl, setGatewayUrl] = useState("https://gateway.example.com"); + const [gatewayProxyUrl, setGatewayProxyUrl] = useState(""); const [controllerJwt, setControllerJwt] = useState(true); const [audience, setAudience] = useState("agentgateway"); const [sessionNewTelemetry, setSessionNewTelemetry] = useState(false); @@ -48,6 +49,7 @@ export function ConfigurationView({ const yaml = daemonConfigYaml({ gateway, gatewayUrl, + gatewayProxyUrl, controllerJwt, audience, sandboxEnabled, @@ -75,6 +77,7 @@ export function ConfigurationView({ setGateway(Boolean(llmGateway)); if (llmGateway) { setGatewayUrl(llmGateway.url); + setGatewayProxyUrl(llmGateway.proxyUrl ?? ""); setControllerJwt(llmGateway.authentication?.type === "controllerJwt"); setAudience(llmGateway.authentication?.audience ?? "agentgateway"); } @@ -177,6 +180,21 @@ export function ConfigurationView({ onChange={(event) => setGatewayUrl(event.target.value)} /> +