diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ed83918..34f9fa06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: save: ${{ github.ref == 'refs/heads/main' }} - name: Install nextest - uses: taiki-e/install-action@d438492cf8a250514fa2d34b30bc3c0dc37c65ff # v2 + uses: taiki-e/install-action@94c31af3204a9f15ab40b35ad084410b905bbc73 # v2 with: tool: nextest @@ -98,7 +98,7 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master with: toolchain: "1.98" @@ -110,7 +110,7 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} - name: Install nextest - uses: taiki-e/install-action@d438492cf8a250514fa2d34b30bc3c0dc37c65ff # v2 + uses: taiki-e/install-action@94c31af3204a9f15ab40b35ad084410b905bbc73 # v2 with: tool: nextest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39198bc2..9d1c2b18 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,11 +76,11 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Build and push by digest id: build - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . platforms: ${{ matrix.platform }} @@ -129,7 +129,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Create and push manifest working-directory: ${{ runner.temp }}/digests @@ -254,7 +254,7 @@ jobs: librsvg2-dev - name: Install Rust target - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master with: toolchain: "1.98" targets: ${{ matrix.target }} @@ -306,15 +306,17 @@ jobs: - name: Remove macOS signing identity if: always() && matrix.os_name == 'darwin' + timeout-minutes: 2 shell: bash run: | - if [ -f "${RUNNER_TEMP}/agentdesktop-signing.pem" ]; then - sudo security remove-trusted-cert -d "${RUNNER_TEMP}/agentdesktop-signing.pem" - fi + # remove-trusted-cert can hang on headless macOS runners: + # https://github.com/actions/runner-images/issues/12116 + # Leave public trust metadata to disposable GitHub-hosted VM teardown. + # Delete exported files first, even if keychain deletion later fails. + rm -f "${RUNNER_TEMP}/agentdesktop-signing.pem" "${RUNNER_TEMP}/agentdesktop-signing.p12" if [ -f "${RUNNER_TEMP}/agentdesktop-signing.keychain-db" ]; then security delete-keychain "${RUNNER_TEMP}/agentdesktop-signing.keychain-db" fi - rm -f "${RUNNER_TEMP}/agentdesktop-signing.pem" "${RUNNER_TEMP}/agentdesktop-signing.p12" - name: Upload binary uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -352,7 +354,7 @@ jobs: node-version-file: frontend/.nvmrc - name: Install Rust target - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master with: toolchain: "1.98" targets: ${{ matrix.target }} @@ -479,15 +481,17 @@ jobs: - name: Remove macOS signing identity if: always() + timeout-minutes: 2 shell: bash run: | - if [ -f "${RUNNER_TEMP}/agentdesktop-signing.pem" ]; then - sudo security remove-trusted-cert -d "${RUNNER_TEMP}/agentdesktop-signing.pem" - fi + # remove-trusted-cert can hang on headless macOS runners: + # https://github.com/actions/runner-images/issues/12116 + # Leave public trust metadata to disposable GitHub-hosted VM teardown. + # Delete exported files first, even if keychain deletion later fails. + rm -f "${RUNNER_TEMP}/agentdesktop-signing.pem" "${RUNNER_TEMP}/agentdesktop-signing.p12" if [ -f "${RUNNER_TEMP}/agentdesktop-signing.keychain-db" ]; then security delete-keychain "${RUNNER_TEMP}/agentdesktop-signing.keychain-db" fi - rm -f "${RUNNER_TEMP}/agentdesktop-signing.pem" "${RUNNER_TEMP}/agentdesktop-signing.p12" - name: Upload PKG (self-signed application) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -525,7 +529,7 @@ jobs: node-version-file: frontend/.nvmrc - name: Install Rust target - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master + uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master with: toolchain: "1.98" targets: ${{ matrix.target }} diff --git a/Cargo.lock b/Cargo.lock index e3fc6092..549064c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,7 +80,7 @@ dependencies = [ "testcontainers", "tokio", "tokio-stream", - "toml 1.1.5+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "tonic", "tracing", "tracing-subscriber", @@ -1028,9 +1028,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -1038,9 +1038,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -1050,9 +1050,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -1617,7 +1617,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.5+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "vswhom", "winreg", ] @@ -3052,9 +3052,9 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "11.0.0" +version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" dependencies = [ "aws-lc-rs", "base64 0.22.1", @@ -3865,9 +3865,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "open" -version = "5.4.3" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ "is-wsl", "libc", @@ -4484,9 +4484,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -4608,11 +4608,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "encoding_rs", "futures-core", @@ -5797,9 +5797,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.5" +version = "2.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +checksum = "6fa5bacdb9bbad5954af3d1bd6cf6ae9192cab1b2e270f4a07f904610b9e85f4" dependencies = [ "anyhow", "bytes", @@ -5911,9 +5911,9 @@ dependencies = [ [[package]] name = "tauri-plugin-single-instance" -version = "2.4.4" +version = "2.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0cb5c412a5071b69bab6a6df1583cbb89460d4a83b6a24769b08d15b6b1e1" +checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0" dependencies = [ "serde", "serde_json", @@ -6007,7 +6007,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.20", - "toml 1.1.5+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "url", "urlpattern", "uuid", @@ -6022,7 +6022,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.5+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", ] [[package]] @@ -6273,9 +6273,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.5+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap 2.14.1", "serde_core", @@ -6770,9 +6770,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", diff --git a/Dockerfile b/Dockerfile index 0e72e93a..231b43db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.26 -FROM docker.io/library/node:26.8.1-bookworm AS ui +FROM docker.io/library/node:26.9.0-bookworm AS ui WORKDIR /app/frontend ENV CI=true COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./ @@ -15,7 +15,7 @@ COPY images/ /app/images/ RUN --mount=type=cache,id=agentdesktop-pnpm,target=/pnpm/store \ pnpm --filter @agentdesktop/controller-web build -FROM docker.io/library/rust:1.98.0-trixie AS builder +FROM docker.io/library/rust:1.98.1-trixie AS builder ARG TARGETARCH ARG BUILD_PROFILE=release WORKDIR /app diff --git a/README.md b/README.md index 6dc490d3..202f6e02 100644 --- a/README.md +++ b/README.md @@ -96,33 +96,137 @@ for prerequisites, test credentials, and a walkthrough of the local services. ## Local LLM proxy -For clients without an API-key helper, start the daemon's optional loopback proxy: +For clients without an API-key helper, enable the daemon's optional loopback proxy +in the local configuration file (startup settings are local only, not delivered by +the controller). The proxy is available in `--user` mode only: it hands out the +current user's gateway credential, so a system daemon rejects +`daemon.llmProxy.listen`. If the address cannot be bound at startup, the daemon +keeps running without the proxy and reports `llmProxy.bound: false` with the +reason in daemon-info (the desktop app's "Daemon information" panel, or +`curl --unix-socket http://localhost/v1/daemon-info`). If the proxy +stops later, the daemon also keeps running; that is only visible in the daemon +log until the next restart: + +```yaml +daemon: + llmProxy: + listen: 127.0.0.1:4000 +``` + +Then start the daemon as usual: ```sh -agentdesktop daemon --user --config examples/standalone/config.yaml \ - --llm-proxy-listen 127.0.0.1:4000 +agentdesktop daemon --user --config examples/standalone/config.yaml ``` Point the client's model endpoint at `http://127.0.0.1:4000/v1/chat/completions`, -`/v1/messages`, or `/v1/responses`, according to the protocol it uses. The proxy -streams bodies unchanged to `llmGateway.url` and adds the credential from the same -authentication flow used by the daemon's API-key helpers. Login and refresh stay -with the existing authentication implementation. No client API key is needed. -Incoming authorization/API-key headers are replaced, and upstream HTTP errors and +`/v1/messages`, or `/v1/responses`, according to the protocol it uses, and give it +the pairing header (see **Pairing** below). The proxy forwards request bodies +(buffered, see below) to `llmGateway.proxyUrl` (or `llmGateway.url` when unset), +streams the response back, and adds the credential from the same authentication +flow used by the daemon's API-key helpers. Login and refresh stay with the +existing authentication implementation. No client API key is needed. Incoming +authorization/API-key headers are replaced, and upstream HTTP errors and redirects are passed back without following redirects. The incoming path and query are appended to the gateway base URL: with -`llmGateway.url: https://gateway.example/prefix`, `/v1/messages` goes to -`https://gateway.example/prefix/v1/messages`. Avoid repeating `/v1` in both URLs. +`llmGateway.proxyUrl: https://gateway.example/prefix` (or `url`, when `proxyUrl` +is unset), `/v1/messages` goes to `https://gateway.example/prefix/v1/messages`. +Avoid repeating `/v1` in both URLs. The proxy does not discover models or rewrite model IDs; configure those in the client. VS Code's built-in **Custom Endpoint** provider can use this endpoint -without the experimental extension in `vscode/`. +without the experimental extension in `vscode/`. The provider reads +`chatLanguageModels.json` in the VS Code user directory (on Linux +`~/.config/Code/User/`); keep the file owner-only, since it carries the pairing +value. Give it the pairing value in the model's `requestHeaders` (the file's +`apiKey` is not sent reliably without an OS keyring): + +```json +[ + { + "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:4000/v1/chat/completions", + "toolCalling": true, "vision": false, + "maxInputTokens": 128000, "maxOutputTokens": 16000, + "requestHeaders": { "x-agentdesktop-pairing": "" } } + ] + } +] +``` -Controller JWT authentication uses client ID `vscode` by default; include it in -`allowedClientIds`, or choose another ID with `--llm-proxy-client-id`. The listen -address must be loopback. Local processes can use the proxy's credentials; -browser-origin requests and CONNECT tunnels are rejected. The listener and active -proxy connections stop with the daemon. +Upgrade note: before the pairing existed, this route accepted any local caller. +A manual setup made against that version now gets `403 pairing_invalid` until +the header is added. + +Controller JWT authentication uses client ID `vscode` by default on the +prefix-less route; choose another ID with `daemon.llmProxy.clientId`. Every client +ID the proxy presents (`vscode` or your choice, plus `copilot-cli` and +`vscode-copilot` for the routes below) must be in the controller's +`allowedClientIds`. With `authentication.type: oidc` the client ID has no effect: the +OIDC token is the same for every route. The listen address must be loopback. + +**Routes.** Besides the prefix-less route (the manual shape used above, with the +credential mode from `llmGateway.githubOAuth` and the upstream from +`llmGateway.proxyUrl`), the proxy serves fixed path prefixes for the managed +Copilot programs, one per program route, so the client ID is decided by the file +the daemon writes and never by request headers. The prefix is stripped before +forwarding. A path whose first segment looks like a route name without matching +one exactly is refused with `404 route_unknown` rather than served as the +prefix-less route; this also applies to a gateway sub-path that happens to start +with a route name (for example `/vscode-copilot-proxy/...`), so do not use such +paths on the prefix-less route: + +| Prefix | Client ID | Sends upstream | Upstream | +| --- | --- | --- | --- | +| `/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) | + +**Pairing.** Every route requires the per-device pairing value in the +`x-agentdesktop-pairing` 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 +home directory from `HOME` or `USERPROFILE`, on every platform), unless +`daemon.stateDir` says otherwise. The Copilot CLI's +`providers.json` carries it as `"headers": {"x-agentdesktop-pairing": ""}` +on the provider entry. It keeps other local users on a shared host +and browser pages off the proxy, provided the client files that carry it are +owner-only too. It does not restrict the current user's own processes, which can +obtain a credential from the daemon directly. If the value cannot be created the +proxy stays off, reported in daemon-info like a failed bind. + +**Requests.** `Host` must be a loopback address; `Origin`, `CONNECT` and +`OPTIONS` are refused; `..` path segments are refused. Request bodies are +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. +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. The cache adds no exposure beyond the credential's own +lifetime: a token the controller has issued stays valid at the gateway until it +expires whether or not the daemon caches it, and a logout does not revoke it. +OIDC credentials are not cached. A controller that does not answer within 15 s +fails the request with `agentdesktop_credential`; an OAuth refresh in flight +ahead of the controller call is not cut off, so its result is saved. When a +client token is forwarded (pass-through, or `githubOAuth.source: request`) a +401 is returned as is and the cached gateway credential is kept. With the one +retry, the worst case for one request is the header limit (600 s), a credential +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, +revoked, controller unreachable or slow), `agentdesktop_unavailable`, +`gateway_unreachable`, `gateway_timeout`. To attach the local user's GitHub App OAuth token as well, add the App's client ID to the gateway configuration (keep the existing `authentication` block): @@ -146,7 +250,9 @@ needed. The App's permissions and the user's Copilot access must permit the upstream requests. This flow currently targets GitHub.com. The proxy sends `Authorization: Bearer ` and -`x-llm-token: `. Incoming values for both are discarded. +`x-llm-token: `. Incoming values for both are discarded on +this route (with `githubOAuth.source: request`, and on the pass-through route, +the client's own token is what goes into `x-llm-token`). The GitHub access and refresh tokens are stored in the daemon's existing secret store, separately for each App client ID. Expiring tokens refresh automatically, including refresh-token rotation; expired/rejected refresh credentials trigger @@ -154,6 +260,72 @@ device authorization again. Non-expiring App user tokens are also supported. Request bodies remain untouched. The existing Claude credential-helper flow is unchanged; the local proxy always uses the gateway identity in `Authorization`. +### Managed program: GitHub Copilot CLI + +`programs.copilot` makes the daemon write the Copilot CLI's BYOK provider +registry so the CLI talks to the proxy's `/copilot-cli` route with client ID +`copilot-cli`; nothing is edited by hand. User mode only (the file lives in the +user's Copilot directory), and it needs `daemon.llmProxy.listen`, a configured +`llmGateway`, and `copilot-cli` in `allowedClientIds`: + +```yaml +programs: + copilot: + models: + gpt-4.1: # the name the CLI shows: --model agentdesktop/gpt-4.1 + wireModel: gpt-4.1-mini # what is sent to the gateway (defaults to modelId, which defaults to the key) + claude-haiku-4.5: + provider: agentdesktop-anthropic + wireModel: claude-haiku-4-5 +``` + +The file is `providers.json` in `$COPILOT_HOME` (default `~/.copilot`), or the +path in `COPILOT_PROVIDERS_CONFIG` when set. The daemon reads those variables +from its own environment, which for a service-started daemon is not the +user's shell: if the CLI is pointed elsewhere by a shell profile, set +`daemon.copilot.config` in the local startup file to the same path. The daemon +adds two provider entries, `agentdesktop` (OpenAI-compatible, base URL +`http:///copilot-cli/v1`) and `agentdesktop-anthropic` (Anthropic, +base URL `http:///copilot-cli`), both carrying the pairing header, +plus one model entry per configured model (`id` is the map key, `provider` +and `modelId` are typed fields, other keys such as `wireModel` are passed +through to the CLI). Providers and +models the user added themselves are kept, including models of their own +under other providers with the same IDs. The two provider names and the +models under them belong to the daemon once it has written the file: it +replaces them on every apply, so an edit made by hand to one of them is +undone. Before the daemon has written the file, a user provider under one of +those names (without a pairing header), or a model under such a name, is a conflict: +the file is left alone and, as for every managed file, the daemon applies +nothing on the device until the conflict is resolved (a conflict present at +startup stops the daemon from starting, as for the other programs). If the +daemon's sidecar next to the file is gone, entries under the managed names +that carry a pairing header are replaced on the next apply, and those that +carry this daemon's own pairing value are removed when the program goes +away; entries with another pairing value are left alone. The file is written owner-only. Removing +`programs.copilot`, setting `useLlmGateway: false`, or running without the +proxy takes the managed entries out again and restores the user's file with +its previous mode (deleted only if the daemon created it and nothing else is +left). + +Upgrade note: `programs.copilot` is a new field. Upgrade the controller first +(an older controller rejects the configuration), then the daemons (an older +daemon marks a pushed configuration that carries the field as failed); push +the program only to devices that run in user mode, since a system-mode daemon +fails the whole apply for the program it cannot manage. + +Pick the model with `copilot --model agentdesktop/gpt-4.1` or the `model` key +in the CLI's `settings.json` (`/model` in the CLI); the daemon does not write +that key. There is no `--dry-run` preview for this file: the proxy cannot run +in one-shot mode, so `daemon.llmProxy.listen` is refused together with +`--once` or `--dry-run`, and without the listener the daemon has nothing to +point the CLI at. A running CLI session does not pick up a changed `providers.json`; +start a new one. Each device's `providers.json` is tied to that device's +pairing value, so after a re-enrollment the daemon rewrites it on the next +apply. Discovery reports the CLI's version from the `@github/copilot` npm +manifest next to the launcher; a standalone binary shows no version. The +program cannot be applied with `--once`. + ## Start locally, grow into a fleet Agentdesktop uses the same daemon and tool-native configuration model at every @@ -197,6 +369,7 @@ stage. | OpenCode | Yes | Yes | MCP | — | | VS Code | Yes | — | 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/src/daemon.rs b/crates/agent/src/daemon.rs index 5ba5477e..d52db61f 100644 --- a/crates/agent/src/daemon.rs +++ b/crates/agent/src/daemon.rs @@ -14,8 +14,8 @@ use std::{ use agentdesktop_core::config::GitHubTokenSource; use agentdesktop_core::{ - DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, DEFAULT_STATE_DIR, VERSION, config, - model::{DaemonControllerInfo, DaemonInfo, DaemonScope}, + DEFAULT_CONFIG_PATH, DEFAULT_SOCKET_PATH, VERSION, config, + model::{DaemonControllerInfo, DaemonInfo, DaemonScope, LlmProxyInfo}, telemetry, }; use anyhow::{Context, bail}; @@ -34,7 +34,9 @@ use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; #[cfg(windows)] use crate::windows_security::SecurityDescriptor; -use crate::{api, enrollment::EnrollmentState, gateway_oidc, reconcile, remote, secure_fs}; +use crate::{ + api, enrollment::EnrollmentState, gateway_oidc, llm_proxy, reconcile, remote, secure_fs, +}; #[cfg(unix)] const LOCAL_API_GROUP: &str = "agentdesktop"; @@ -64,50 +66,6 @@ pub struct DaemonArgs { /// Path to the local YAML configuration file. #[arg(long)] config: Option, - - /// Directory used for persistent daemon state. - #[arg(long)] - state_dir: Option, - - /// Address for the OIDC callback server to bind instead of the redirect URI's loopback address. - #[arg(long)] - oidc_callback_listen: Option, - - /// Start a loopback HTTP proxy to llmGateway, attaching the daemon's credentials. - #[arg(long, conflicts_with_all = ["once", "dry_run"])] - llm_proxy_listen: Option, - - /// Credential policy client ID used by the local LLM proxy. - #[arg(long, default_value = "vscode")] - llm_proxy_client_id: String, - - /// Path to Claude Code's Agentdesktop-managed settings file. - #[arg(long)] - claude_code_settings: Option, - - /// Path to Claude Desktop's system-managed JSON configuration. - #[arg(long)] - claude_desktop_managed_settings: Option, - - /// Path used for Claude Desktop's Agentdesktop credential helper. - #[arg(long)] - claude_desktop_credential_helper: Option, - - /// Path to Codex's organization-managed TOML configuration. - #[arg(long)] - codex_managed_config: Option, - - /// Path to OpenCode's system-managed JSONC configuration. - #[arg(long)] - open_code_managed_config: Option, - - /// Path used for Agentdesktop's OpenCode credential plugin. - #[arg(long)] - open_code_plugin: Option, - - /// Path to Grok Build's organization-managed TOML configuration. - #[arg(long)] - grok_managed_config: Option, } struct ResolvedDaemonArgs { @@ -118,59 +76,118 @@ struct ResolvedDaemonArgs { oidc_callback_listen: Option, llm_proxy_listen: Option, llm_proxy_client_id: String, - claude_code_settings: PathBuf, - claude_desktop_managed_settings: PathBuf, - claude_desktop_credential_helper: PathBuf, - codex_managed_config: PathBuf, - open_code_managed_config: PathBuf, - open_code_plugin: PathBuf, - grok_managed_config: PathBuf, + claude_code: ResolvedToolConfigPath, + claude_desktop: ResolvedClaudeDesktopStartupConfig, + codex: ResolvedToolConfigPath, + open_code: ResolvedOpenCodeStartupConfig, + grok: ResolvedToolConfigPath, + /// The Copilot CLI providers file; `None` in system mode (user-only program). + copilot_providers: Option, once: bool, dry_run: bool, } +struct ResolvedToolConfigPath { + config: PathBuf, +} + +struct ResolvedClaudeDesktopStartupConfig { + config: PathBuf, + credential_helper: PathBuf, +} + +struct ResolvedOpenCodeStartupConfig { + config: PathBuf, + plugin: PathBuf, +} + impl DaemonArgs { - fn resolve(self, socket: PathBuf) -> anyhow::Result { - if self - .llm_proxy_listen - .is_some_and(|address| !address.ip().is_loopback()) - { - bail!("--llm-proxy-listen must be a loopback address"); + fn resolve( + self, + startup: config::DaemonStartupConfig, + config: PathBuf, + socket: PathBuf, + ) -> anyhow::Result { + let user = self.user || startup.user; + let socket = startup.socket.clone().unwrap_or(socket); + let llm_proxy_listen = startup.llm_proxy.listen; + // The proxy issues the current user's gateway credential to whatever + // connects on loopback; a system daemon has no single user to act for. + // Checked first so a system-mode operator sees this reason, not a + // secondary one about the address or the run mode. + if !user && llm_proxy_listen.is_some() { + // clientId without listen is inert in either mode; only listen is rejected. + bail!("daemon.llmProxy.listen requires --user (or daemon.user: true)"); + } + if llm_proxy_listen.is_some_and(|address| !address.ip().is_loopback()) { + bail!("daemon.llmProxy.listen must be a loopback address"); } - if !config::valid_client_id(&self.llm_proxy_client_id) { - bail!("invalid --llm-proxy-client-id"); + if llm_proxy_listen.is_some() && (self.once || self.dry_run) { + bail!("daemon.llmProxy.listen cannot be combined with --once or --dry-run"); } - if !self.user { + let llm_proxy_client_id = startup + .llm_proxy + .client_id + .clone() + .unwrap_or_else(|| "vscode".to_owned()); + if llm_proxy_listen.is_some() && !config::valid_client_id(&llm_proxy_client_id) { + bail!("invalid daemon.llmProxy.clientId"); + } + if !user { return Ok(ResolvedDaemonArgs { user: false, - config: self.config.unwrap_or_else(|| DEFAULT_CONFIG_PATH.into()), - state_dir: self.state_dir.unwrap_or_else(|| DEFAULT_STATE_DIR.into()), + config, + state_dir: startup + .state_dir + .unwrap_or_else(|| PathBuf::from(agentdesktop_core::DEFAULT_STATE_DIR)), socket, - oidc_callback_listen: self.oidc_callback_listen, - llm_proxy_listen: Some("127.0.0.1:14000".parse().unwrap()), - llm_proxy_client_id: self.llm_proxy_client_id, - claude_code_settings: self.claude_code_settings.unwrap_or_else(|| { - reconcile::default_claude_code_managed_settings_dir() - .join("50-agentdesktop.json") - }), - claude_desktop_managed_settings: self - .claude_desktop_managed_settings - .unwrap_or_else(reconcile::default_claude_desktop_managed_settings_path), - claude_desktop_credential_helper: self - .claude_desktop_credential_helper - .unwrap_or_else(reconcile::default_claude_desktop_credential_helper_path), - codex_managed_config: self - .codex_managed_config - .unwrap_or_else(reconcile::default_codex_managed_config_path), - open_code_managed_config: self - .open_code_managed_config - .unwrap_or_else(reconcile::default_open_code_managed_config_path), - open_code_plugin: self - .open_code_plugin - .unwrap_or_else(reconcile::default_open_code_plugin_path), - grok_managed_config: self - .grok_managed_config - .unwrap_or_else(reconcile::default_grok_managed_config_path), + oidc_callback_listen: startup.oidc_callback_listen, + llm_proxy_listen: None, + llm_proxy_client_id: llm_proxy_client_id.clone(), + claude_code: ResolvedToolConfigPath { + config: startup.claude_code.config.unwrap_or_else(|| { + reconcile::default_claude_code_managed_settings_dir() + .join("50-agentdesktop.json") + }), + }, + claude_desktop: ResolvedClaudeDesktopStartupConfig { + config: startup + .claude_desktop + .config + .unwrap_or_else(reconcile::default_claude_desktop_managed_settings_path), + credential_helper: startup + .claude_desktop + .credential_helper + .unwrap_or_else(reconcile::default_claude_desktop_credential_helper_path), + }, + codex: ResolvedToolConfigPath { + config: startup + .codex + .config + .unwrap_or_else(reconcile::default_codex_managed_config_path), + }, + open_code: ResolvedOpenCodeStartupConfig { + config: startup + .open_code + .config + .unwrap_or_else(reconcile::default_open_code_managed_config_path), + plugin: startup + .open_code + .plugin + .unwrap_or_else(reconcile::default_open_code_plugin_path), + }, + grok: ResolvedToolConfigPath { + config: startup + .grok + .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 + }, once: self.once || self.dry_run, dry_run: self.dry_run, }); @@ -183,10 +200,10 @@ impl DaemonArgs { let state_home = std::env::var_os("XDG_STATE_HOME") .map(PathBuf::from) .unwrap_or_else(|| home.join(".local/state")); - let state_dir = self + let state_dir = startup .state_dir .unwrap_or_else(|| state_home.join("agentdesktop")); - let socket = if socket == Path::new(DEFAULT_SOCKET_PATH) { + let socket = if startup.socket.is_none() && socket == Path::new(DEFAULT_SOCKET_PATH) { user_socket_path(&state_dir) } else { socket @@ -194,38 +211,56 @@ impl DaemonArgs { let claude_desktop_settings = user_claude_desktop_settings(&home, &config_home); Ok(ResolvedDaemonArgs { user: true, - config: self - .config - .unwrap_or_else(|| config_home.join("agentdesktop/config.yaml")), + config, state_dir: state_dir.clone(), socket, - oidc_callback_listen: self.oidc_callback_listen, - llm_proxy_listen: self.llm_proxy_listen, - llm_proxy_client_id: self.llm_proxy_client_id, - claude_code_settings: self - .claude_code_settings - .unwrap_or_else(|| home.join(".claude/settings.json")), - claude_desktop_managed_settings: self - .claude_desktop_managed_settings - .unwrap_or(claude_desktop_settings), - claude_desktop_credential_helper: self - .claude_desktop_credential_helper - .unwrap_or_else(|| state_dir.join("bin/claude-desktop-credential-helper")), - codex_managed_config: self - .codex_managed_config - .unwrap_or_else(|| home.join(".codex/config.toml")), - open_code_managed_config: self - .open_code_managed_config - .unwrap_or_else(|| config_home.join("opencode/opencode.json")), - open_code_plugin: self - .open_code_plugin - .unwrap_or_else(|| config_home.join("opencode/plugins/agentdesktop.js")), - grok_managed_config: self.grok_managed_config.unwrap_or_else(|| { - std::env::var_os("GROK_HOME") - .filter(|value| !value.is_empty()) - .map(PathBuf::from) - .unwrap_or_else(|| home.join(".grok")) - .join("managed_config.toml") + oidc_callback_listen: startup.oidc_callback_listen, + llm_proxy_listen, + llm_proxy_client_id, + claude_code: ResolvedToolConfigPath { + config: startup + .claude_code + .config + .unwrap_or_else(|| home.join(".claude/settings.json")), + }, + claude_desktop: ResolvedClaudeDesktopStartupConfig { + config: startup + .claude_desktop + .config + .unwrap_or(claude_desktop_settings), + credential_helper: startup + .claude_desktop + .credential_helper + .unwrap_or_else(|| state_dir.join("bin/claude-desktop-credential-helper")), + }, + codex: ResolvedToolConfigPath { + config: startup + .codex + .config + .unwrap_or_else(|| home.join(".codex/config.toml")), + }, + open_code: ResolvedOpenCodeStartupConfig { + config: startup + .open_code + .config + .unwrap_or_else(|| config_home.join("opencode/opencode.json")), + plugin: startup + .open_code + .plugin + .unwrap_or_else(|| config_home.join("opencode/plugins/agentdesktop.js")), + }, + grok: ResolvedToolConfigPath { + config: startup.grok.config.unwrap_or_else(|| { + std::env::var_os("GROK_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".grok")) + .join("managed_config.toml") + }), + }, + copilot_providers: Some(match startup.copilot.config { + Some(path) => path, + None => reconcile::default_copilot_providers_path()?, }), once: self.once || self.dry_run, dry_run: self.dry_run, @@ -283,22 +318,57 @@ pub async fn run_until_shutdown( where F: Future> + Send, { - let args = args.resolve(socket)?; + if socket != Path::new(DEFAULT_SOCKET_PATH) { + bail!("set daemon.socket in the configuration file; --socket is for client commands"); + } + let config_path = match args.config.clone() { + Some(path) => path, + None if args.user => std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or(home_directory()?.join(".config")) + .join("agentdesktop/config.yaml"), + None => DEFAULT_CONFIG_PATH.into(), + }; + let config = config::load_daemon(&config_path)?; + let startup = config.daemon.clone().unwrap_or_default(); + let args = args.resolve(startup, config_path, socket)?; let _log_flush = telemetry::setup_logging(if args.once { "warn" } else { "info" }, false); let socket = args.socket.clone(); - let config = config::load_daemon(&args.config)?; + // Bind the loopback proxy before the reconciler exists, so anything that + // writes the proxy address into a managed file gets the bind result, never + // a configured address the daemon does not own. A failed bind is reported + // through daemon-info and the proxy stays off; the daemon keeps enrolling + // and reconciling, since a port taken by some other process must not take + // the device out of the fleet. + let (mut proxy_listener, mut llm_proxy) = + bind_llm_proxy(args.llm_proxy_listen, &args.llm_proxy_client_id).await; + let pairing = attach_llm_proxy_pairing(&mut proxy_listener, &mut llm_proxy, &args.state_dir); + let proxy_context = match (&proxy_listener, &pairing) { + (Some(listener), Some(pairing)) => { + listener + .local_addr() + .ok() + .map(|address| llm_proxy::LlmProxyContext { + address, + pairing: pairing.clone(), + }) + } + _ => None, + }; let reconciler = reconcile::Reconciler::new( args.user, - args.claude_code_settings.clone(), - args.claude_desktop_managed_settings.clone(), - args.claude_desktop_credential_helper.clone(), - args.codex_managed_config.clone(), - args.open_code_managed_config.clone(), - args.open_code_plugin.clone(), - args.grok_managed_config.clone(), + args.claude_code.config.clone(), + args.claude_desktop.config.clone(), + args.claude_desktop.credential_helper.clone(), + args.codex.config.clone(), + args.open_code.config.clone(), + args.open_code.plugin.clone(), + args.grok.config.clone(), + args.copilot_providers.clone(), agentdesktop_client_executable()?, socket.clone(), - ); + ) + .with_llm_proxy(proxy_context); if args.once { if args.dry_run { validate_dry_run(&config)?; @@ -315,13 +385,13 @@ where return Ok(()); } - let daemon_info = describe_daemon(&config, &args.config, &args.state_dir, args.user); + let daemon_info = describe_daemon(&config, &args.config, &args.state_dir, args.user, llm_proxy); secure_fs::ensure_private_dir(&args.state_dir)?; start_gateway_authentication( &config, args.state_dir.clone(), args.oidc_callback_listen, - args.llm_proxy_listen.is_some(), + proxy_listener.is_some(), ); let enrollment = EnrollmentState::new(config.controller.is_some()); let local_config = config.clone(); @@ -411,23 +481,28 @@ where telemetry, logout, }; - let proxy_listener = if let Some(address) = args.llm_proxy_listen { - let listener = tokio::net::TcpListener::bind(address) - .await - .context("bind local LLM proxy")?; - tracing::info!(address = %listener.local_addr()?, "local LLM proxy listening"); - Some(listener) - } else { - None - }; let app = api::router(state.clone()); + // The proxy is optional at runtime too: if serving fails (for example the + // TLS root store cannot be built), log it and keep the daemon running + // without the proxy rather than dropping the device out of the fleet. let proxy = async move { - match proxy_listener { - Some(listener) => { - crate::llm_proxy::serve(listener, state, args.llm_proxy_client_id).await - } - None => std::future::pending::>().await, + if let Some(listener) = proxy_listener + && let Err(error) = llm_proxy::serve( + listener, + state, + llm_proxy::ProxyConfig { + default_client_id: args.llm_proxy_client_id, + pairing: pairing.expect("pairing exists whenever the listener does"), + }, + ) + .await + { + tracing::error!( + error = %format!("{error:#}"), + "local LLM proxy stopped; proxy unavailable until the daemon restarts" + ); } + std::future::pending::>().await }; tracing::info!(socket = %socket.display(), "agent daemon listening"); @@ -443,11 +518,90 @@ where Ok(()) } +/// Load or create the pairing value for a bound proxy. Without a pairing value +/// the proxy would accept anyone on the host, so a failure to obtain one +/// switches the proxy off like a failed bind: the listener is dropped and +/// daemon-info reports `bound: false` with the reason. +fn attach_llm_proxy_pairing( + proxy_listener: &mut Option, + llm_proxy: &mut Option, + state_dir: &Path, +) -> Option> { + proxy_listener.as_ref()?; + match llm_proxy::load_or_create_pairing(state_dir) { + Ok(pairing) => Some(pairing), + Err(error) => { + tracing::error!( + error = %format!("{error:#}"), + "LLM proxy pairing unavailable; proxy disabled until the daemon restarts" + ); + *proxy_listener = None; + if let Some(info) = llm_proxy.as_mut() { + info.bound = false; + info.error = Some(format!("pairing unavailable: {error:#}")); + } + None + } + } +} + +/// Bind the loopback LLM proxy listener, if one is configured. +/// +/// Returns the listener and the state to report through daemon-info. A bind +/// 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 +/// 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 +/// the listen backlog. +async fn bind_llm_proxy( + listen: Option, + client_id: &str, +) -> (Option, Option) { + let Some(address) = listen else { + return (None, None); + }; + match tokio::net::TcpListener::bind(address).await { + Ok(listener) => { + let bound = listener.local_addr().unwrap_or(address); + tracing::info!(address = %bound, "local LLM proxy listening"); + ( + Some(listener), + Some(LlmProxyInfo { + listen: bound.to_string(), + bound: true, + client_id: client_id.to_owned(), + error: None, + }), + ) + } + Err(error) => { + tracing::error!( + address = %address, + %error, + "local LLM proxy could not bind; proxy and its GitHub sign-in step disabled until the daemon restarts" + ); + ( + None, + Some(LlmProxyInfo { + listen: address.to_string(), + bound: false, + client_id: client_id.to_owned(), + error: Some(error.to_string()), + }), + ) + } + } +} + fn describe_daemon( config: &config::DaemonConfig, config_path: &Path, state_directory: &Path, user: bool, + llm_proxy: Option, ) -> DaemonInfo { let controller = config .controller @@ -471,6 +625,7 @@ fn describe_daemon( state_directory: path_for_display(state_directory), inventory_interval: config.inventory_interval, controller, + llm_proxy, } } @@ -676,6 +831,11 @@ 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" + ); + } let authenticated_gateway_is_used = config .llm_gateway .as_ref() @@ -706,6 +866,11 @@ 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), ] .into_iter() .any(|used| used); @@ -943,7 +1108,7 @@ fn effective_uid() -> u32 { mod tests { use std::{ io, - path::Path, + path::{Path, PathBuf}, pin::Pin, sync::{ Arc, @@ -953,8 +1118,10 @@ mod tests { time::Duration, }; - use agentdesktop_core::config::parse_daemon; - use agentdesktop_core::model::{Agent, Discovery}; + use super::{DaemonArgs, bind_llm_proxy}; + use agentdesktop_core::DEFAULT_SOCKET_PATH; + use agentdesktop_core::config::{self, parse_daemon}; + use agentdesktop_core::model::{Agent, Discovery, LlmProxyInfo}; use tokio::sync::watch; fn discovery(kinds: &[&str]) -> Discovery { @@ -1048,7 +1215,13 @@ mod tests { #[test] fn daemon_information_reports_standalone_defaults_and_resolved_paths() { let config = parse_daemon("{}").unwrap(); - let info = describe_daemon(&config, Path::new("config.yaml"), Path::new("state"), true); + let info = describe_daemon( + &config, + Path::new("config.yaml"), + Path::new("state"), + true, + None, + ); assert_eq!(info.version, agentdesktop_core::VERSION); assert_eq!(info.scope, super::DaemonScope::User); @@ -1104,7 +1277,13 @@ mod tests { "controller:\n address: https://controller.example.com\n caCertificatePath: ''\n", ) .unwrap(); - let info = describe_daemon(&config, Path::new("config.yaml"), Path::new("state"), false); + let info = describe_daemon( + &config, + Path::new("config.yaml"), + Path::new("state"), + false, + None, + ); assert_eq!( info.controller.unwrap().ca_certificate_path.as_deref(), Some("") @@ -1131,7 +1310,7 @@ programs: ) .unwrap(); let config = agentdesktop_core::config::load_daemon(&config_path).unwrap(); - let info = describe_daemon(&config, &config_path, root.path(), false); + let info = describe_daemon(&config, &config_path, root.path(), false, None); assert_eq!(info.scope, super::DaemonScope::System); let controller = info.controller.as_ref().unwrap(); assert_eq!(controller.heartbeat_interval, Duration::from_secs(45)); @@ -1230,7 +1409,7 @@ programs: // Keep invalid-byte paths in memory: filesystems such as APFS reject // these filenames, but the diagnostic projection must handle them. let config = parse_daemon("{}").unwrap(); - let info = describe_daemon(&config, &config_path, &state_dir, true); + let info = describe_daemon(&config, &config_path, &state_dir, true, None); let (_, inventory) = watch::channel(Arc::new(discovery(&[]))); let app = crate::api::router(crate::api::AppState { config, @@ -1411,6 +1590,27 @@ 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 dry_run_rejects_controller_managed_configuration() { let managed = parse_daemon( @@ -1438,4 +1638,237 @@ programs: .expect("valid local configuration"); validate_dry_run(&local).expect("local dry run works"); } + fn daemon_args(user: bool) -> DaemonArgs { + DaemonArgs { + user, + once: false, + dry_run: false, + config: None, + } + } + + fn resolve_error(args: DaemonArgs, startup: config::DaemonStartupConfig) -> anyhow::Error { + match args.resolve( + startup, + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) { + Ok(_) => panic!("resolve succeeded unexpectedly"), + Err(error) => error, + } + } + + fn startup_with_llm_proxy(listen: &str) -> config::DaemonStartupConfig { + let mut startup = config::DaemonStartupConfig::default(); + startup.llm_proxy.listen = Some(listen.parse().unwrap()); + startup + } + + #[test] + fn system_mode_rejects_a_copilot_providers_path() { + let mut startup = config::DaemonStartupConfig::default(); + startup.copilot.config = Some(PathBuf::from("/tmp/providers.json")); + let error = resolve_error(daemon_args(false), startup); + assert!( + error + .to_string() + .contains("daemon.copilot.config requires --user"), + "{error:#}" + ); + } + + #[test] + fn system_mode_rejects_a_configured_llm_proxy() { + let error = resolve_error( + daemon_args(false), + startup_with_llm_proxy("127.0.0.1:18095"), + ); + assert!( + error.to_string().contains("requires --user"), + "unexpected error: {error:#}" + ); + } + + #[test] + fn system_mode_runs_no_llm_proxy_by_default() { + let resolved = daemon_args(false) + .resolve( + config::DaemonStartupConfig::default(), + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .unwrap(); + assert_eq!(resolved.llm_proxy_listen, None); + } + + #[test] + fn user_mode_keeps_the_configured_llm_proxy_address_and_default_client_id() { + // Pin the state dir and socket so XDG variables do not matter. resolve() + // still needs a home directory (HOME / USERPROFILE), like every user-mode + // daemon start. + let mut startup = startup_with_llm_proxy("127.0.0.1:18095"); + startup.state_dir = Some(PathBuf::from("/tmp/agentdesktop-test-state")); + startup.socket = Some(PathBuf::from("/tmp/agentdesktop-test.sock")); + let resolved = daemon_args(true) + .resolve( + startup, + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .unwrap(); + assert_eq!( + resolved.llm_proxy_listen, + Some("127.0.0.1:18095".parse().unwrap()) + ); + assert_eq!(resolved.llm_proxy_client_id, "vscode"); + } + + #[test] + fn client_id_is_only_validated_when_a_listen_address_is_set() { + let mut startup = config::DaemonStartupConfig::default(); + startup.llm_proxy.client_id = Some("not valid!".to_owned()); + let resolved = daemon_args(false) + .resolve( + startup.clone(), + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .map(|_| ()); + assert!( + resolved.is_ok(), + "clientId without listen must not fail startup" + ); + let mut user_startup = startup.clone(); + user_startup.state_dir = Some(PathBuf::from("/tmp/agentdesktop-test-state")); + user_startup.socket = Some(PathBuf::from("/tmp/agentdesktop-test.sock")); + let resolved = daemon_args(true) + .resolve( + user_startup, + PathBuf::from("config.yaml"), + PathBuf::from(DEFAULT_SOCKET_PATH), + ) + .map(|resolved| resolved.llm_proxy_listen); + assert!( + matches!(resolved, Ok(None)), + "clientId without listen is inert in user mode too" + ); + startup.llm_proxy.listen = Some("127.0.0.1:18095".parse().unwrap()); + let error = resolve_error(daemon_args(true), startup); + assert!(error.to_string().contains("clientId")); + } + + #[test] + fn llm_proxy_rejects_non_loopback_and_one_shot_runs() { + let error = resolve_error(daemon_args(true), startup_with_llm_proxy("0.0.0.0:18095")); + assert!(error.to_string().contains("loopback")); + let mut once = daemon_args(true); + once.once = true; + let error = resolve_error(once, startup_with_llm_proxy("127.0.0.1:18095")); + assert!(error.to_string().contains("--once")); + let mut dry_run = daemon_args(true); + dry_run.dry_run = true; + let error = resolve_error(dry_run, startup_with_llm_proxy("127.0.0.1:18095")); + assert!(error.to_string().contains("--once")); + } + + #[test] + fn system_mode_reports_the_user_mode_rule_before_other_checks() { + // A system-mode operator with a non-loopback address must see the real + // reason, not the loopback rule. + let error = resolve_error(daemon_args(false), startup_with_llm_proxy("0.0.0.0:18095")); + assert!(error.to_string().contains("requires --user"), "{error:#}"); + } + + #[tokio::test] + async fn llm_proxy_bind_failure_is_reported_not_fatal() { + let taken = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = taken.local_addr().unwrap(); + let (listener, info) = bind_llm_proxy(Some(address), "vscode").await; + assert!(listener.is_none()); + let info = info.expect("proxy info"); + assert_eq!(info.listen, address.to_string()); + assert!(!info.bound); + assert_eq!(info.client_id, "vscode"); + assert!(info.error.as_deref().is_some_and(|error| !error.is_empty())); + } + + #[tokio::test] + async fn llm_proxy_bind_reports_the_bound_address() { + let (listener, info) = + bind_llm_proxy(Some("127.0.0.1:0".parse().unwrap()), "copilot-cli").await; + let listener = listener.expect("listener bound"); + let info = info.expect("proxy info"); + assert!(info.bound); + assert_eq!(info.listen, listener.local_addr().unwrap().to_string()); + assert_eq!(info.client_id, "copilot-cli"); + let (none, info) = bind_llm_proxy(None, "vscode").await; + assert!(none.is_none() && info.is_none()); + } + + #[tokio::test] + async fn pairing_failure_switches_the_proxy_off_and_reports_it() { + // A state directory that is a regular file: reading the pairing file + // fails (not a directory), which is neither "missing" nor "unusable", so + // no pairing is created and the proxy is switched off. + let dir = tempfile::tempdir().unwrap(); + let blocked = dir.path().join("state-as-file"); + std::fs::write(&blocked, b"not a directory").unwrap(); + let (mut listener, mut info) = + bind_llm_proxy(Some("127.0.0.1:0".parse().unwrap()), "copilot-cli").await; + assert!(listener.is_some()); + let pairing = super::attach_llm_proxy_pairing(&mut listener, &mut info, &blocked); + assert!(pairing.is_none()); + assert!( + listener.is_none(), + "the listener is dropped when no pairing exists" + ); + let info = info.expect("proxy info"); + assert!(!info.bound); + assert!( + info.error + .as_deref() + .is_some_and(|error| error.starts_with("pairing unavailable")) + ); + // A usable state directory keeps the listener and yields the pairing. + let (mut listener, mut info) = + bind_llm_proxy(Some("127.0.0.1:0".parse().unwrap()), "copilot-cli").await; + let pairing = super::attach_llm_proxy_pairing(&mut listener, &mut info, dir.path()); + assert!(pairing.is_some() && listener.is_some() && info.unwrap().bound); + } + + #[test] + fn daemon_information_reports_the_llm_proxy_state() { + let config = parse_daemon("{}").unwrap(); + let proxy = LlmProxyInfo { + listen: "127.0.0.1:18095".to_owned(), + bound: true, + client_id: "vscode".to_owned(), + error: None, + }; + let info = describe_daemon( + &config, + Path::new("config.yaml"), + Path::new("state"), + true, + Some(proxy.clone()), + ); + let json = serde_json::to_value(&info).unwrap(); + assert_eq!(json["llmProxy"]["bound"], serde_json::Value::Bool(true)); + assert_eq!(json["llmProxy"]["listen"], "127.0.0.1:18095"); + assert_eq!(json["llmProxy"]["clientId"], "vscode"); + assert!(json["llmProxy"].get("error").is_none()); + let without = describe_daemon( + &config, + Path::new("config.yaml"), + Path::new("state"), + true, + None, + ); + assert!( + serde_json::to_value(&without) + .unwrap() + .get("llmProxy") + .is_none() + ); + } } diff --git a/crates/agent/src/github_oauth.rs b/crates/agent/src/github_oauth.rs index 6dcb76f1..36522d17 100644 --- a/crates/agent/src/github_oauth.rs +++ b/crates/agent/src/github_oauth.rs @@ -158,10 +158,10 @@ async fn acquire( } let (page_url, status, server) = device_page(&code.user_code, continuation.as_ref()).await?; println!("Open this URL to connect GitHub:\n{page_url}"); - if continuation.is_none() { - if let Err(error) = open::that_detached(&page_url) { - tracing::warn!(%error, "could not open the Agentdesktop sign-in page; use the URL above"); - } + if continuation.is_none() + && let Err(error) = open::that_detached(&page_url) + { + tracing::warn!(%error, "could not open the Agentdesktop sign-in page; use the URL above"); } let deadline = Instant::now() + Duration::from_secs(code.expires_in); let mut interval = Duration::from_secs(code.interval.max(1)); diff --git a/crates/agent/src/identity.rs b/crates/agent/src/identity.rs index c14dacfc..b6a8db9c 100644 --- a/crates/agent/src/identity.rs +++ b/crates/agent/src/identity.rs @@ -45,6 +45,21 @@ struct StoredSecrets { oauth: OAuthCredentials, } +/// The enrolled device ID alone, without opening the secret store. Used where +/// only the identity's *name* matters (cache tagging), so a keyring that is +/// locked or unavailable does not turn a lookup into a failure. +pub fn load_device_id(path: &Path) -> anyhow::Result> { + match fs::read(path) { + Ok(contents) => { + let stored: StoredIdentity = + serde_json::from_slice(&contents).context("parse device identity")?; + Ok(Some(stored.device_id)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error).with_context(|| format!("read identity from {}", path.display())), + } +} + pub fn load(path: &Path) -> anyhow::Result> { let stored: StoredIdentity = match fs::read(path) { Ok(contents) => serde_json::from_slice(&contents).context("parse device identity")?, diff --git a/crates/agent/src/llm_proxy.rs b/crates/agent/src/llm_proxy.rs index 37250013..5dba09f7 100644 --- a/crates/agent/src/llm_proxy.rs +++ b/crates/agent/src/llm_proxy.rs @@ -1,12 +1,13 @@ -use std::{convert::Infallible, time::Duration}; +use std::{convert::Infallible, net::SocketAddr, path::Path, sync::Arc, time::Duration}; 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 hyper::{ HeaderMap, Method, Request, Response, StatusCode, Uri, body::Incoming, - header::{AUTHORIZATION, CONNECTION, HOST, HeaderValue, ORIGIN}, + header::{AUTHORIZATION, CONNECTION, CONTENT_TYPE, HOST, HeaderValue, ORIGIN}, service::service_fn, }; use hyper_rustls::HttpsConnectorBuilder; @@ -16,17 +17,300 @@ use hyper_util::{ }; use tokio::{net::TcpListener, task::JoinSet}; -use agentdesktop_core::config::GitHubTokenSource; +use agentdesktop_core::config::{GitHubTokenSource, LlmGatewayConfig}; use crate::api::{self, AppState}; -type ProxyClient = Client, Incoming>; +type ProxyClient = Client, Full>; type ProxyBody = BoxBody; +/// Largest request body the proxy buffers. Bodies are buffered so a request can +/// be retried once with a fresh credential; LLM requests are far below this. +const MAX_REQUEST_BODY: usize = if cfg!(test) { + 64 * 1024 +} else { + 32 * 1024 * 1024 +}; +/// How long to wait for the gateway's response headers. Streaming bodies have +/// no total timeout: a long completion is normal. A non-streaming completion +/// sends nothing until the model is done, so this also bounds those. +const RESPONSE_HEADERS_TIMEOUT: Duration = Duration::from_secs(600); +/// How long a request waits for a controller credential fetch before it fails +/// with `agentdesktop_credential` instead of hanging the tool. The fetch itself +/// bounds its controller connection and call (remote.rs, 15 s), so it normally +/// ends with its own error first; this outer wait covers the OAuth refresh in +/// front of it. Applies to controller-issued credentials only: an OIDC +/// credential may need a browser login. +const CREDENTIAL_FETCH_TIMEOUT: Duration = Duration::from_secs(20); +/// A cached controller credential is not used within this margin of its expiry. +const CREDENTIAL_EXPIRY_MARGIN: Duration = Duration::from_secs(30); +/// Longest a controller credential is served from the cache, counted from the +/// gateway's first answer with it. Together with the wait for that answer this +/// bounds how long a revoked device keeps using the proxy: revocation is +/// enforced at issuance, the gateway validates tokens statelessly. +const CREDENTIAL_CACHE_TTL: Duration = Duration::from_secs(60); + +/// Listener-local cache of controller-issued gateway credentials, one per client +/// id. A controller fetch is a fresh mTLS connection and round trip; agent-mode +/// sessions issue bursts of requests, so a short cache keeps them off the +/// controller. The lock is never held across a fetch: a slow controller delays +/// only the requests that need a credential, not cache hits on other routes. +/// OIDC credentials never go through here; the secret store is their cache. +#[derive(Default)] +pub(crate) struct CredentialCache { + entries: std::sync::Mutex>, +} + +struct CachedCredential { + credential: String, + /// The enrolled device the credential was issued to. A logout or a + /// re-enrollment changes it, and the entry is then ignored. + device_id: String, + /// Monotonic deadline (wall-clock expiry at fetch time, minus the margin, + /// capped by CREDENTIAL_CACHE_TTL) and the wall-clock deadline itself. Both + /// must be in the future: the monotonic clock does not advance across a + /// suspend, the wall clock can jump; neither alone is trusted. + valid_until: std::time::Instant, + valid_until_unix: u64, +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +impl CredentialCache { + /// A cached credential for the key and device, if one is still valid. + fn get(&self, key: &str, device_id: &str) -> Option { + let entries = self + .entries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + entries + .get(key) + .filter(|entry| { + entry.device_id == device_id + && std::time::Instant::now() < entry.valid_until + && now_unix() < entry.valid_until_unix + }) + .map(|entry| entry.credential.clone()) + } + + /// Remember a freshly fetched credential until the earlier of its expiry + /// margin and the cache TTL. + fn insert( + &self, + key: &str, + device_id: &str, + fetched: &agentdesktop_core::model::LlmGatewayCredential, + ) { + let now = now_unix(); + let remaining = Duration::from_secs(fetched.expires_at_unix_seconds.saturating_sub(now)); + let lifetime = remaining + .saturating_sub(CREDENTIAL_EXPIRY_MARGIN) + .min(CREDENTIAL_CACHE_TTL); + let mut entries = self + .entries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if lifetime > Duration::ZERO { + entries.insert( + key.to_owned(), + CachedCredential { + credential: fetched.credential.clone(), + device_id: device_id.to_owned(), + valid_until: std::time::Instant::now() + lifetime, + valid_until_unix: now + lifetime.as_secs(), + }, + ); + } else { + tracing::debug!( + key, + "controller credential already within its expiry margin; not cached" + ); + entries.remove(key); + } + } + + /// Drop the entry for the key if it still holds the given credential. A + /// concurrent request that already replaced it with a fresh one is left alone. + fn invalidate_if(&self, key: &str, credential: &str) { + let mut entries = self + .entries + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if entries + .get(key) + .is_some_and(|entry| entry.credential == credential) + { + entries.remove(key); + } + } +} + +/// Header that carries the per-device pairing value on every route. +pub(crate) const PAIRING_HEADER: &str = "x-agentdesktop-pairing"; +/// File under the daemon state directory that holds the pairing value. +const PAIRING_FILE: &str = "llm-proxy-pairing"; + +/// How a route obtains the credential that goes upstream in `x-llm-token`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RouteCredential { + /// Only the gateway identity is sent; any client credential is dropped. + Gateway, + /// The client's own bearer token is moved to `x-llm-token` and the gateway + /// identity takes `Authorization` (the VS Code Copilot pass-through shape). + Passthrough, +} + +/// Which configured gateway base URL a route forwards to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Upstream { + /// `llmGateway.url`, shared with the credential-helper programs. + Url, + /// `llmGateway.proxyUrl` when set, else `llmGateway.url`. + ProxyUrl, +} + +/// A fixed path prefix the proxy serves for one managed program. +/// +/// The prefix is what a reconciler writes into that program's client file, so +/// the client id is decided by the file the daemon owns, never by request +/// headers. The prefix is stripped before forwarding. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ProxyRoute { + pub prefix: &'static str, + pub client_id: &'static str, + pub credential: RouteCredential, + pub upstream: Upstream, +} + +/// 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] = &[ + ProxyRoute { + prefix: "/vscode-copilot-passthrough", + client_id: "vscode-copilot", + credential: RouteCredential::Passthrough, + upstream: Upstream::ProxyUrl, + }, + ProxyRoute { + prefix: "/vscode-copilot", + client_id: "vscode-copilot", + credential: RouteCredential::Gateway, + upstream: Upstream::Url, + }, + ProxyRoute { + prefix: "/copilot-cli", + client_id: "copilot-cli", + credential: RouteCredential::Gateway, + upstream: Upstream::Url, + }, +]; + +/// Runtime settings of the proxy listener. +#[derive(Clone)] +pub(crate) struct ProxyConfig { + /// Client id for requests that match no prefixed route (the original, + /// hand-configured shape from the README). + pub default_client_id: String, + /// Pairing value required on every route. + pub pairing: Arc, +} + +impl std::fmt::Debug for ProxyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProxyConfig") + .field("default_client_id", &self.default_client_id) + .field("pairing", &"") + .finish() + } +} + +/// What a reconciler needs to point a client file at the proxy. +/// +/// The pairing is a secret: write it only into files created owner-only (0600, +/// or under an owner-only directory; on Windows `secure_fs::atomic_write` +/// relies on the parent directory's ACL). A reconciler that sees `None` here +/// must remove or neutralise a pointer it wrote earlier, so a stale client file +/// never sends the pairing, or the user's own token, to a port the daemon no +/// longer owns. +#[derive(Clone)] +pub struct LlmProxyContext { + pub address: SocketAddr, + pub pairing: Arc, +} + +impl std::fmt::Debug for LlmProxyContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LlmProxyContext") + .field("address", &self.address) + .field("pairing", &"") + .finish() + } +} + +/// Read the pairing value from the state directory, creating it on first use. +/// +/// The value is a bearer secret for the loopback listener, required on every +/// route: it stops other local users on a shared host and browser-origin +/// traffic from using the proxy. It does not, and cannot, stop the current +/// user's own processes, which can ask the daemon for a credential directly. +/// Kept across restarts so client files stay valid. +pub(crate) fn load_or_create_pairing(state_dir: &Path) -> anyhow::Result> { + let path = state_dir.join(PAIRING_FILE); + let existing = std::fs::read_to_string(&path); + match existing { + Ok(contents) if contents.trim().len() >= 32 => Ok(Arc::from(contents.trim())), + // Only a missing or unusable file (too short, not text) is replaced; any + // other read error (permissions, I/O) is an error, so an existing pairing + // is never silently rotated and every client file invalidated by a + // transient fault. + Err(error) + if !matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::InvalidData + ) => + { + Err(anyhow::Error::from(error).context(format!("read {}", path.display()))) + } + Ok(_) | Err(_) => { + if !matches!(&existing, Err(error) if error.kind() == std::io::ErrorKind::NotFound) { + tracing::warn!(path = %path.display(), "LLM proxy pairing file is unusable; regenerating (client files must be re-applied)"); + } + let mut bytes = [0u8; 32]; + rand::fill(&mut bytes); + let value = URL_SAFE_NO_PAD.encode(bytes); + crate::secure_fs::ensure_private_dir(state_dir)?; + crate::secure_fs::atomic_write(&path, value.as_bytes(), 0o600) + .with_context(|| format!("write {}", path.display()))?; + Ok(Arc::from(value.as_str())) + } + } +} + pub(crate) async fn serve( listener: TcpListener, state: AppState, - client_id: String, + config: ProxyConfig, +) -> anyhow::Result<()> { + serve_with_cache( + listener, + state, + config, + Arc::new(CredentialCache::default()), + ) + .await +} + +/// `serve` with a caller-provided credential cache (tests pre-seed it). +pub(crate) async fn serve_with_cache( + listener: TcpListener, + state: AppState, + config: ProxyConfig, + cache: Arc, ) -> anyhow::Result<()> { let mut http = HttpConnector::new(); http.enforce_http(false); @@ -39,28 +323,39 @@ pub(crate) async fn serve( .enable_http1() .wrap_connector(http); let client = Client::builder(TokioExecutor::new()).build(connector); + let config = Arc::new(config); // Dropping the server cancels active connections and their upstream streams. let mut connections = JoinSet::new(); loop { tokio::select! { accepted = listener.accept() => { - let (stream, _) = accepted.context("accept LLM proxy connection")?; + // A per-connection accept error (EMFILE, ECONNABORTED, ENOBUFS) + // must not end the proxy, let alone the daemon: log, pause briefly + // so a persistent condition does not spin, and keep accepting. + let (stream, _) = match accepted { + Ok(accepted) => accepted, + Err(error) => { + tracing::warn!(%error, "LLM proxy accept failed; retrying"); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + }; let client = client.clone(); let state = state.clone(); - let client_id = client_id.clone(); + let config = config.clone(); + let cache = cache.clone(); connections.spawn(async move { let service = service_fn(move |request| { let client = client.clone(); let state = state.clone(); - let client_id = client_id.clone(); + let config = config.clone(); + let cache = cache.clone(); async move { - let response = match forward(request, &client, &state, &client_id).await { + let response = match forward(request, &client, &state, &config, &cache).await { Ok(response) => response, - Err((status, error)) => { - tracing::warn!(%status, %error, "LLM proxy request failed"); - Response::builder().status(status) - .body(Full::new(Bytes::from_static(b"LLM proxy request failed; see daemon logs\n")) - .map_err(|never| match never {}).boxed()).unwrap() + Err(error) => { + tracing::warn!(status = %error.status, code = error.code, message = %error.message, "LLM proxy request failed"); + error.into_response() } }; Ok::<_, Infallible>(response) @@ -82,105 +377,418 @@ pub(crate) async fn serve( } } +/// A refused or failed request, rendered as an OpenAI-style JSON error so the +/// LLM clients display the message instead of dropping the connection. +#[derive(Debug)] +pub(crate) struct ProxyError { + pub status: StatusCode, + pub code: &'static str, + pub message: String, +} + +impl ProxyError { + fn new(status: StatusCode, code: &'static str, message: impl Into) -> Self { + Self { + status, + code, + message: message.into(), + } + } + + fn into_response(self) -> Response { + let body = serde_json::json!({ + "error": { + "message": self.message, + "type": "agentdesktop_error", + "code": self.code, + } + }) + .to_string(); + Response::builder() + .status(self.status) + .header(CONTENT_TYPE, "application/json") + .body( + Full::new(Bytes::from(body)) + .map_err(|never| match never {}) + .boxed(), + ) + .expect("static response") + } +} + +impl ProxyError { + /// A failure to obtain the gateway identity for this device: not enrolled, + /// revoked, controller unreachable, OIDC refresh failed. The status comes + /// from the credential path; the code tells the client what kind of problem + /// it is so the message is actionable ("run agentdesktop login" and so on). + fn credential((status, message): (StatusCode, String)) -> Self { + Self::new( + status, + "agentdesktop_credential", + format!("agentdesktop: no gateway credential for this device: {message}"), + ) + } +} + +/// Only loopback hosts: a page on another origin that resolves a name to +/// 127.0.0.1 (DNS rebinding) sends that name in `Host`. Accepts any loopback +/// IP (the listen validation allows all of 127.0.0.0/8 and ::1), bracketed or +/// bare IPv6, `localhost` in any case with an optional trailing dot, each with +/// or without a port. A request without `Host` (HTTP/1.0) is refused. +fn host_is_loopback(headers: &HeaderMap) -> bool { + let Some(host) = headers.get(HOST).and_then(|value| value.to_str().ok()) else { + return false; + }; + let host = host.trim(); + let name = if let Some(rest) = host.strip_prefix('[') { + match rest.split_once(']') { + Some((name, port)) if port.is_empty() || port.starts_with(':') => name, + _ => return false, + } + } else if host.matches(':').count() > 1 { + // Bare IPv6 without brackets cannot carry a port. + host + } else { + host.rsplit_once(':').map_or(host, |(name, _)| name) + }; + if let Ok(ip) = name.parse::() { + return ip.is_loopback(); + } + name.trim_end_matches('.').eq_ignore_ascii_case("localhost") +} + +/// Refuse `..` segments so a route cannot escape the upstream base it pins. +/// The check runs on a percent-decoded copy (the forwarded path stays as sent), +/// so `%2e%2e` and encoded separators are caught too; a malformed escape is +/// refused outright. +fn path_escapes_base(path: &str) -> bool { + let Some(decoded) = percent_decode(path) else { + return true; + }; + decoded + .split(|byte| *byte == b'/' || *byte == b'\\') + .any(|segment| segment == b"..") +} + +/// Decodes `%XX` escapes to bytes. Escapes that are not two hex digits are +/// malformed and yield `None`; the bytes are not required to be UTF-8, since +/// the check only looks for separators and dots. +fn percent_decode(input: &str) -> Option> { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + let hex = bytes.get(i + 1..i + 3)?; + if !hex.iter().all(u8::is_ascii_hexdigit) { + return None; + } + let value = u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?; + out.push(value); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + Some(out) +} + +/// Whether the first path segment looks like one of the managed route names +/// without matching one exactly (wrong case, extra suffix, doubled slash). +/// A gateway sub-path whose first segment starts with a route name (for +/// example `/vscode-copilot-proxy/...`) is refused on the prefix-less route +/// as well; the check is on the literal path, so an encoded near miss is not +/// caught and reaches the gateway on the prefix-less route. +fn resembles_route(path: &str) -> bool { + let first = path + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .to_ascii_lowercase(); + !first.is_empty() + && ROUTES.iter().any(|route| { + let name = &route.prefix[1..]; + first == name || first.starts_with(name) || name.starts_with(&first) && first.len() >= 8 + }) +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// The route for a request path, and the path with the prefix removed. +fn match_route(path: &str) -> Option<(&'static ProxyRoute, &str)> { + ROUTES.iter().find_map(|route| { + let rest = path.strip_prefix(route.prefix)?; + (rest.is_empty() || rest.starts_with('/')) + .then_some((route, if rest.is_empty() { "/" } else { rest })) + }) +} + +fn bearer_or_header(headers: &HeaderMap, header: &str) -> Option { + headers + .get(header) + .or_else(|| headers.get(AUTHORIZATION)) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .strip_prefix("Bearer ") + .unwrap_or(value) + .trim() + .to_owned() + }) + .filter(|value| !value.is_empty()) +} + +fn upstream_base(gateway: &LlmGatewayConfig, upstream: Upstream) -> &url::Url { + match upstream { + Upstream::Url => &gateway.url, + Upstream::ProxyUrl => gateway.proxy_url.as_ref().unwrap_or(&gateway.url), + } +} + async fn forward( - mut request: Request, + request: Request, client: &ProxyClient, state: &AppState, - client_id: &str, -) -> Result, (StatusCode, String)> { + config: &ProxyConfig, + cache: &CredentialCache, +) -> Result, ProxyError> { + if !host_is_loopback(request.headers()) { + return Err(ProxyError::new( + StatusCode::FORBIDDEN, + "host_not_allowed", + "agentdesktop proxy: Host must be a loopback address", + )); + } // This listener is for local native clients, not browser scripts or tunnels. if request.headers().contains_key(ORIGIN) || request.method() == Method::CONNECT { - return Err(( + return Err(ProxyError::new( StatusCode::FORBIDDEN, - "browser requests and CONNECT are not supported".into(), + "browser_not_allowed", + "agentdesktop proxy: browser requests and CONNECT are not supported", )); } - let effective = api::load_effective_config(&state.config, &state.state_dir) - .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?; - let gateway = effective.llm_gateway.as_ref().ok_or(( - StatusCode::SERVICE_UNAVAILABLE, - "no llmGateway configured".into(), - ))?; - // proxyUrl wins when present, so the proxy and the credential-helper - // programs can target different prefixes on the same gateway. - let base = gateway.proxy_url.as_ref().unwrap_or(&gateway.url); - let uri: Uri = format!( - "{}{}", - base.as_str().trim_end_matches('/'), - request - .uri() - .path_and_query() - .map_or("/", |path| path.as_str()) - ) + if request.method() == Method::OPTIONS { + return Err(ProxyError::new( + StatusCode::METHOD_NOT_ALLOWED, + "method_not_allowed", + "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. + let path_and_query = request + .uri() + .path_and_query() + .map_or("/", |path| path.as_str()); + let (path, query) = match path_and_query.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (path_and_query, None), + }; + if path_escapes_base(path) { + return Err(ProxyError::new( + StatusCode::BAD_REQUEST, + "path_invalid", + "agentdesktop proxy: path must not contain '..' segments", + )); + } + if match_route(path).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", + )); + } + let effective = + api::load_effective_config(&state.config, &state.state_dir).map_err(|error| { + tracing::warn!( + error = %format!("{error:#}"), + "LLM proxy could not read the applied configuration" + ); + ProxyError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "agentdesktop_unavailable", + "agentdesktop: could not read the applied configuration; see the daemon log", + ) + })?; + let gateway = effective.llm_gateway.as_ref().ok_or_else(|| { + ProxyError::new( + StatusCode::SERVICE_UNAVAILABLE, + "agentdesktop_unavailable", + "agentdesktop: no LLM gateway configured on this device yet", + ) + })?; + + // Which route, which client id, which credential mode, which upstream. + let (client_id, credential_mode, base, rest) = match match_route(path) { + Some((route, rest)) => { + if route.upstream == Upstream::ProxyUrl && gateway.proxy_url.is_none() { + // The pass-through shape needs the gateway route that restores the + // client token; sending x-llm-token to the plain LLM route would + // fail in a way that looks like a gateway fault. + return Err(ProxyError::new( + StatusCode::SERVICE_UNAVAILABLE, + "agentdesktop_unavailable", + "agentdesktop proxy: this route needs llmGateway.proxyUrl, which is not configured", + )); + } + ( + route.client_id.to_owned(), + Some(route.credential), + upstream_base(gateway, route.upstream), + rest, + ) + } + // No prefix: the original hand-configured shape. Credential mode comes + // from llmGateway.githubOAuth, upstream from proxyUrl. + None => ( + config.default_client_id.clone(), + None, + upstream_base(gateway, Upstream::ProxyUrl), + path, + ), + }; + let uri: Uri = match query { + Some(query) => format!("{}{rest}?{query}", base.as_str().trim_end_matches('/')), + None => format!("{}{rest}", base.as_str().trim_end_matches('/')), + } .parse() - .map_err(|error: hyper::http::uri::InvalidUri| (StatusCode::BAD_GATEWAY, error.to_string()))?; + .map_err(|error: hyper::http::uri::InvalidUri| { + ProxyError::new( + StatusCode::BAD_GATEWAY, + "gateway_unreachable", + error.to_string(), + ) + })?; + // Capture the client's own credential before the headers are cleared. A // client such as VS Code Copilot has already done its provider handshake and - // sends the resulting token; with `githubOAuth.source: request` that token is - // what goes upstream, and the daemon only adds the gateway identity. + // sends the resulting token; on a pass-through route (or with + // `githubOAuth.source: request` on the default route) that token is what + // goes upstream, and the daemon only adds the gateway identity. // // x-llm-token wins over Authorization so a caller can send both: its identity // in one and the provider credential in the other. - let client_credential = request - .headers() - .get("x-llm-token") - .or_else(|| request.headers().get(AUTHORIZATION)) - .and_then(|value| value.to_str().ok()) - .map(|value| { - value - .strip_prefix("Bearer ") - .unwrap_or(value) - .trim() - .to_owned() - }) - .filter(|value| !value.is_empty()); + // The pairing value is never a client credential, even if a client put it in + // 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())); - strip_hop_headers(request.headers_mut()); - request.headers_mut().remove(AUTHORIZATION); - request.headers_mut().remove("x-api-key"); - request.headers_mut().remove("api-key"); - request.headers_mut().remove("x-llm-token"); - if gateway.authentication.is_some() { - let credential = api::gateway_credential(state, &effective, client_id, false).await?; - let mut authorization = HeaderValue::from_str(&format!("Bearer {}", credential.credential)) - .map_err(|_| { - ( - StatusCode::BAD_GATEWAY, - "invalid gateway credential header".into(), + // Buffer the body (capped) so the request can be retried once with a fresh + // credential; the response is streamed as it arrives. + let (parts, body) = request.into_parts(); + let body = http_body_util::Limited::new(body, MAX_REQUEST_BODY) + .collect() + .await + .map_err(|error| { + if error + .downcast_ref::() + .is_some() + { + ProxyError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "body_too_large", + format!( + "agentdesktop proxy: request body exceeds {} bytes", + MAX_REQUEST_BODY + ), + ) + } else { + ProxyError::new( + StatusCode::BAD_REQUEST, + "body_invalid", + format!("agentdesktop proxy: could not read the request body: {error}"), ) - })?; - authorization.set_sensitive(true); - request.headers_mut().insert(AUTHORIZATION, authorization); - } - if let Some(github) = &gateway.github_oauth { - // Both branches put a bare token in x-llm-token. The gateway is the one - // that re-forms the header, with `"Bearer " + request.headers['x-llm-token']`, - // so the prefix is added in exactly one place. - let credential = match github.source { - GitHubTokenSource::DeviceFlow => { - let client_id = github.client_id.as_deref().ok_or(( - StatusCode::INTERNAL_SERVER_ERROR, - "githubOAuth.clientId is required for deviceFlow".to_string(), - ))?; - crate::github_oauth::credential(client_id, &state.state_dir, None) - .await - .map_err(|error| (StatusCode::BAD_GATEWAY, format!("GitHub OAuth: {error:#}")))? - .credential } - // Fail rather than forward a request that cannot succeed: without a - // client credential the gateway would swap in an empty Authorization - // and the provider would reject it with an error that looks like a - // gateway fault. - GitHubTokenSource::Request => client_credential.clone().ok_or(( + })? + .to_bytes(); + let mut request = Request::from_parts(parts, ()); + + strip_hop_headers(request.headers_mut()); + for header in [ + AUTHORIZATION.as_str(), + "x-api-key", + "api-key", + "x-llm-token", + PAIRING_HEADER, + ] { + request.headers_mut().remove(header); + } + // Both branches put a bare token in x-llm-token. The gateway is the one + // that re-forms the header, with `"Bearer " + request.headers['x-llm-token']`, + // so the prefix is added in exactly one place. + let upstream_token = match credential_mode { + Some(RouteCredential::Gateway) => None, + Some(RouteCredential::Passthrough) => Some(client_credential.ok_or_else(|| { + ProxyError::new( StatusCode::UNAUTHORIZED, - "no client credential to forward; send it in Authorization or x-llm-token" - .to_string(), - ))?, - }; - let mut value = HeaderValue::from_str(&credential).map_err(|_| { - ( + "client_credential_missing", + "agentdesktop proxy: no client credential to pass through; send it in Authorization or x-llm-token", + ) + })?), + None => match &gateway.github_oauth { + None => None, + Some(github) => Some(match github.source { + GitHubTokenSource::DeviceFlow => { + let client_id = github.client_id.as_deref().ok_or_else(|| { + ProxyError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "agentdesktop_unavailable", + "githubOAuth.clientId is required for deviceFlow", + ) + })?; + crate::github_oauth::credential(client_id, &state.state_dir, None) + .await + .map_err(|error| ProxyError::new(StatusCode::BAD_GATEWAY, "gateway_unreachable", format!("GitHub OAuth: {error:#}")))? + .credential + } + // Fail rather than forward a request that cannot succeed: without a + // client credential the gateway would swap in an empty Authorization + // and the provider would reject it with an error that looks like a + // gateway fault. + GitHubTokenSource::Request => client_credential.ok_or_else(|| { + ProxyError::new( + StatusCode::UNAUTHORIZED, + "client_credential_missing", + "no client credential to forward; send it in Authorization or x-llm-token", + ) + })?, + }), + }, + }; + let client_token_sent = upstream_token.is_some(); + if let Some(token) = upstream_token { + let mut value = HeaderValue::from_str(&token).map_err(|_| { + ProxyError::new( StatusCode::BAD_GATEWAY, - "invalid GitHub credential header".into(), + "gateway_unreachable", + "invalid client credential header", ) })?; value.set_sensitive(true); @@ -188,16 +796,165 @@ async fn forward( } request.headers_mut().insert( HOST, - HeaderValue::from_str(uri.authority().unwrap().as_str()) - .map_err(|_| (StatusCode::BAD_GATEWAY, "invalid gateway host".into()))?, + HeaderValue::from_str( + uri.authority() + .map(|authority| authority.as_str()) + .unwrap_or(""), + ) + .map_err(|_| { + ProxyError::new( + StatusCode::BAD_GATEWAY, + "gateway_unreachable", + "invalid gateway host", + ) + })?, ); *request.uri_mut() = uri; - let mut response = client - .request(request) - .await - .map_err(|error| (StatusCode::BAD_GATEWAY, error.to_string()))?; - strip_hop_headers(response.headers_mut()); - Ok(response.map(BodyExt::boxed)) + // Identity last: no controller or OIDC round trip for a request that was + // going to be refused anyway. Controller credentials are cached per client + // id; on a 401 from the gateway the request is sent once more with a fresh + // credential, which covers a token that expired between fetch and use. + let authenticated = gateway.authentication.is_some(); + let controller_issued = matches!( + gateway.authentication, + Some(agentdesktop_core::config::LlmGatewayAuthentication::ControllerJwt { .. }) + ); + // Only controller-issued credentials are cached, keyed by client id, + // gateway and audience, and tagged with the enrolled device so a logout or + // re-enrollment (a different identity.json) never reuses an old token. The + // device id is read from identity.json alone; the secret store is not opened. + let cache_key = match &gateway.authentication { + Some(agentdesktop_core::config::LlmGatewayAuthentication::ControllerJwt { + audience, + .. + }) => crate::identity::load_device_id(&state.state_dir.join("identity.json")) + .ok() + .flatten() + .map(|device_id| { + ( + format!("{client_id}\u{0}{}\u{0}{audience}", gateway.url), + device_id, + ) + }), + _ => None, + }; + let mut retried = false; + loop { + let mut attempt = + Request::from_parts(request.clone().into_parts().0, Full::new(body.clone())); + let mut from_cache = false; + // A credential fetched for this attempt; cached only once the gateway + // has not rejected it, so a bad token is never handed to later requests. + let mut fetched_now: Option = None; + let mut attempt_credential: Option = None; + if authenticated { + let cached = cache_key + .as_ref() + .and_then(|(key, device_id)| cache.get(key, device_id)); + let credential = match cached { + Some(credential) => { + from_cache = true; + credential + } + None => { + // The fetch runs on its own task so a client disconnect or the + // wait below does not cancel it half-way (an OAuth refresh must + // finish and be stored). The fetch bounds its own controller + // call, so a detached fetch ends by itself; an OIDC acquisition + // may wait for a browser login and is not bounded. + let fetch = { + let state = state.clone(); + let effective = effective.clone(); + let client_id = client_id.clone(); + tokio::spawn(async move { + api::gateway_credential(&state, &effective, &client_id, false).await + }) + }; + let outcome = if controller_issued { + tokio::time::timeout(CREDENTIAL_FETCH_TIMEOUT, fetch) + .await + .map_err(|_| { + ProxyError::credential(( + StatusCode::GATEWAY_TIMEOUT, + "the credential fetch (token refresh or controller call) did not finish in time".to_owned(), + )) + })? + } else { + fetch.await + }; + let fetched = outcome + .map_err(|error| { + ProxyError::credential(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("credential fetch failed: {error}"), + )) + })? + .map_err(ProxyError::credential)?; + let credential = fetched.credential.clone(); + fetched_now = Some(fetched); + credential + } + }; + let mut authorization = HeaderValue::from_str(&format!("Bearer {credential}")) + .map_err(|_| { + ProxyError::new( + StatusCode::BAD_GATEWAY, + "gateway_unreachable", + "invalid gateway credential header", + ) + })?; + authorization.set_sensitive(true); + attempt.headers_mut().insert(AUTHORIZATION, authorization); + attempt_credential = Some(credential); + } + let response = tokio::time::timeout(RESPONSE_HEADERS_TIMEOUT, client.request(attempt)) + .await + .map_err(|_| { + ProxyError::new( + StatusCode::GATEWAY_TIMEOUT, + "gateway_timeout", + "agentdesktop proxy: the gateway did not answer in time", + ) + })? + .map_err(|error| { + ProxyError::new( + StatusCode::BAD_GATEWAY, + "gateway_unreachable", + format!("agentdesktop proxy: gateway unreachable: {error}"), + ) + })?; + // Retry only when the rejected credential came from the cache and no + // client token was forwarded: a freshly fetched credential that is + // rejected would be rejected again, and with a client token in + // x-llm-token the 401 may be about that token, not the gateway identity + // (the cached gateway credential is then kept: dropping it on every + // client-token 401 would turn a bad client token into a controller + // fetch per request). + if response.status() == StatusCode::UNAUTHORIZED + && from_cache + && !client_token_sent + && !retried + { + tracing::info!( + client_id, + "gateway rejected a cached credential; retrying once with a fresh one" + ); + if let Some((key, _)) = &cache_key { + let rejected = attempt_credential.as_deref().unwrap_or_default(); + cache.invalidate_if(key, rejected); + } + retried = true; + continue; + } + if response.status() != StatusCode::UNAUTHORIZED + && let (Some(fetched), Some((key, device_id))) = (&fetched_now, &cache_key) + { + cache.insert(key, device_id, fetched); + } + let mut response = response; + strip_hop_headers(response.headers_mut()); + return Ok(response.map(BodyExt::boxed)); + } } fn strip_hop_headers(headers: &mut HeaderMap) { @@ -260,6 +1017,7 @@ mod tests { assert_eq!(request.headers()[HOST], address.to_string()); assert!(!request.headers().contains_key("x-api-key")); assert!(!request.headers().contains_key("x-hop")); + assert!(!request.headers().contains_key(PAIRING_HEADER)); assert_eq!(request.headers()["x-llm-token"], "ghu_test"); assert_eq!(request.headers()["anthropic-version"], "2023-06-01"); let gate = release.lock().await.take(); @@ -300,13 +1058,13 @@ mod tests { 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 }, + 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, }; 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, "vscode".into())); + let proxy = tokio::spawn(serve(listener, state, default_config())); let client: Client> = Client::builder(TokioExecutor::new()).build_http(); let mut release_tx = Some(release_tx); for _ in 0..2 { @@ -315,6 +1073,7 @@ mod tests { .header(AUTHORIZATION, "Bearer must-not-forward") .header("x-api-key", "must-not-forward") .header("x-llm-token", "must-not-forward") + .header(PAIRING_HEADER, PAIRING) .header(CONNECTION, "x-hop").header("x-hop", "remove") .header("anthropic-version", "2023-06-01") .body(Full::new(Bytes::from_static(b"\xffnot-json\x00"))).unwrap(); @@ -367,12 +1126,13 @@ mod tests { 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, "vscode".into())); + 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}/chat/completions")) + .header(PAIRING_HEADER, PAIRING) .header(AUTHORIZATION, "Bearer tid=from-client") .body(Full::new(Bytes::from_static(b"{}"))) .unwrap(); @@ -399,16 +1159,92 @@ mod tests { 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, "vscode".into())); + 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}/chat/completions")) + .header(PAIRING_HEADER, PAIRING) .body(Full::new(Bytes::from_static(b"{}"))) .unwrap(); let response = client.request(request).await.unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + // The default route is paired too: without the header it is refused + // before any credential work. + let request = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}/chat/completions")) + .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()) + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + const PAIRING: &str = "pairing-secret-value-0123456789abcdef"; + + fn default_config() -> ProxyConfig { + ProxyConfig { + default_client_id: "vscode".into(), + pairing: Arc::from(PAIRING), + } + } + + async fn error_code(response: Response) -> (StatusCode, String) { + let status = response.status(); + assert_eq!(response.headers()[CONTENT_TYPE], "application/json"); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + (status, json["error"]["code"].as_str().unwrap().to_owned()) + } + + #[tokio::test] + async fn refuses_non_loopback_hosts_and_options_with_json_errors() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let (dir, state) = request_source_state(upstream.local_addr().unwrap()).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 rebound = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/copilot-cli/v1/chat/completions" + )) + .header(HOST, "attacker.example") + .header(PAIRING_HEADER, "pairing-secret-value-0123456789abcdef") + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + error_code(client.request(rebound).await.unwrap()).await, + (StatusCode::FORBIDDEN, "host_not_allowed".to_owned()) + ); + let options = Request::builder() + .method(Method::OPTIONS) + .uri(format!( + "http://{proxy_address}/copilot-cli/v1/chat/completions" + )) + .body(Full::new(Bytes::new())) + .unwrap(); + assert_eq!( + error_code(client.request(options).await.unwrap()).await, + ( + StatusCode::METHOD_NOT_ALLOWED, + "method_not_allowed".to_owned() + ) + ); proxy.abort(); let _ = proxy.await; drop(client); @@ -418,6 +1254,893 @@ mod tests { .unwrap(); } + // A prefixed route needs the pairing value, strips its prefix, sends only the + // gateway identity (no client credential leaks through), and is refused with a + // JSON error without the pairing. + #[tokio::test] + async fn prefixed_gateway_route_requires_pairing_and_strips_the_prefix() { + 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(), "/v1/chat/completions?stream=true"); + assert_eq!(request.headers()[AUTHORIZATION], "Bearer identity"); + assert!(!request.headers().contains_key("x-llm-token")); + assert!(!request.headers().contains_key(PAIRING_HEADER)); + Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"ok")))) + }), + ) + .await + .unwrap(); + }); + 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.clone(), default_config())); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let unpaired = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/copilot-cli/v1/chat/completions" + )) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + error_code(client.request(unpaired).await.unwrap()).await, + (StatusCode::FORBIDDEN, "pairing_invalid".to_owned()) + ); + // The pairing is only accepted in its own header, never as the bearer. + let as_bearer = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/copilot-cli/v1/chat/completions" + )) + .header(AUTHORIZATION, format!("Bearer {PAIRING}")) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + error_code(client.request(as_bearer).await.unwrap()).await, + (StatusCode::FORBIDDEN, "pairing_invalid".to_owned()) + ); + let wrong = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/copilot-cli/v1/chat/completions" + )) + .header(PAIRING_HEADER, "pairing-secret-value-0123456789abcdeX") + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + error_code(client.request(wrong).await.unwrap()).await, + (StatusCode::FORBIDDEN, "pairing_invalid".to_owned()) + ); + let paired = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/copilot-cli/v1/chat/completions?stream=true" + )) + .header(PAIRING_HEADER, PAIRING) + .header(AUTHORIZATION, "Bearer a-real-key-by-mistake") + .header("x-llm-token", "must-not-forward") + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + let response = client.request(paired).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + proxy.abort(); + let _ = proxy.await; + // A '..' segment cannot escape the upstream base the route pins. + 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 request = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}/copilot-cli/../admin")) + .header(PAIRING_HEADER, PAIRING) + .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); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // The pass-through route moves the client's token to x-llm-token, keeps the + // gateway identity in Authorization, forwards to proxyUrl, and refuses a + // request without a client token. The pairing must come in its own header + // here, because Authorization carries the client token. + #[tokio::test] + async fn passthrough_route_moves_the_client_token_and_uses_proxy_url() { + 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(), "/copilot-proxy/chat/completions"); + assert_eq!(request.headers()["x-llm-token"], "tid=from-client"); + assert_eq!(request.headers()[AUTHORIZATION], "Bearer identity"); + assert!(!request.headers().contains_key(PAIRING_HEADER)); + Ok::<_, Infallible>(Response::new(Full::new(Bytes::from_static(b"ok")))) + }), + ) + .await + .unwrap(); + }); + let (dir, mut state) = request_source_state(address).await; + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + // Without proxyUrl the pass-through route is unavailable rather than + // sending x-llm-token to the plain LLM route. + 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.clone(), default_config())); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/vscode-copilot-passthrough/chat/completions" + )) + .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::SERVICE_UNAVAILABLE, + "agentdesktop_unavailable".to_owned() + ) + ); + proxy.abort(); + let _ = proxy.await; + state.config.llm_gateway.as_mut().unwrap().proxy_url = + Some(format!("http://{address}/copilot-proxy").parse().unwrap()); + 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 no_token = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/vscode-copilot-passthrough/chat/completions" + )) + .header(PAIRING_HEADER, "pairing-secret-value-0123456789abcdef") + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + error_code(client.request(no_token).await.unwrap()).await, + ( + StatusCode::UNAUTHORIZED, + "client_credential_missing".to_owned() + ) + ); + // The pairing in Authorization is not a client token: refused, never forwarded. + let pairing_as_token = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/vscode-copilot-passthrough/chat/completions" + )) + .header(PAIRING_HEADER, PAIRING) + .header(AUTHORIZATION, format!("Bearer {PAIRING}")) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + error_code(client.request(pairing_as_token).await.unwrap()).await, + ( + StatusCode::UNAUTHORIZED, + "client_credential_missing".to_owned() + ) + ); + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/vscode-copilot-passthrough/chat/completions" + )) + .header(PAIRING_HEADER, "pairing-secret-value-0123456789abcdef") + .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(); + } + + #[test] + fn route_matching_prefers_the_longer_prefix_and_strips_it() { + let (route, rest) = match_route("/vscode-copilot-passthrough/chat/completions").unwrap(); + assert_eq!(route.client_id, "vscode-copilot"); + assert_eq!(route.credential, RouteCredential::Passthrough); + assert_eq!(rest, "/chat/completions"); + let (route, rest) = match_route("/vscode-copilot/v1/chat/completions").unwrap(); + assert_eq!(route.credential, RouteCredential::Gateway); + assert_eq!(rest, "/v1/chat/completions"); + assert_eq!(match_route("/copilot-cli").unwrap().1, "/"); + assert!(match_route("/copilot-client/v1").is_none()); + assert!(match_route("/v1/chat/completions").is_none()); + } + + #[test] + fn host_check_accepts_loopback_forms_only() { + let mut headers = HeaderMap::new(); + let accepted = [ + "127.0.0.1:18095", + "localhost:18095", + "[::1]:18095", + "localhost", + "127.0.0.1", + "127.0.0.2:1", + "Localhost:18095", + "LOCALHOST", + "localhost.:18095", + "[::1]", + "::1", + ]; + for ok in accepted { + headers.insert(HOST, HeaderValue::from_static(ok)); + assert!(host_is_loopback(&headers), "{ok}"); + } + let refused = [ + "attacker.example", + "127.0.0.1.attacker.example:18095", + "10.0.0.5:18095", + "", + "[::1", + "[fe80::1]:18095", + "localhost.attacker.example", + "127.0.0.1:18095:1", + ]; + for bad in refused { + headers.insert(HOST, HeaderValue::from_static(bad)); + assert!(!host_is_loopback(&headers), "{bad}"); + } + headers.remove(HOST); + assert!(!host_is_loopback(&headers)); + } + + #[test] + fn pairing_is_created_once_and_reused() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create_pairing(dir.path()).unwrap(); + let second = load_or_create_pairing(dir.path()).unwrap(); + assert_eq!(first, second); + assert!(first.len() >= 43); + let metadata = std::fs::metadata(dir.path().join(PAIRING_FILE)).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(metadata.permissions().mode() & 0o777, 0o600); + } + let _ = metadata; + } + + #[test] + fn pairing_file_is_regenerated_only_when_unusable() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(PAIRING_FILE); + // Too short: replaced. + std::fs::write(&path, b"short").unwrap(); + let regenerated = load_or_create_pairing(dir.path()).unwrap(); + assert!(regenerated.len() >= 43); + assert_eq!(std::fs::read_to_string(&path).unwrap(), *regenerated); + // Not text: replaced. + std::fs::write(&path, [0xff, 0xfe, 0x00, 0x01]).unwrap(); + let again = load_or_create_pairing(dir.path()).unwrap(); + assert_ne!(again, regenerated); + assert_eq!(std::fs::read_to_string(&path).unwrap(), *again); + // Any other read error is an error, and the value on disk is untouched. + let other = tempfile::tempdir().unwrap(); + std::fs::create_dir(other.path().join(PAIRING_FILE)).unwrap(); + let error = load_or_create_pairing(other.path()).unwrap_err(); + assert!(format!("{error:#}").contains("read "), "{error:#}"); + assert!(other.path().join(PAIRING_FILE).is_dir()); + // An existing value that cannot be read (permissions) is kept as is. + #[cfg(unix)] + if !nix_is_root() { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); + let error = load_or_create_pairing(dir.path()).unwrap_err(); + assert!(format!("{error:#}").contains("read "), "{error:#}"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!(*load_or_create_pairing(dir.path()).unwrap(), *again); + } + // A state directory that cannot be created is an error, not a pairing. + let blocked = tempfile::tempdir().unwrap(); + let file = blocked.path().join("state"); + std::fs::write(&file, b"x").unwrap(); + assert!(load_or_create_pairing(&file.join("nested")).is_err()); + } + + #[cfg(unix)] + fn nix_is_root() -> bool { + std::fs::metadata("/proc/self") + .map(|m| { + use std::os::unix::fs::MetadataExt; + m.uid() == 0 + }) + .unwrap_or(false) + } + + #[test] + fn route_table_maps_each_prefix_to_its_identity_mode_and_upstream() { + let expected = [ + ( + "/copilot-cli/v1/x", + "copilot-cli", + RouteCredential::Gateway, + Upstream::Url, + ), + ( + "/vscode-copilot/v1/x", + "vscode-copilot", + RouteCredential::Gateway, + Upstream::Url, + ), + ( + "/vscode-copilot-passthrough/chat/completions", + "vscode-copilot", + RouteCredential::Passthrough, + Upstream::ProxyUrl, + ), + ]; + for (path, client_id, credential, upstream) in expected { + let (route, _) = match_route(path).unwrap_or_else(|| panic!("{path}")); + assert_eq!(route.client_id, client_id, "{path}"); + assert_eq!(route.credential, credential, "{path}"); + assert_eq!(route.upstream, upstream, "{path}"); + } + assert_eq!(ROUTES.len(), 3); + } + + #[test] + fn path_guard_catches_encoded_traversal_and_near_miss_routes() { + for escaping in [ + "/copilot-cli/../admin", + "/copilot-cli/%2e%2e/admin", + "/copilot-cli/%2E%2E/admin", + "/copilot-cli/%2e%2e%2fadmin", + "/copilot-cli/..%5cadmin", + "/copilot-cli/%zz", + "/copilot-cli/%+f", + "/copilot-cli/%2", + ] { + assert!(path_escapes_base(escaping), "{escaping}"); + } + for fine in [ + "/copilot-cli/v1/chat/completions", + "/v1/messages", + "/copilot-cli/a.b/c%20d", + "/copilot-cli/caf%e9", + "/copilot-cli/%2e%2ex", + ] { + assert!(!path_escapes_base(fine), "{fine}"); + } + for near in [ + "/copilot-cli2/v1", + "/Copilot-cli/v1", + "//copilot-cli/v1", + "/vscode-copilotx/v1", + ] { + assert!( + match_route(near).is_none() && resembles_route(near), + "{near}" + ); + } + for other in ["/v1/chat/completions", "/chat/completions", "/models"] { + assert!(!resembles_route(other), "{other}"); + } + } + + // The pairing is checked before any configuration is read: an unpaired + // caller gets 403 even when the applied configuration is unreadable, and a + // paired caller gets a generic message without file detail. + #[tokio::test] + async fn pairing_is_checked_before_configuration_is_read() { + tokio::time::timeout(Duration::from_secs(15), async { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("remote-config.yaml"), + "llmGateway: [not: an: object", + ) + .unwrap(); + let config = parse_daemon("controller:\n address: https://127.0.0.1:1\n").unwrap(); + let (_, discovery) = watch::channel(Arc::new(Discovery { + agents: vec![], + model_runtimes: vec![], + })); + let state = AppState { + 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(true), + state_dir: dir.path().to_owned(), + oidc_callback_listen: None, + telemetry: None, + logout: None, + }; + 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 unpaired = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/copilot-cli/v1/chat/completions" + )) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + assert_eq!( + error_code(client.request(unpaired).await.unwrap()).await, + (StatusCode::FORBIDDEN, "pairing_invalid".to_owned()) + ); + // The path checks are pure string checks and run before the + // configuration is read: a paired caller with a bad path gets the + // path error, not the configuration error. + for (bad_path, code) in [ + ("/copilot-cli/../admin", "path_invalid"), + ("/Copilot-cli/v1/chat/completions", "route_unknown"), + ] { + let request = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}{bad_path}")) + .header(PAIRING_HEADER, PAIRING) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + let (_, got) = error_code(client.request(request).await.unwrap()).await; + assert_eq!(got, code, "{bad_path}"); + } + let paired = Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/copilot-cli/v1/chat/completions" + )) + .header(PAIRING_HEADER, PAIRING) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + let response = client.request(paired).await.unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let message = json["error"]["message"].as_str().unwrap(); + assert!( + !message.contains("remote-config") + && !message.contains(dir.path().to_str().unwrap()), + "{message}" + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + // The retry applies only to a credential served from the cache. OIDC + // credentials are never cached, so a 401 from the gateway is returned as is + // after a single attempt, with the buffered body delivered once. The cached + // path is covered by `cached_credential_is_used_and_dropped_on_401` below. + #[tokio::test] + async fn uncached_credentials_are_not_retried_on_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 attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = attempts.clone(); + let upstream_task = tokio::spawn(async move { + loop { + let (socket, _) = upstream.accept().await.unwrap(); + let seen = seen.clone(); + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| { + let seen = seen.clone(); + async move { + let n = + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + assert_eq!( + request.headers()[AUTHORIZATION], + "Bearer identity" + ); + let body = + request.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body, "{\"n\":1}"); + // One attempt per client request: the first + // is answered 401, the second 200. + let status = if n == 1 { + StatusCode::OK + } else { + StatusCode::UNAUTHORIZED + }; + Ok::<_, Infallible>( + Response::builder() + .status(status) + .body(Full::new(Bytes::from_static(b"upstream"))) + .unwrap(), + ) + } + }), + ) + .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(); + let request = || { + Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}/chat/completions")) + .header(PAIRING_HEADER, PAIRING) + .header(AUTHORIZATION, "Bearer tid=from-client") + .body(Full::new(Bytes::from_static(b"{\"n\":1}"))) + .unwrap() + }; + let response = client.request(request()).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1); + let response = client.request(request()).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2); + proxy.abort(); + let _ = proxy.await; + drop(client); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // A pre-seeded cache stands in for a controller: the proxy sends the cached + // credential; when the gateway accepts it the entry stays; when the gateway + // rejects it the entry is dropped and the request is retried, which here + // needs a controller that does not exist, so the client gets + // `agentdesktop_credential` and the gateway saw the body exactly once. + #[tokio::test] + async fn cached_credential_is_used_and_dropped_on_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(); + type Seen = Arc)>>>; + let seen: Seen = Arc::default(); + let record = seen.clone(); + let upstream_task = tokio::spawn(async move { + loop { + let (socket, _) = upstream.accept().await.unwrap(); + let record = record.clone(); + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| { + let record = record.clone(); + async move { + let authorization = request + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let body = request + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + let status = if authorization == "Bearer good" { + StatusCode::OK + } else { + StatusCode::UNAUTHORIZED + }; + record.lock().unwrap().push((authorization, body)); + Ok::<_, Infallible>( + Response::builder() + .status(status) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(), + ) + } + }), + ) + .await; + }); + } + }); + let (dir, state) = controller_jwt_state(address, false).await; + let cache = Arc::new(CredentialCache::default()); + let key = format!("copilot-cli\u{0}http://{address}/\u{0}agentgateway"); + let issued = |value: &str| agentdesktop_core::model::LlmGatewayCredential { + credential: value.to_owned(), + expires_at_unix_seconds: now_unix() + 600, + }; + 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.clone(), + )); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let request = |body: &'static [u8]| { + Request::builder() + .method(Method::POST) + .uri(format!( + "http://{proxy_address}/copilot-cli/v1/chat/completions" + )) + .header(PAIRING_HEADER, PAIRING) + .body(Full::new(Bytes::from_static(body))) + .unwrap() + }; + // Accepted: served from the cache, entry kept. + cache.insert(&key, "device-test", &issued("good")); + let response = client.request(request(b"{\"n\":1}")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(cache.get(&key, "device-test").as_deref(), Some("good")); + // Rejected: entry dropped, one retry that needs a controller. + cache.insert(&key, "device-test", &issued("stale")); + let (status, code) = + error_code(client.request(request(b"{\"n\":2}")).await.unwrap()).await; + assert_eq!(code, "agentdesktop_credential", "{status}"); + assert_eq!(cache.get(&key, "device-test"), None); + let seen = seen.lock().unwrap().clone(); + assert_eq!( + seen, + vec![ + ("Bearer good".to_owned(), b"{\"n\":1}".to_vec()), + ("Bearer stale".to_owned(), b"{\"n\":2}".to_vec()), + ], + "each body reaches the gateway exactly once per attempt" + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // With a client token forwarded (prefix-less route, githubOAuth source + // request) a 401 is returned as is: no retry, and the cached gateway + // credential is kept. + #[tokio::test] + async fn client_token_401_is_not_retried_and_keeps_the_cached_credential() { + 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 attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = attempts.clone(); + let upstream_task = tokio::spawn(async move { + loop { + let (socket, _) = upstream.accept().await.unwrap(); + let seen = seen.clone(); + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection( + TokioIo::new(socket), + service_fn(move |request: Request| { + let seen = seen.clone(); + async move { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + assert_eq!( + request.headers()[AUTHORIZATION], + "Bearer cached" + ); + assert_eq!(request.headers()["x-llm-token"], "tid=bad"); + Ok::<_, Infallible>( + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(), + ) + } + }), + ) + .await; + }); + } + }); + let (dir, state) = controller_jwt_state(address, true).await; + let cache = Arc::new(CredentialCache::default()); + let key = format!("vscode\u{0}http://{address}/\u{0}agentgateway"); + cache.insert( + &key, + "device-test", + &agentdesktop_core::model::LlmGatewayCredential { + credential: "cached".to_owned(), + expires_at_unix_seconds: now_unix() + 600, + }, + ); + 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.clone(), + )); + let client: Client> = + Client::builder(TokioExecutor::new()).build_http(); + let request = Request::builder() + .method(Method::POST) + .uri(format!("http://{proxy_address}/chat/completions")) + .header(PAIRING_HEADER, PAIRING) + .header(AUTHORIZATION, "Bearer tid=bad") + .body(Full::new(Bytes::from_static(b"{}"))) + .unwrap(); + let response = client.request(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(cache.get(&key, "device-test").as_deref(), Some("cached")); + proxy.abort(); + let _ = proxy.await; + drop(client); + upstream_task.abort(); + drop(dir); + }) + .await + .unwrap(); + } + + // Controller-JWT state with an identity.json but no controller configured: + // the device id is readable for the cache key, a fetch fails at once + // ("requires a controller"), so no timeout is involved. + async fn controller_jwt_state( + address: std::net::SocketAddr, + client_token: bool, + ) -> (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 github = if client_token { + " githubOAuth:\n source: request\n" + } else { + "" + }; + let config = parse_daemon(&format!( + "llmGateway:\n url: http://{address}\n{github} authentication:\n type: controllerJwt\n audience: agentgateway\n allowedClientIds: [copilot-cli, vscode]\n" + )).unwrap(); + let (_, discovery) = watch::channel(Arc::new(Discovery { + agents: vec![], + model_runtimes: vec![], + })); + let state = AppState { + 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) + } + + #[tokio::test] + async fn refuses_a_body_over_the_cap_with_a_json_error() { + tokio::time::timeout(Duration::from_secs(15), async { + let upstream = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let (dir, state) = request_source_state(upstream.local_addr().unwrap()).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}/chat/completions")) + .header(PAIRING_HEADER, PAIRING) + .header(AUTHORIZATION, "Bearer tid=from-client") + .body(Full::new(Bytes::from(vec![b'x'; MAX_REQUEST_BODY + 1]))) + .unwrap(); + assert_eq!( + error_code(client.request(request).await.unwrap()).await, + (StatusCode::PAYLOAD_TOO_LARGE, "body_too_large".to_owned()) + ); + proxy.abort(); + let _ = proxy.await; + drop(client); + drop(dir); + }) + .await + .unwrap(); + } + + #[test] + fn credential_cache_honours_device_ttl_and_invalidation() { + let cache = CredentialCache::default(); + let far = now_unix() + 600; + let issued = |value: &str, expires: u64| agentdesktop_core::model::LlmGatewayCredential { + credential: value.to_owned(), + expires_at_unix_seconds: expires, + }; + cache.insert("k", "device-1", &issued("one", far)); + assert_eq!(cache.get("k", "device-1").as_deref(), Some("one")); + assert_eq!( + cache.get("k", "device-2"), + None, + "another device never sees it" + ); + assert_eq!(cache.get("other", "device-1"), None); + cache.invalidate_if("k", "someone-else"); + assert_eq!( + cache.get("k", "device-1").as_deref(), + Some("one"), + "a different credential leaves the entry alone" + ); + cache.invalidate_if("k", "one"); + assert_eq!(cache.get("k", "device-1"), None); + // Already within the expiry margin: not cached at all. + cache.insert("m", "device-1", &issued("m1", now_unix() + 10)); + assert_eq!(cache.get("m", "device-1"), None); + // Lifetime is capped by the TTL, not the token's own expiry. + cache.insert("t", "device-1", &issued("t1", far)); + let entries = cache.entries.lock().unwrap(); + let entry = &entries["t"]; + assert!(entry.valid_until <= std::time::Instant::now() + CREDENTIAL_CACHE_TTL); + assert!(entry.valid_until_unix <= now_unix() + CREDENTIAL_CACHE_TTL.as_secs()); + } + async fn request_source_state(address: std::net::SocketAddr) -> (tempfile::TempDir, AppState) { let dir = tempfile::tempdir().unwrap(); let store = SecretStore::new(dir.path()).unwrap(); @@ -449,6 +2172,7 @@ mod tests { state_directory: String::new(), inventory_interval: Duration::from_secs(60), controller: None, + llm_proxy: None, }, discovery, enrollment: EnrollmentState::new(false), diff --git a/crates/agent/src/oidc.rs b/crates/agent/src/oidc.rs index 00a74768..e95c6bb4 100644 --- a/crates/agent/src/oidc.rs +++ b/crates/agent/src/oidc.rs @@ -94,17 +94,14 @@ impl AuthorizationPage { "Connect Agentdesktop", &format!( r#"

Agentdesktop needs your identity before it can issue credentials to configured agents.

-{} +{}{} "#, - format!( - "{}{}", - checklist(false, subscription_available, SubscriptionState::Pending), - if github_available { - "
GitHub CopilotConnect your GitHub account after organization sign-in.
" - } else { - "" - } - ) + checklist(false, subscription_available, SubscriptionState::Pending), + if github_available { + "
GitHub CopilotConnect your GitHub account after organization sign-in.
" + } else { + "" + } ), ), Self::Subscription => page( @@ -509,7 +506,11 @@ pub(crate) async fn wait_for_authorization_code_with_page( println!("Open this URL to connect Agentdesktop:\n{prompt_url}"); if open_browser { tracing::info!(%prompt_url, "opening Agentdesktop authorization page"); - if let Err(error) = open::that(prompt_url.as_str()) { + // Detached: `open::that` waits for the launcher to exit, and a stuck + // `xdg-open` (no display, portal call hanging) would block this runtime + // worker with the just-spawned callback server behind it, so the page + // the user is told to open never answers. + if let Err(error) = open::that_detached(prompt_url.as_str()) { tracing::warn!(%error, "could not open the browser automatically"); } } else { diff --git a/crates/agent/src/provider/README.md b/crates/agent/src/provider/README.md index e2417f50..5f8714d2 100644 --- a/crates/agent/src/provider/README.md +++ b/crates/agent/src/provider/README.md @@ -2,6 +2,9 @@ Features currently implemented by Agentdesktop. +GitHub Copilot CLI managed configuration requires user mode (the file is +the user's `providers.json`, pointed at the daemon's loopback LLM proxy; its +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 +13,24 @@ 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: its 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 6a24256b..a57a2cde 100644 --- a/crates/agent/src/provider/claude_code/mod.rs +++ b/crates/agent/src/provider/claude_code/mod.rs @@ -132,6 +132,7 @@ mod tests { fn claude_hooks_keep_the_executable_and_arguments_separate() { let provider = ClaudeCode::default(); let context = ReconcileContext { + llm_proxy: None, merge_user_settings: false, credential_helper: PathBuf::from(r"C:\Program Files\Agent Desktop\agentdesktop.exe"), socket: PathBuf::from(r"\\.\pipe\agentdesktop"), 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..a7cf758b --- /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": [{ "id": "gpt-4.1", "wireModel": "gpt-4.1-mini" }] }); + } + serde_json::to_string_pretty(&document).unwrap() + }; + container.write(CONFIG, &config(true)).await?; + container.exec(&["chown", "tester:tester", CONFIG]).await?; + + // The proxy cannot run in one-shot mode, so a dry run with the + // listener configured is refused rather than previewed. + info!("Checking that dry run refuses the listener"); + let refusal = "daemon.llmProxy.listen cannot be combined with --once or --dry-run"; + let refused = container + .exec_as( + "tester", + &["agentdesktop", "daemon", "--config", CONFIG, "--dry-run"], + Duration::from_secs(15), + ) + .await + .map(|output| output.contains(refusal)) + .unwrap_or_else(|error| format!("{error:#}").contains(refusal)); + ensure!(refused, "dry run with llmProxy.listen must be refused with the documented message"); + container.exec(&["test", "!", "-e", SIDECAR]).await?; + + container + .start_process( + "daemon", + &[ + "runuser", + "-u", + "tester", + "--", + "agentdesktop", + "daemon", + "--config", + CONFIG, + ], + ) + .await?; + container + .wait_ready(&["agentdesktop", "--socket", SOCKET, "status"]) + .await?; + + info!("Checking discovery and the written providers file"); + let discovered = container + .exec(&["agentdesktop", "--socket", SOCKET, "discover"]) + .await?; + ensure!( + discovered.contains(&format!("copilot\t{VERSION}\t")), + "Copilot CLI discovery failed: {discovered}" + ); + let pairing = container.exec(&["cat", PAIRING]).await?; + let pairing = pairing.trim(); + ensure!(pairing.len() >= 32, "pairing not created: {pairing:?}"); + let written: Value = serde_json::from_str(&container.exec(&["cat", PROVIDERS]).await?)?; + let providers = written["providers"].as_array().cloned().unwrap_or_default(); + ensure!( + providers.iter().any(|provider| provider["name"] == "my-own"), + "user provider lost: {written}" + ); + let ours = providers + .iter() + .find(|provider| provider["name"] == "agentdesktop") + .cloned() + .unwrap_or_default(); + ensure!( + ours["baseUrl"] == format!("http://{LISTEN}/copilot-cli/v1"), + "unexpected managed provider: {written}" + ); + ensure!( + ours["headers"]["x-agentdesktop-pairing"] == pairing, + "pairing header does not match the daemon's pairing: {written}" + ); + ensure!(ours.get("apiKey").is_none(), "no apiKey may be written"); + let mode = container.exec(&["stat", "-c", "%a", PROVIDERS]).await?; + ensure!(mode.trim() == "600", "providers file mode {mode}"); + container.exec(&["test", "-e", SIDECAR]).await?; + + info!("Checking that a client using the file reaches the gateway through the proxy"); + let status = container + .exec_as( + "tester", + &[ + "curl", + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + "-H", + &format!("x-agentdesktop-pairing: {pairing}"), + "-H", + "content-type: application/json", + "-X", + "POST", + &format!("http://{LISTEN}/copilot-cli/v1/chat/completions"), + "-d", + "{\"model\":\"gpt-4.1-mini\",\"messages\":[]}", + ], + Duration::from_secs(15), + ) + .await?; + // The stub gateway wants its own token, which no gateway identity is + // configured to supply here; the request still has to arrive. + ensure!( + gateway + .requests() + .iter() + .any(|request| request["path"] == "/v1/chat/completions"), + "no request reached the gateway (client saw {status}): {:?}", + gateway.requests() + ); + + info!("Removing the program"); + container.write(CONFIG, &config(false)).await?; + container.stop_process("daemon").await?; + container + .start_process( + "daemon", + &[ + "runuser", + "-u", + "tester", + "--", + "agentdesktop", + "daemon", + "--config", + CONFIG, + ], + ) + .await?; + container + .wait_ready(&["agentdesktop", "--socket", SOCKET, "status"]) + .await?; + let remaining: Value = serde_json::from_str(&container.exec(&["cat", PROVIDERS]).await?)?; + let providers = remaining["providers"].as_array().cloned().unwrap_or_default(); + ensure!( + providers.len() == 1 && providers[0]["name"] == "my-own", + "managed entries not removed: {remaining}" + ); + let mode = container.exec(&["stat", "-c", "%a", PROVIDERS]).await?; + ensure!( + mode.trim() == "600", + "removal must keep the user's file owner-only, got {mode}" + ); + container.exec(&["test", "!", "-e", SIDECAR]).await?; + Ok(()) + }, + ) + .await +} diff --git a/crates/agent/src/provider/copilot/mod.rs b/crates/agent/src/provider/copilot/mod.rs new file mode 100644 index 00000000..1b31f2a7 --- /dev/null +++ b/crates/agent/src/provider/copilot/mod.rs @@ -0,0 +1,81 @@ +use std::path::PathBuf; + +use agentdesktop_core::config::DaemonConfig; +use agentdesktop_core::model::Discovery; + +use super::{Provider, ReconcileContext}; +use crate::reconcile::ReconcilePlan; + +pub(super) mod discovery; +pub(crate) mod reconcile; + +#[cfg(test)] +mod tests; + +/// Display name used in reconcile-plan reporting (dry-run output, conflict +/// and error messages). +pub(super) const DISPLAY_NAME: &str = "GitHub Copilot CLI"; +/// Provider name for the OpenAI-shaped route (`/copilot-cli/v1`). +pub(super) const PROVIDER_OPENAI: &str = agentdesktop_core::config::CopilotConfig::PROVIDER_OPENAI; +/// Provider name for the Anthropic-shaped route (`/copilot-cli`). +pub(super) const PROVIDER_ANTHROPIC: &str = + agentdesktop_core::config::CopilotConfig::PROVIDER_ANTHROPIC; + +/// GitHub Copilot CLI: points the user's `providers.json` at the loopback LLM +/// proxy's `/copilot-cli` route. User mode only; `providers_path` is `None` +/// for a system daemon, which has no user file to manage. +pub struct Copilot { + pub providers_path: Option, +} + +impl Copilot { + pub const ID: &'static str = "copilot"; +} + +/// The user's `providers.json` from the daemon's environment +/// (`COPILOT_PROVIDERS_CONFIG`, `COPILOT_HOME`, then the home directory). +pub fn default_copilot_providers_path() -> anyhow::Result { + reconcile::providers_path(&|name| std::env::var_os(name)) +} + +#[async_trait::async_trait] +impl Provider for Copilot { + async fn discover(&self) -> Discovery { + Discovery { + agents: discovery::discover().into_iter().collect(), + model_runtimes: Vec::new(), + } + } + + fn plan(&self, ctx: &ReconcileContext, config: &DaemonConfig) -> anyhow::Result { + let configured = config.programs.copilot.as_ref().map(|program| { + let gateway = config + .llm_gateway + .as_ref() + .filter(|_| program.use_llm_gateway); + (program, gateway) + }); + let proxy = ctx + .llm_proxy + .as_ref() + .map(|proxy| (proxy.address, &*proxy.pairing)); + let plan = ReconcilePlan::default(); + // The inverse of Grok's check: the file lives in the user's home, so a + // system daemon has no providers path and rejects the program before + // any provider writes. + let Some(path) = self + .providers_path + .as_deref() + .filter(|_| ctx.merge_user_settings) + else { + if configured.is_some() { + anyhow::bail!( + "{DISPLAY_NAME} reads its providers from the user's own Copilot directory; remove programs.copilot or run agentdesktop with --user (daemon.user: true) so it can manage that file" + ); + } + return Ok(plan); + }; + reconcile::plan(path, proxy, configured, &plan)?; + Ok(plan) + } +} diff --git a/crates/agent/src/provider/copilot/reconcile.rs b/crates/agent/src/provider/copilot/reconcile.rs new file mode 100644 index 00000000..f39730d9 --- /dev/null +++ b/crates/agent/src/provider/copilot/reconcile.rs @@ -0,0 +1,297 @@ +use super::{DISPLAY_NAME, PROVIDER_ANTHROPIC, PROVIDER_OPENAI}; + +use std::{ffi::OsString, net::SocketAddr, path::Path, path::PathBuf}; + +use agentdesktop_core::config::{CopilotConfig, LlmGatewayConfig}; +use anyhow::Context; +use serde_json::{Value, json}; +use tracing::warn; + +use crate::provider::json_merge; +use crate::reconcile::ReconcilePlan; + +/// What the plan report calls the managed part of the file. +const DESCRIPTION: &str = "providers"; +/// The file carries the pairing value, so a file we write is owner-only. +const FILE_MODE: u32 = 0o600; + +/// Resolves the `providers.json` path: `COPILOT_PROVIDERS_CONFIG` if set, else +/// `$COPILOT_HOME/providers.json` if `COPILOT_HOME` is set, else +/// `/.copilot/providers.json` with home read from `HOME`, then +/// `USERPROFILE`. Empty values count as unset. Errors when no home resolves. +pub(crate) fn providers_path(env: &dyn Fn(&str) -> Option) -> anyhow::Result { + let get = |name: &str| env(name).filter(|value| !value.is_empty()); + if let Some(explicit) = get("COPILOT_PROVIDERS_CONFIG") { + return Ok(PathBuf::from(explicit)); + } + if let Some(copilot_home) = get("COPILOT_HOME") { + return Ok(PathBuf::from(copilot_home).join("providers.json")); + } + let home = get("HOME") + .or_else(|| get("USERPROFILE")) + .context("the GitHub Copilot CLI providers file needs HOME, USERPROFILE or COPILOT_HOME")?; + Ok(PathBuf::from(home).join(".copilot").join("providers.json")) +} + +/// The JSON object merged into `providers.json`: two provider entries pointed +/// at the loopback proxy's `/copilot-cli` route with the pairing header, plus +/// one model entry per configured model. No `apiKey` is written: the proxy +/// adds the gateway credential itself. +pub(super) fn managed_document( + config: &CopilotConfig, + listen: SocketAddr, + pairing: &str, +) -> anyhow::Result { + let headers = json!({ crate::llm_proxy::PAIRING_HEADER: pairing }); + Ok(json!({ + "providers": [ + { + "name": PROVIDER_OPENAI, + "type": "openai", + "baseUrl": format!("http://{listen}/copilot-cli/v1"), + "headers": headers, + }, + { + "name": PROVIDER_ANTHROPIC, + "type": "anthropic", + "baseUrl": format!("http://{listen}/copilot-cli"), + "headers": headers, + }, + ], + "models": config.model_documents(), + })) +} + +/// The two arrays agentdesktop manages by key: providers by `name`, models by +/// `provider` and `id` (the CLI addresses a model as `/`). +const KEYED: &[json_merge::KeyedArray] = &[ + json_merge::KeyedArray { + field: "providers", + keys: &["name"], + }, + json_merge::KeyedArray { + field: "models", + keys: &["provider", "id"], + }, +]; + +fn options() -> json_merge::MergeOptions { + json_merge::MergeOptions { + mode: FILE_MODE, + keyed_arrays: KEYED, + // The file holds the pairing and possibly the user's own API keys. + redact_diff: true, + } +} + +/// Plans the create/update/remove/conflict for `providers.json`. +/// +/// With the program absent, the gateway off for it (`useLlmGateway: false` or +/// no top-level `llmGateway`), or no loopback proxy, the managed entries are +/// removed and the user's own entries stay. Otherwise the managed document is +/// merged in. Entries under the managed keys (provider names `agentdesktop` +/// and `agentdesktop-anthropic`, models under those providers) belong to +/// agentdesktop once it has written the file; before that, a user entry under +/// one of those keys is a conflict, unless it carries a pairing header (then +/// it was written by an agentdesktop daemon whose sidecar is gone, and it is +/// replaced). +pub(super) fn plan( + path: &Path, + proxy: Option<(SocketAddr, &str)>, + configured: Option<(&CopilotConfig, Option<&LlmGatewayConfig>)>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + let state_path = json_merge::state_path(path); + let pairing = proxy.map(|(_, pairing)| pairing); + let Some((config, Some(_gateway))) = configured else { + remove(path, &state_path, pairing, plan)?; + return Ok(()); + }; + let Some((listen, pairing)) = proxy else { + warn!( + path = %path.display(), + "programs.copilot is configured but the local LLM proxy is not available, so the Copilot CLI is not pointed at the gateway; the reason is llmProxy.error in daemon-info (or daemon.llmProxy.listen is unset); removing the managed Copilot CLI providers" + ); + remove(path, &state_path, None, plan)?; + return Ok(()); + }; + let managed = managed_document(config, listen, pairing)?; + if let Some(reason) = foreign_managed_entry(path, &state_path, &managed, plan)? { + warn!(path = %path.display(), reason, "refusing to change the Copilot CLI providers file"); + plan.record( + DISPLAY_NAME, + &format!("{DESCRIPTION} ({reason})"), + "conflict", + path, + ); + return Ok(()); + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + && !parent.exists() + { + // A Copilot directory we create holds the pairing: owner-only, like the + // file. An existing directory keeps the user's mode. + plan.ensure_private_dir(parent); + } + json_merge::plan_merge_with( + path, + &state_path, + managed, + false, + DESCRIPTION, + DISPLAY_NAME, + options(), + plan, + ) +} + +/// Managed entries are removed through the sidecar when there is one, and by +/// their pairing header when there is none. +fn remove( + path: &Path, + state_path: &Path, + pairing: Option<&str>, + plan: &ReconcilePlan, +) -> anyhow::Result<()> { + if !json_merge::plan_remove_with(path, state_path, DESCRIPTION, DISPLAY_NAME, options(), plan)? + && let Some(pairing) = pairing + { + plan_remove_orphaned(path, pairing, plan)?; + } + Ok(()) +} + +/// Before agentdesktop has written the file (no sidecar), an entry under a +/// managed key is the user's: a provider with one of the managed names that +/// carries no pairing header, or a model under a managed provider name whose +/// provider entry was not written by an agentdesktop daemon (absent, or +/// without a pairing header). Once the sidecar exists, the managed keys are +/// agentdesktop's and their entries are replaced in place. +fn foreign_managed_entry( + path: &Path, + state_path: &Path, + managed: &Value, + plan: &ReconcilePlan, +) -> anyhow::Result> { + if json_merge::managed_after(state_path, DISPLAY_NAME, plan)?.is_some() { + return Ok(None); + } + let Some(current) = read_object(path, plan)? else { + return Ok(None); + }; + let managed_providers: Vec<&str> = managed["providers"] + .as_array() + .into_iter() + .flatten() + .filter_map(|provider| provider["name"].as_str()) + .collect(); + let written_by_agentdesktop: Vec<&str> = providers(¤t) + .filter(|provider| !provider["headers"][crate::llm_proxy::PAIRING_HEADER].is_null()) + .filter_map(|provider| provider["name"].as_str()) + .collect(); + for provider in providers(¤t) { + let Some(name) = provider["name"].as_str() else { + continue; + }; + if managed_providers.contains(&name) && !written_by_agentdesktop.contains(&name) { + return Ok(Some(format!( + "provider {name} exists but was not written by agentdesktop" + ))); + } + } + for model in current + .get("models") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let (Some(provider), Some(id)) = (model["provider"].as_str(), model["id"].as_str()) + && managed_providers.contains(&provider) + && !written_by_agentdesktop.contains(&provider) + { + return Ok(Some(format!( + "model {provider}/{id} exists under a managed provider name that was not written by agentdesktop" + ))); + } + } + Ok(None) +} + +/// Removal without a sidecar: entries under the managed names that carry +/// this daemon's own pairing value were written for this daemon (before its +/// sidecar was deleted), so they and the models under them are taken out by +/// key; the file keeps its mode and is never deleted here. Entries with +/// another pairing value, or none, are left alone, and a file that cannot be +/// read is skipped with a warning rather than failing the whole apply. +fn plan_remove_orphaned(path: &Path, pairing: &str, plan: &ReconcilePlan) -> anyhow::Result<()> { + let current = match read_object(path, plan) { + Ok(Some(current)) => current, + Ok(None) => return Ok(()), + Err(error) => { + warn!(error = %format!("{error:#}"), "skipping the Copilot CLI providers file"); + return Ok(()); + } + }; + let orphaned: Vec = providers(¤t) + .filter(|provider| provider["headers"][crate::llm_proxy::PAIRING_HEADER] == pairing) + .filter_map(|provider| provider["name"].as_str().map(str::to_owned)) + .filter(|name| name == PROVIDER_OPENAI || name == PROVIDER_ANTHROPIC) + .collect(); + if orphaned.is_empty() { + return Ok(()); + } + let mut remaining = Value::Object(current); + if let Some(entries) = remaining["providers"].as_array_mut() { + entries.retain(|provider| { + !provider["name"] + .as_str() + .is_some_and(|name| orphaned.iter().any(|own| own == name)) + }); + } + if let Some(entries) = remaining["models"].as_array_mut() { + entries.retain(|model| { + !model["provider"] + .as_str() + .is_some_and(|name| orphaned.iter().any(|own| own == name)) + }); + } + let existing = plan.read(path)?; + let mut contents = serde_json::to_vec_pretty(&remaining) + .with_context(|| format!("serialize {DISPLAY_NAME} providers"))?; + contents.push(b'\n'); + if contents == existing { + return Ok(()); + } + plan.record_diff(DISPLAY_NAME, DESCRIPTION, "update", path, None, None); + let mode = json_merge::current_mode(path).unwrap_or(FILE_MODE); + plan.write_file(path, &contents, mode) +} + +fn read_object( + path: &Path, + plan: &ReconcilePlan, +) -> anyhow::Result>> { + let existing = match plan.read(path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("read {DISPLAY_NAME} providers from {}", path.display())); + } + }; + // Anything that is not a JSON object is left to the merge, which reports it. + Ok(match serde_json::from_slice::(&existing) { + Ok(Value::Object(object)) => Some(object), + _ => None, + }) +} + +fn providers(current: &serde_json::Map) -> impl Iterator { + current + .get("providers") + .and_then(Value::as_array) + .into_iter() + .flatten() +} diff --git a/crates/agent/src/provider/copilot/testdata/Dockerfile b/crates/agent/src/provider/copilot/testdata/Dockerfile new file mode 100644 index 00000000..1bb6e175 --- /dev/null +++ b/crates/agent/src/provider/copilot/testdata/Dockerfile @@ -0,0 +1,19 @@ +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +# The real CLI is an npm package that needs Node. Discovery reads the +# `@github/copilot` manifest next to the launcher and never runs the tool, so +# an npm-shaped install of a stand-in launcher exercises the same code path: +# `/bin/copilot` -> `/lib/node_modules/@github/copilot/bin/copilot`. +ARG COPILOT_VERSION=1.0.88 +RUN groupadd --system agentdesktop \ + && useradd --create-home --user-group --groups agentdesktop tester \ + && mkdir -p /usr/local/lib/node_modules/@github/copilot/bin \ + && printf '{"name":"@github/copilot","version":"%s"}\n' "${COPILOT_VERSION}" > /usr/local/lib/node_modules/@github/copilot/package.json \ + && printf '#!/bin/sh\necho "GitHub Copilot CLI %s."\n' "${COPILOT_VERSION}" > /usr/local/lib/node_modules/@github/copilot/bin/copilot \ + && chmod 0755 /usr/local/lib/node_modules/@github/copilot/bin/copilot \ + && ln -s ../lib/node_modules/@github/copilot/bin/copilot /usr/local/bin/copilot \ + && mkdir -p /run/user/1000 && chown tester:tester /run/user/1000 +ENV HOME=/home/tester \ + XDG_RUNTIME_DIR=/run/user/1000 +WORKDIR /home/tester +CMD ["sleep", "infinity"] diff --git a/crates/agent/src/provider/copilot/testdata/providers.lab.json b/crates/agent/src/provider/copilot/testdata/providers.lab.json new file mode 100644 index 00000000..dc71489d --- /dev/null +++ b/crates/agent/src/provider/copilot/testdata/providers.lab.json @@ -0,0 +1,34 @@ +{ + "providers": [ + { + "name": "agentdesktop", + "type": "openai", + "baseUrl": "http://127.0.0.1:18095/copilot-cli/v1", + "headers": { + "x-agentdesktop-pairing": "PAIRING-FIXTURE" + } + }, + { + "name": "agentdesktop-anthropic", + "type": "anthropic", + "baseUrl": "http://127.0.0.1:18095/copilot-cli", + "headers": { + "x-agentdesktop-pairing": "PAIRING-FIXTURE" + } + } + ], + "models": [ + { + "provider": "agentdesktop-anthropic", + "id": "claude-haiku-4.5", + "modelId": "claude-haiku-4.5", + "wireModel": "claude-haiku-4-5" + }, + { + "provider": "agentdesktop", + "id": "gpt-4.1", + "modelId": "gpt-4.1", + "wireModel": "gpt-4.1-mini" + } + ] +} diff --git a/crates/agent/src/provider/copilot/tests.rs b/crates/agent/src/provider/copilot/tests.rs new file mode 100644 index 00000000..712b978c --- /dev/null +++ b/crates/agent/src/provider/copilot/tests.rs @@ -0,0 +1,1066 @@ +//! Tests for the GitHub Copilot CLI provider (specs/PR-2.md): the spec-derived +//! baseline (written before the implementation; changes to it are listed in +//! specs/PR-2-test-changes.md) plus the tests added during review. + +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 (criterion 1), 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 (criterion 6, revised: manifest only, nothing runs) -- + +#[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 (criterion 3) --------------------------------- + +#[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 (criterion 4, matches the lab fixture) --------- + +#[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 (criteria 3, 4, 8) -------------------------------- + +#[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 +// (criteria 4, 8) ------------------------------------------------------------- + +#[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 (criterion 5) ------------------------------------------ + +#[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 (criterion 4, 8) ------------------------------------- + +#[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 (criterion 2) -------------------- + +#[test] +fn system_mode_rejects_a_configured_program_before_writing_anything() { + use super::Copilot; + use crate::provider::{Provider, ReconcileContext}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + let config = parse_programs( + "llmGateway:\n url: https://gateway.example\nprograms:\n copilot:\n models:\n gpt-4.1: {}\n", + ) + .unwrap(); + let context = |merge_user_settings: bool| ReconcileContext { + merge_user_settings, + credential_helper: dir.path().join("agentdesktop"), + socket: dir.path().join("agentdesktop.sock"), + llm_proxy: None, + }; + // System mode: no providers path, or a path but no user settings. + for provider in [ + Copilot { + providers_path: None, + }, + Copilot { + providers_path: Some(path.clone()), + }, + ] { + let error = match provider.plan(&context(false), &config) { + Ok(_) => panic!("system mode with programs.copilot configured must fail"), + Err(error) => error, + }; + assert!(error.to_string().contains("--user"), "{error}"); + assert!(error.to_string().contains("GitHub Copilot CLI"), "{error}"); + } + assert!(!path.exists(), "preflight failure must not write any files"); + // System mode with the program absent does nothing. + let absent = parse_programs("llmGateway:\n url: https://gateway.example\n").unwrap(); + let plan = Copilot { + providers_path: None, + } + .plan(&context(false), &absent) + .unwrap(); + assert!( + plan.render().trim().ends_with("changed"), + "{}", + plan.render() + ); + // User mode without the proxy: a removal is planned, nothing to remove here. + Copilot { + providers_path: Some(path.clone()), + } + .plan(&context(true), &config) + .unwrap() + .apply() + .unwrap(); + assert!(!path.exists()); +} + +// --- keyed ownership, created flag, redaction ------------------------------ + +fn apply_managed(path: &std::path::Path, listen: SocketAddr, pairing: &str) { + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + path, + Some((listen, pairing)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!(!changes.has_conflicts(), "{}", changes.render()); + changes.apply().unwrap(); +} + +fn apply_removal(path: &std::path::Path) -> ReconcilePlan { + let changes = ReconcilePlan::default(); + plan(path, Some((listen_addr(), PAIRING)), None, &changes).unwrap(); + changes +} + +fn read(path: &std::path::Path) -> Value { + serde_json::from_slice(&fs::read(path).unwrap()).unwrap() +} + +#[test] +fn an_edited_managed_entry_is_replaced_and_removed_by_key() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + apply_managed(&path, listen_addr(), PAIRING); + // The user edits our model and our provider in place. + let mut document = read(&path); + for model in document["models"].as_array_mut().unwrap() { + if model["id"] == "gpt-4.1" { + model["wireModel"] = json!("gpt-4.1-edited"); + } + } + for provider in document["providers"].as_array_mut().unwrap() { + if provider["name"] == "agentdesktop" { + provider["baseUrl"] = json!("http://127.0.0.1:1/edited"); + } + } + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + // Re-apply with a new pairing: ours are replaced, not duplicated, edits gone. + apply_managed(&path, listen_addr(), "PAIRING-NEW"); + let after = read(&path); + assert_eq!(our_provider_count(&after), 2); + let openai = find_provider(&after, "agentdesktop"); + assert_eq!(openai["baseUrl"], "http://127.0.0.1:18095/copilot-cli/v1"); + assert_eq!(openai["headers"]["x-agentdesktop-pairing"], "PAIRING-NEW"); + let models = after["models"].as_array().unwrap(); + assert_eq!(models.iter().filter(|m| m["id"] == "gpt-4.1").count(), 1); + assert_eq!( + models.iter().find(|m| m["id"] == "gpt-4.1").unwrap()["wireModel"], + "gpt-4.1-mini" + ); + // Edit again, then remove the program: the edited entries go too. + let mut document = read(&path); + document["models"][0]["wireModel"] = json!("edited-again"); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + apply_removal(&path).apply().unwrap(); + assert!(!path.exists(), "nothing of ours may remain"); +} + +#[test] +fn a_user_model_with_a_managed_id_under_another_provider_is_not_a_conflict() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "providers": [{"name": "my-own", "type": "openai", "baseUrl": "https://example.invalid/v1"}], + "models": [{"provider": "my-own", "id": "gpt-4.1"}], + })) + .unwrap(), + ) + .unwrap(); + apply_managed(&path, listen_addr(), PAIRING); + let after = read(&path); + let gpt = after["models"] + .as_array() + .unwrap() + .iter() + .filter(|m| m["id"] == "gpt-4.1") + .count(); + assert_eq!( + gpt, 2, + "the user's my-own/gpt-4.1 and our agentdesktop/gpt-4.1 coexist" + ); + // A user model under OUR provider with another id is kept across re-apply. + let mut document = read(&path); + document["models"] + .as_array_mut() + .unwrap() + .push(json!({"provider": "agentdesktop", "id": "gpt-user"})); + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + apply_managed(&path, listen_addr(), PAIRING); + assert!( + read(&path)["models"] + .as_array() + .unwrap() + .iter() + .any(|m| m["id"] == "gpt-user") + ); +} + +#[test] +fn an_orphaned_managed_provider_with_a_pairing_header_is_replaced_not_a_conflict() { + // The sidecar is gone (deleted, or the file came from another device), but + // the entry carries a pairing header: an agentdesktop wrote it, so it is + // replaced and owned again. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + apply_managed(&path, listen_addr(), "PAIRING-OLD"); + fs::remove_file(super::super::json_merge::state_path(&path)).unwrap(); + apply_managed(&path, listen_addr(), PAIRING); + let after = read(&path); + assert_eq!(our_provider_count(&after), 2); + assert_eq!( + find_provider(&after, "agentdesktop")["headers"]["x-agentdesktop-pairing"], + PAIRING + ); + assert!(super::super::json_merge::state_path(&path).exists()); +} + +#[test] +fn a_file_recreated_by_the_daemon_after_the_user_deleted_theirs_is_removed_whole() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + write_user_owned_document(&path); + apply_managed(&path, listen_addr(), PAIRING); + fs::remove_file(&path).unwrap(); + apply_managed(&path, listen_addr(), PAIRING); + apply_removal(&path).apply().unwrap(); + assert!( + !path.exists(), + "the daemon created this file, so removal deletes it" + ); +} + +#[test] +fn the_plan_report_never_shows_the_pairing_or_user_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + write_user_owned_document(&path); + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + let report = changes.render(); + assert!(report.contains("UPDATE"), "{report}"); + assert!( + !report.contains(PAIRING) && !report.contains("sk-user"), + "{report}" + ); + changes.apply().unwrap(); + let removal = apply_removal(&path); + let report = removal.render(); + assert!( + !report.contains(PAIRING) && !report.contains("sk-user"), + "{report}" + ); +} + +#[test] +fn a_managed_model_dropped_from_the_config_is_removed_even_if_edited() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + apply_managed(&path, listen_addr(), PAIRING); + let mut document = read(&path); + for model in document["models"].as_array_mut().unwrap() { + if model["id"] == "claude-haiku-4.5" { + model["wireModel"] = json!("edited"); + } + } + fs::write(&path, serde_json::to_vec_pretty(&document).unwrap()).unwrap(); + // The admin drops the anthropic model from the config. + let mut config = sample_config(); + config.models.remove("claude-haiku-4.5"); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + let after = read(&path); + assert!( + !after["models"] + .as_array() + .unwrap() + .iter() + .any(|m| m["id"] == "claude-haiku-4.5"), + "a previously managed model must not linger: {after}" + ); + apply_removal(&path).apply().unwrap(); + assert!(!path.exists()); +} + +#[test] +fn a_user_model_under_a_managed_provider_name_without_that_provider_is_a_conflict() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "providers": [], + "models": [{"provider": "agentdesktop", "id": "gpt-4.1"}], + })) + .unwrap(), + ) + .unwrap(); + let before = fs::read(&path).unwrap(); + let config = sample_config(); + let gateway = sample_gateway(); + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), PAIRING)), + Some((&config, Some(&gateway))), + &changes, + ) + .unwrap(); + assert!(changes.has_conflicts()); + assert!( + changes.render().contains("model agentdesktop/gpt-4.1"), + "{}", + changes.render() + ); + assert_eq!(fs::read(&path).unwrap(), before); +} + +#[test] +fn removal_without_a_sidecar_strips_entries_that_carry_a_pairing_header() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("providers.json"); + write_user_owned_document(&path); + apply_managed(&path, listen_addr(), PAIRING); + fs::remove_file(super::super::json_merge::state_path(&path)).unwrap(); + // Another device's (or a hand-written) entry with a different pairing is + // not this daemon's and stays. + let changes = ReconcilePlan::default(); + plan( + &path, + Some((listen_addr(), "PAIRING-OTHER")), + None, + &changes, + ) + .unwrap(); + changes.apply().unwrap(); + assert_eq!(our_provider_count(&read(&path)), 2); + let removal = apply_removal(&path); + assert!(!removal.render().contains(PAIRING)); + removal.apply().unwrap(); + let after = read(&path); + assert_eq!(our_provider_count(&after), 0, "{after}"); + assert!( + after["models"] + .as_array() + .unwrap() + .iter() + .all(|m| m["provider"] == "my-own-openai") + ); + assert_user_entries_present(&after); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } +} + +#[test] +fn config_rejects_a_non_object_entry_and_names_the_entry() { + let error = + parse_programs("programs:\n copilot:\n models:\n gpt-4.1: not-an-object\n") + .unwrap_err(); + assert!(format!("{error:#}").contains("gpt-4.1"), "{error:#}"); + let error = + parse_programs("programs:\n copilot:\n models:\n gpt-4.1:\n apiKey: x\n") + .unwrap_err(); + assert!( + format!("{error:#}").contains("models.gpt-4.1.apiKey"), + "{error:#}" + ); +} diff --git a/crates/agent/src/provider/cursor/discovery.rs b/crates/agent/src/provider/cursor/discovery.rs index 17f12791..90994bd1 100644 --- a/crates/agent/src/provider/cursor/discovery.rs +++ b/crates/agent/src/provider/cursor/discovery.rs @@ -12,21 +12,15 @@ use crate::provider::{metadata, vscode::discovery as vscode}; const PRODUCT_NAME: &str = "Cursor"; pub(super) fn discover() -> Option { - let executable = metadata::find_all_in_path("cursor") - .into_iter() - .chain( + let executable = pick_executable( + metadata::find_all_in_path("cursor").into_iter().chain( executable_candidates() .into_iter() .filter(|candidate| candidate.is_file()), - ) - .find(|candidate| is_cursor(candidate))?; - // Only this install's own manifest, so an unrelated `cursor` on PATH cannot - // be reported carrying a version read out of a real Cursor elsewhere. - let version = metadata::packaged_manifest_candidates(&executable) - .into_iter() - .find_map(|path| metadata::json_package_version(&path, PRODUCT_NAME)); + ), + )?; Some(Agent { - version, + version: discover_version(&executable), executable, kind: Cursor::ID.to_owned(), mcp_servers: discover_mcp_servers(), @@ -34,6 +28,32 @@ pub(super) fn discover() -> Option { }) } +/// Prefers a Cursor launcher that carries its own packaged version over a PATH +/// shim with no nearby manifest (`~/.local/bin/cursor` is a common example). +fn pick_executable(candidates: impl IntoIterator) -> Option { + let mut without_version = None; + for candidate in candidates { + if !is_cursor(&candidate) { + continue; + } + if discover_version(&candidate).is_some() { + return Some(candidate); + } + if without_version.is_none() { + without_version = Some(candidate); + } + } + without_version +} + +fn discover_version(executable: &Path) -> Option { + // Only this install's own manifest, so an unrelated `cursor` on PATH cannot + // be reported carrying a version read out of a real Cursor elsewhere. + metadata::packaged_manifest_candidates(executable) + .into_iter() + .find_map(|path| metadata::json_package_version(&path, PRODUCT_NAME)) +} + /// Rejects a `cursor` launcher whose packaged manifest names a different /// product, mirroring the Visual Studio Code check. /// @@ -204,6 +224,40 @@ mod tests { } } + #[test] + fn prefers_a_packaged_install_over_a_versionless_path_shim() { + let root = std::env::temp_dir().join(format!( + "agentdesktop-cursor-version-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + let shim_dir = root.join("shim"); + let app_bin = root.join("Cursor.app/Contents/Resources/app/bin"); + std::fs::create_dir_all(&shim_dir).unwrap(); + std::fs::create_dir_all(&app_bin).unwrap(); + let shim = shim_dir.join("cursor"); + let launcher = app_bin.join("cursor"); + std::fs::write(&shim, "").unwrap(); + std::fs::write(&launcher, "").unwrap(); + std::fs::write( + root.join("Cursor.app/Contents/Resources/app/package.json"), + json!({ "name": "Cursor", "version": "3.21.13" }).to_string(), + ) + .unwrap(); + + assert_eq!(super::discover_version(&shim), None); + assert_eq!( + super::discover_version(&launcher).as_deref(), + Some("3.21.13") + ); + assert_eq!( + super::pick_executable([shim, launcher.clone()]), + Some(launcher) + ); + + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn identifies_cursor_only_from_its_own_manifest() { let root = std::env::temp_dir().join(format!("agentdesktop-cursor-{}", std::process::id())); diff --git a/crates/agent/src/provider/json_merge.rs b/crates/agent/src/provider/json_merge.rs index d9e39c35..74eb0688 100644 --- a/crates/agent/src/provider/json_merge.rs +++ b/crates/agent/src/provider/json_merge.rs @@ -25,7 +25,40 @@ 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. +#[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 +67,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,10 +111,13 @@ 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); let mut combined = match existing.as_deref() { Some(contents) => match serde_json::from_slice::(contents) { @@ -65,6 +135,16 @@ pub(super) fn plan_merge( } else if legacy_owned { combined = json!({}); } + // Keyed elements are owned by key: whatever the file holds under a managed + // key (edited or not) gives way to the managed element, and an element the + // previous merge added under a key that is no longer managed goes away. + for keyed in options.keyed_arrays { + if let Some(previous) = previous.as_ref() { + let added = keyed_added(keyed, &previous.before, &previous.after); + remove_keyed(&mut combined, keyed, &added); + } + remove_keyed(&mut combined, keyed, &managed); + } let before = combined.clone(); merge_overlay(&mut combined, managed); @@ -76,17 +156,19 @@ pub(super) fn plan_merge( Some(_) => "update", None => "create", }; - plan.record_diff( + record( + plan, display_name, description, action, path, existing.as_deref(), Some(&contents), + options.redact_diff, ); if action != "unchanged" { - plan.write_file(path, &contents, 0o644)?; + plan.write_file(path, &contents, options.mode)?; } let mut state = serde_json::to_vec_pretty(&MergeState { created, @@ -106,6 +188,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); @@ -129,7 +231,27 @@ pub(super) fn plan_remove( return Ok(true); } }; - let settings = rollback_overlay(&settings, &state.before, &state.after); + 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 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); let proposed = if state.created && empty { None @@ -144,13 +266,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 +282,9 @@ pub(super) fn plan_remove( } else if let Some(contents) = proposed && action == "update" { - plan.write_file(path, &contents, 0o644)?; + // The user's remaining content keeps the file's current mode: a file + // that was owner-only (it may hold the user's own keys) stays so. + plan.write_file(path, &contents, current_mode(path).unwrap_or(0o644))?; } remove_file(state_path, display_name, plan)?; debug!(provider = display_name, action, path = %path.display(), "planned removal of managed values from user settings"); @@ -283,3 +409,146 @@ 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() +} + +/// Remove from `document[keyed.field]` every element whose key equals the key +/// of an element in `managed[keyed.field]`. +fn remove_keyed(document: &mut Value, keyed: &KeyedArray, managed: &Value) { + let managed_keys: Vec> = managed + .get(keyed.field) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|element| element_key(keyed, element)) + .collect(); + if managed_keys.is_empty() { + return; + } + if let Some(elements) = document.get_mut(keyed.field).and_then(Value::as_array_mut) { + 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> = before + .get(keyed.field) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|element| element_key(keyed, element)) + .collect(); + let added: Vec = after + .get(keyed.field) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|element| { + element_key(keyed, element).is_some_and(|key| !before_keys.contains(&key)) + }) + .cloned() + .collect(); + json!({ keyed.field: added }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn managed() -> Value { + json!({"env": {"MANAGED": "1"}}) + } + + // 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" + ); + } +} diff --git a/crates/agent/src/provider/mod.rs b/crates/agent/src/provider/mod.rs index ebd28c4c..465f8cf3 100644 --- a/crates/agent/src/provider/mod.rs +++ b/crates/agent/src/provider/mod.rs @@ -11,6 +11,7 @@ use crate::reconcile::ReconcilePlan; pub mod claude_code; pub mod claude_desktop; pub mod codex; +pub mod copilot; pub mod cursor; pub mod grok; mod json_merge; @@ -43,4 +44,9 @@ pub struct ReconcileContext { pub merge_user_settings: bool, pub credential_helper: PathBuf, pub socket: PathBuf, + /// The bound loopback LLM proxy, for programs whose client files point at + /// it instead of a credential helper. `None` when the proxy is off, its bind + /// failed, or no pairing could be created: such programs must then remove + /// or neutralise any pointer they wrote earlier, never leave a stale one. + pub llm_proxy: Option, } diff --git a/crates/agent/src/reconcile/mod.rs b/crates/agent/src/reconcile/mod.rs index 64e71621..ddc7f485 100644 --- a/crates/agent/src/reconcile/mod.rs +++ b/crates/agent/src/reconcile/mod.rs @@ -4,7 +4,8 @@ pub use plan::ReconcilePlan; use crate::provider::{ Provider, ReconcileContext, claude_code::ClaudeCode, claude_desktop::ClaudeDesktop, - codex::Codex, cursor::Cursor, grok::Grok, ollama::Ollama, opencode::OpenCode, vscode::VsCode, + codex::Codex, copilot::Copilot, cursor::Cursor, grok::Grok, ollama::Ollama, opencode::OpenCode, + vscode::VsCode, }; use agentdesktop_core::{config::DaemonConfig, model::Discovery}; use serde_json::Value; @@ -23,6 +24,7 @@ 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}, }; @@ -44,6 +46,7 @@ impl Reconciler { open_code_managed_config_path: PathBuf, open_code_plugin_path: PathBuf, grok_managed_config_path: PathBuf, + copilot_providers_path: Option, credential_helper: PathBuf, socket: PathBuf, ) -> Self { @@ -52,6 +55,7 @@ impl Reconciler { merge_user_settings, credential_helper, socket, + llm_proxy: None, }, providers: Arc::new(vec![ Box::new(ClaudeCode { @@ -73,11 +77,20 @@ impl Reconciler { Box::new(Grok { managed_config_path: grok_managed_config_path, }), + Box::new(Copilot { + providers_path: copilot_providers_path, + }), Box::new(Ollama), ]), } } + /// Attach the bound loopback LLM proxy so reconcilers can point client files at it. + pub fn with_llm_proxy(mut self, llm_proxy: Option) -> Self { + self.context.llm_proxy = llm_proxy; + self + } + /// Plan every provider, including cleanup for disabled providers, before /// applying any writes. A later provider's failure leaves all files intact. pub fn plan(&self, config: &DaemonConfig) -> anyhow::Result { @@ -274,6 +287,7 @@ programs: root.join("opencode/config.json"), root.join("opencode/plugin.js"), root.join("grok/managed_config.toml"), + Some(root.join("copilot/providers.json")), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -312,6 +326,7 @@ programs: root.join("opencode/config.json"), root.join("opencode/plugin.js"), root.join("grok/managed_config.toml"), + Some(root.join("copilot/providers.json")), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -321,6 +336,99 @@ 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 (the lab found it dropped once). + 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()), + 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 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, + 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() { @@ -367,6 +475,7 @@ programs: root.join("opencode/config.json"), root.join("opencode/plugin.js"), root.join("grok/managed_config.toml"), + Some(root.join("copilot/providers.json")), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); @@ -408,6 +517,7 @@ programs: root.join("opencode/config.json"), root.join("opencode/plugin.js"), root.join("grok/managed_config.toml"), + Some(root.join("copilot/providers.json")), root.join("bin/agentdesktop"), root.join("agentdesktop.sock"), ); diff --git a/crates/agent/src/reconcile/plan.rs b/crates/agent/src/reconcile/plan.rs index f28a27c1..fbef996a 100644 --- a/crates/agent/src/reconcile/plan.rs +++ b/crates/agent/src/reconcile/plan.rs @@ -14,6 +14,7 @@ use std::{ pub struct ReconcilePlan { observed: RefCell>>>, operations: RefCell>, + private_dirs: PrivateDirs, report: DryRunReport, } @@ -23,6 +24,12 @@ struct FileChange { permissions: u32, } +/// A directory to create owner-only before the file changes are applied. +/// Used for a directory that will hold a secret-bearing file and does not +/// exist yet; an existing directory is left as it is. +#[derive(Default)] +struct PrivateDirs(RefCell>); + impl ReconcilePlan { pub fn render(&self) -> String { self.report.render() @@ -61,6 +68,11 @@ impl ReconcilePlan { path.display() ); } + for dir in self.private_dirs.0.into_inner() { + if !dir.exists() { + crate::secure_fs::ensure_private_dir(&dir)?; + } + } for change in self.operations.into_inner() { match change.contents { Some(contents) => { @@ -93,6 +105,10 @@ impl ReconcilePlan { } observations.insert(path, expected); } + self.private_dirs + .0 + .get_mut() + .extend(other.private_dirs.0.into_inner()); let operations = self.operations.get_mut(); for change in other.operations.into_inner() { anyhow::ensure!( @@ -144,6 +160,11 @@ impl ReconcilePlan { Ok(()) } + /// Create `dir` owner-only at apply time if it does not exist by then. + pub(crate) fn ensure_private_dir(&self, dir: &Path) { + self.private_dirs.0.borrow_mut().push(dir.to_owned()); + } + pub(crate) fn remove_file(&self, path: &Path) -> io::Result<()> { self.read(path)?; self.operations.borrow_mut().push(FileChange { diff --git a/crates/agent/src/remote.rs b/crates/agent/src/remote.rs index 9c20a678..f7b719ab 100644 --- a/crates/agent/src/remote.rs +++ b/crates/agent/src/remote.rs @@ -44,6 +44,10 @@ use crate::{ static OAUTH_REFRESH_MUTEX: Mutex<()> = Mutex::const_new(()); const INITIAL_RETRY_DELAY: Duration = Duration::from_secs(1); const MAX_RETRY_DELAY: Duration = Duration::from_secs(60); +/// Bound on the controller connection and call when fetching an LLM gateway +/// credential, so a controller that accepts and then stalls cannot hold the +/// caller (or a detached fetch) open indefinitely. +const CREDENTIAL_CALL_TIMEOUT: Duration = Duration::from_secs(15); pub struct LogoutRequest { pub completion: oneshot::Sender>, @@ -287,15 +291,28 @@ pub async fn llm_gateway_credential( identity::load(&state_dir.join("identity.json"))?.context("device is not enrolled")?; let identity_path = state_dir.join("identity.json"); refresh_oauth_if_needed(&mut identity, &identity_path).await?; - let mut client = client(controller, Some(&identity)).await?; - let mut request = Request::new(LlmGatewayCredentialRequest { - client_id: client_id.to_owned(), - }); - authenticate_request(&identity, &mut request)?; - let response = client - .get_llm_gateway_credential(request) + // The refresh above is not cut off (its result is saved); the controller + // connection and call are bounded together. + let call = async { + let mut client = client(controller, Some(&identity)).await?; + let mut request = Request::new(LlmGatewayCredentialRequest { + client_id: client_id.to_owned(), + }); + authenticate_request(&identity, &mut request)?; + client + .get_llm_gateway_credential(request) + .await + .context("request LLM gateway credential") + }; + let response = tokio::time::timeout(CREDENTIAL_CALL_TIMEOUT, call) .await - .context("request LLM gateway credential")? + .map_err(|_| { + anyhow::anyhow!( + "controller at {} did not answer within {}s", + controller.address, + CREDENTIAL_CALL_TIMEOUT.as_secs() + ) + })?? .into_inner(); Ok(agentdesktop_core::model::LlmGatewayCredential { credential: response.credential, 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/agentdesktop/macos/dev.agentdesktop.daemon.plist b/crates/agentdesktop/macos/dev.agentdesktop.daemon.plist index e9cf1cd7..d2d36eba 100644 --- a/crates/agentdesktop/macos/dev.agentdesktop.daemon.plist +++ b/crates/agentdesktop/macos/dev.agentdesktop.daemon.plist @@ -10,8 +10,6 @@ daemon --config /etc/agentdesktop/config.yaml - --state-dir - /var/lib/agentdesktop RunAtLoad diff --git a/crates/agentdesktop/src/main.rs b/crates/agentdesktop/src/main.rs index de8ccda4..d4a94bfc 100644 --- a/crates/agentdesktop/src/main.rs +++ b/crates/agentdesktop/src/main.rs @@ -200,7 +200,6 @@ fn user_socket_path() -> Option { #[cfg(target_os = "macos")] struct UserDaemonPaths { config: PathBuf, - state_directory: PathBuf, socket: PathBuf, launch_agent: PathBuf, log: PathBuf, @@ -238,7 +237,6 @@ fn user_daemon_paths() -> anyhow::Result { Ok(UserDaemonPaths { config: config_home.join("agentdesktop/config.yaml"), - state_directory, socket, launch_agent: home .join("Library/LaunchAgents") @@ -264,14 +262,10 @@ fn user_launch_agent( label: USER_LAUNCH_AGENT_LABEL, program_arguments: vec![ path_string(executable)?, - "--socket".to_owned(), - path_string(&paths.socket)?, "daemon".to_owned(), "--user".to_owned(), "--config".to_owned(), path_string(&paths.config)?, - "--state-dir".to_owned(), - path_string(&paths.state_directory)?, ], run_at_load: true, keep_alive: true, @@ -973,7 +967,6 @@ mod tests { fn user_launch_agent_runs_the_bundled_daemon_in_user_mode() { let paths = UserDaemonPaths { config: "/Users/test/.config/agentdesktop/config.yaml".into(), - state_directory: "/Users/test/.local/state/agentdesktop".into(), socket: "/Users/test/.local/state/agentdesktop/agentdesktop.sock".into(), launch_agent: "/Users/test/Library/LaunchAgents/dev.agentdesktop.daemon.user.plist" .into(), @@ -992,14 +985,10 @@ mod tests { launch_agent.program_arguments, [ "/Applications/agentdesktop.app/Contents/MacOS/agentdesktop", - "--socket", - "/Users/test/.local/state/agentdesktop/agentdesktop.sock", "daemon", "--user", "--config", "/Users/test/.config/agentdesktop/config.yaml", - "--state-dir", - "/Users/test/.local/state/agentdesktop", ] ); } diff --git a/crates/agentdesktop/windows/installer.wxs b/crates/agentdesktop/windows/installer.wxs index 95e13fd9..fd1f257d 100644 --- a/crates/agentdesktop/windows/installer.wxs +++ b/crates/agentdesktop/windows/installer.wxs @@ -29,7 +29,7 @@ Name="AgentDesktop" DisplayName="agentdesktop" Description="Applies managed AI agent configuration and connects this device to agentdesktop." - Arguments="--config "[AgentDesktopDataFolder]config.yaml" --state-dir "[AgentDesktopDataFolder]state"" + Arguments="--config "[AgentDesktopDataFolder]config.yaml"" Type="ownProcess" Start="auto" ErrorControl="normal" diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 0d516ca4..6d16328a 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -16,6 +16,9 @@ use url::Url; #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct DaemonConfig { + /// Local daemon startup settings. Not accepted in controller-delivered policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub daemon: Option, /// Controller connection settings. Omit this field to run without fleet management. #[serde(default, skip_serializing_if = "Option::is_none")] pub controller: Option, @@ -41,9 +44,102 @@ pub struct DaemonConfig { pub inventory_interval: Duration, } +/// Settings read only at local daemon startup. Paths use the process working directory. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DaemonStartupConfig { + /// Manage the current user’s tool settings instead of system settings. + #[serde(default)] + pub user: bool, + /// Persistent daemon state directory. + #[serde(default)] + pub state_dir: Option, + /// Local API Unix socket or Windows named pipe. + #[serde(default)] + pub socket: Option, + /// Override the OIDC callback bind address. + #[serde(default)] + pub oidc_callback_listen: Option, + /// Claude Code paths. + #[serde(default)] + pub claude_code: ToolConfigPath, + /// Claude Desktop paths. + #[serde(default)] + pub claude_desktop: ClaudeDesktopStartupConfig, + /// Codex paths. + #[serde(default)] + pub codex: ToolConfigPath, + /// OpenCode paths. + #[serde(default)] + pub open_code: OpenCodeStartupConfig, + /// 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, + /// Local loopback LLM proxy. + #[serde(default)] + pub llm_proxy: LlmProxyStartupConfig, +} + +/// Local loopback LLM proxy settings. User mode only: the proxy hands out the +/// current user's gateway credential, so a system daemon never runs one. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LlmProxyStartupConfig { + /// Loopback address to listen on. Unset disables the proxy. Rejected in system mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen: Option, + /// Credential policy client ID used by the proxy. Defaults to `vscode`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, +} + +/// Local Claude Desktop paths. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClaudeDesktopStartupConfig { + /// Configuration file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + /// Credential helper path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub credential_helper: Option, +} + +/// Local tool configuration path. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ToolConfigPath { + /// Configuration file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +/// Local OpenCode paths. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct OpenCodeStartupConfig { + /// Configuration file. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + /// Credential plugin path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin: Option, +} + impl Default for DaemonConfig { fn default() -> Self { Self { + daemon: None, controller: None, llm_gateway: None, sandbox: None, @@ -111,6 +207,7 @@ pub struct LlmGatewayConfig { /// value. Setting this leaves `url` to the programs and gives the proxy its /// own target. #[serde(rename = "proxyUrl", default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schema", schemars(with = "Option"))] pub proxy_url: Option, /// GitHub App OAuth used by the local proxy for the x-llm-token header. #[serde( @@ -451,6 +548,9 @@ 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, } impl ProgramsConfig { @@ -460,6 +560,7 @@ impl ProgramsConfig { && self.codex.is_none() && self.open_code.is_none() && self.grok.is_none() + && self.copilot.is_none() } } @@ -586,6 +687,140 @@ 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.contains(&key.as_str())) + { + anyhow::bail!( + "programs.copilot.models.{id}.{key} is set by agentdesktop and cannot be overridden" + ); + } + if model.extra.contains_key("apiKey") { + anyhow::bail!( + "programs.copilot.models.{id}.apiKey is not allowed: the local proxy adds the gateway credential" + ); + } + } + Ok(()) + } +} + /// Upstream authentication selected by a managed agent. #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] @@ -600,7 +835,8 @@ pub enum ProgramAuthentication { pub fn load_daemon(path: &Path) -> anyhow::Result { let contents = std::fs::read_to_string(path) .with_context(|| format!("read configuration from {}", path.display()))?; - parse_daemon(&contents).with_context(|| format!("parse configuration from {}", path.display())) + parse_local_daemon(&contents) + .with_context(|| format!("parse configuration from {}", path.display())) } /// Loads and validates a controller YAML configuration file from `path`. @@ -676,6 +912,14 @@ fn resolve_relative(path: &mut PathBuf, directory: &Path) { /// Parses and validates a daemon YAML configuration document. pub fn parse_daemon(contents: &str) -> anyhow::Result { + let config = parse_local_daemon(contents)?; + if config.daemon.is_some() { + anyhow::bail!("daemon startup settings are only allowed in the local configuration file"); + } + Ok(config) +} + +fn parse_local_daemon(contents: &str) -> anyhow::Result { let config: DaemonConfig = crate::serdes::yamlviajson::from_str(contents).context("parse daemon configuration")?; if let Some(controller) = &config.controller @@ -719,6 +963,9 @@ 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 let Some(gateway) = llm_gateway { if !matches!(gateway.url.scheme(), "http" | "https") { @@ -736,10 +983,10 @@ fn validate_daemon( if gateway.url.query().is_some() || gateway.url.fragment().is_some() { anyhow::bail!("LLM gateway URL cannot include a query or fragment"); } - if let Some(proxy_url) = &gateway.proxy_url { - if proxy_url.query().is_some() || proxy_url.fragment().is_some() { - anyhow::bail!("LLM gateway proxyUrl cannot include a query or fragment"); - } + if let Some(proxy_url) = &gateway.proxy_url + && (proxy_url.query().is_some() || proxy_url.fragment().is_some()) + { + anyhow::bail!("LLM gateway proxyUrl cannot include a query or fragment"); } if let Some(github) = &gateway.github_oauth { match github.source { @@ -885,6 +1132,14 @@ 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" + ); + } + } Ok(()) } @@ -913,6 +1168,29 @@ fn is_true(value: &bool) -> bool { mod tests { use super::{DaemonConfig, LlmGatewayAuthentication, parse_controller, parse_daemon}; + #[test] + fn startup_settings_are_local_only_and_reject_unknown_fields() { + let yaml = "daemon:\n user: true\n stateDir: /tmp/device\n socket: /tmp/device.sock\n"; + let config = super::parse_local_daemon(yaml).unwrap(); + let startup = config.daemon.unwrap(); + assert!(startup.user); + assert_eq!( + startup.state_dir.unwrap(), + std::path::Path::new("/tmp/device") + ); + assert_eq!( + startup.socket.unwrap(), + std::path::Path::new("/tmp/device.sock") + ); + assert!( + parse_daemon(yaml) + .unwrap_err() + .to_string() + .contains("only allowed in the local") + ); + assert!(super::parse_local_daemon("daemon: { stateDr: /tmp/device }").is_err()); + } + #[test] fn daemon_configuration_supports_local_and_managed_options() { let document = r#" diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 102b0a77..c85168a0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -16,8 +16,13 @@ pub const DEFAULT_CONFIG_PATH: &str = "/etc/agentdesktop/config.yaml"; /// Default location of the controller's YAML configuration file. pub const DEFAULT_CONTROLLER_CONFIG_PATH: &str = "/etc/agentdesktop/controller.yaml"; /// Default directory for the device identity and other persistent daemon state. +#[cfg(unix)] pub const DEFAULT_STATE_DIR: &str = "/var/lib/agentdesktop"; +/// Default directory for the device identity and other persistent daemon state. +#[cfg(windows)] +pub const DEFAULT_STATE_DIR: &str = r"C:\ProgramData\AgentDesktop\state"; + #[cfg(all(unix, not(target_os = "macos")))] /// Default Unix socket exposed by the daemon to local clients. pub const DEFAULT_SOCKET_PATH: &str = "/run/agentdesktop/agentdesktop.sock"; diff --git a/crates/core/src/model.rs b/crates/core/src/model.rs index 984c410b..e4a5e078 100644 --- a/crates/core/src/model.rs +++ b/crates/core/src/model.rs @@ -87,6 +87,26 @@ pub struct DaemonInfo { #[serde(with = "humantime_serde")] pub inventory_interval: Duration, pub controller: Option, + /// Local loopback LLM proxy, when configured. Absent in system mode and when + /// `daemon.llmProxy.listen` is unset. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm_proxy: Option, +} + +/// State of the daemon's loopback LLM proxy listener. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LlmProxyInfo { + /// Configured address; the bound address once the listener is up. + pub listen: String, + /// Whether the listener is bound. `false` means the bind failed at startup; + /// the daemon keeps running with the proxy off until it is restarted. + pub bound: bool, + /// Credential policy client ID the proxy presents to the gateway. + pub client_id: String, + /// Why the bind failed, when `bound` is `false`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] diff --git a/examples/standalone/config.yaml b/examples/standalone/config.yaml index a0a5a295..08c65b91 100644 --- a/examples/standalone/config.yaml +++ b/examples/standalone/config.yaml @@ -12,6 +12,11 @@ llmGateway: # Enable Device Flow in the App settings; no callback URL is needed. clientId: Iv23liJrjLXp6bL3WUCC +# Optional loopback proxy for clients without an API-key helper (see README, "Local LLM proxy"). +# daemon: +# llmProxy: +# listen: 127.0.0.1:4000 + programs: {} # claudeCode: # useLlmGateway: true diff --git a/frontend/controller/package.json b/frontend/controller/package.json index 11c27278..5e00de00 100644 --- a/frontend/controller/package.json +++ b/frontend/controller/package.json @@ -15,24 +15,24 @@ }, "dependencies": { "@agentdesktop/ui": "workspace:*", - "lucide-react": "^1.41.0", - "react": "^19.2.8", - "react-dom": "^19.2.8" + "lucide-react": "^1.47.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" }, "devDependencies": { - "@biomejs/biome": "^2.5.12", + "@biomejs/biome": "^2.5.14", "@storybook/addon-a11y": "^10.6.0", "@storybook/addon-vitest": "^10.6.0", "@storybook/react-vite": "^10.6.0", - "@types/node": "^26.4.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.7", + "@types/node": "^26.6.2", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", - "@vitest/browser-playwright": "^5.0.0", + "@vitest/browser-playwright": "^5.0.1", "playwright": "^1.63.0", "storybook": "^10.6.0", "typescript": "^7.0.2", - "vite": "^8.2.2", - "vitest": "^5.0.0" + "vite": "^8.3.0", + "vitest": "^5.0.1" } } diff --git a/frontend/controller/src/styles.css b/frontend/controller/src/styles.css index 141e74cc..50b5824b 100644 --- a/frontend/controller/src/styles.css +++ b/frontend/controller/src/styles.css @@ -261,14 +261,18 @@ a { } .table-card, -.detail-card, -.output-card { +.detail-card { overflow: hidden; } .output-card { - max-height: 490px; - overflow: auto; + position: sticky; + top: 16px; + display: flex; + flex-direction: column; + min-height: 0; + max-height: calc(100dvh - 140px); + overflow: hidden; } .table-scroll { @@ -1147,6 +1151,10 @@ code { .output-card pre { margin: 0; padding: 18px; + min-height: 0; + flex: 1 1 auto; + overflow: auto; + overscroll-behavior: contain; color: var(--ink); background: var(--code-surface); font-size: 12px; diff --git a/frontend/controller/src/types.ts b/frontend/controller/src/types.ts index d3bb0aeb..ff5a160c 100644 --- a/frontend/controller/src/types.ts +++ b/frontend/controller/src/types.ts @@ -74,7 +74,8 @@ export type AgentKind = | "claudeDesktop" | "codex" | "openCode" - | "grok"; + | "grok" + | "copilot"; export type AgentDraft = { kind: AgentKind; diff --git a/frontend/controller/src/views/ConfigurationView.stories.tsx b/frontend/controller/src/views/ConfigurationView.stories.tsx index 359470a6..5c56bbe2 100644 --- a/frontend/controller/src/views/ConfigurationView.stories.tsx +++ b/frontend/controller/src/views/ConfigurationView.stories.tsx @@ -52,6 +52,16 @@ export const ActiveSandbox: Story = { }, }; +export const GeneratedYamlScrolls: Story = { + args: { initialConfig: activeDaemonConfig }, + play: async ({ canvasElement }) => { + const preview = canvasElement.querySelector(".output-card pre"); + await expect(preview).toBeTruthy(); + const overflowY = getComputedStyle(preview as Element).overflowY; + await expect(["auto", "scroll"]).toContain(overflowY); + }, +}; + export const BuildsYaml: Story = { play: async ({ canvas, canvasElement }) => { const gatewayUrl = canvas.getByLabelText("Gateway URL"); diff --git a/frontend/controller/src/views/ConfigurationView.tsx b/frontend/controller/src/views/ConfigurationView.tsx index ad4ad3f3..af579a6a 100644 --- a/frontend/controller/src/views/ConfigurationView.tsx +++ b/frontend/controller/src/views/ConfigurationView.tsx @@ -528,12 +528,20 @@ const configurableAgents: Array<{ placeholder: "model: grok-4.6", initialSettings: "model: grok-4.6", }, + { + kind: "copilot", + label: "GitHub Copilot CLI", + iconKind: "copilot", + placeholder: "models:\n gpt-4.1:\n wireModel: gpt-4.1-mini", + initialSettings: "models:\n gpt-4.1: {}", + }, ]; const sandboxUnsupportedAgents = new Set([ "claudeDesktop", "openCode", "grok", + "copilot", ]); function daemonConfigYaml(options: { @@ -558,7 +566,7 @@ function daemonConfigYaml(options: { " authentication:", " type: controllerJwt", ` audience: ${yamlString(options.audience)}`, - " allowedClientIds: [claude-code, claude-desktop, codex, opencode, grok]", + " allowedClientIds: [claude-code, claude-desktop, codex, opencode, grok, copilot-cli]", ); } lines.push(""); diff --git a/frontend/desktop/README.md b/frontend/desktop/README.md index f2acee4b..47446e0c 100644 --- a/frontend/desktop/README.md +++ b/frontend/desktop/README.md @@ -38,11 +38,13 @@ rm -rf "$AGENTDESKTOP_DEV_STATE" pnpm --dir frontend --filter @agentdesktop/desktop-web build cargo run -p agentdesktop -- daemon \ --user \ - --socket "$AGENTDESKTOP_SOCKET" \ - --config "$HOME/.config/agentdesktop/config.yaml" \ - --state-dir "$AGENTDESKTOP_DEV_STATE" + --config "$HOME/.config/agentdesktop/config.yaml" ``` +Set `daemon.stateDir` and `daemon.socket` in that config to the absolute paths +printed by `echo "$AGENTDESKTOP_DEV_STATE"` and `echo "$AGENTDESKTOP_SOCKET"`. +Config values do not expand shell variables. + The configuration file must exist and contain the controller-managed or standalone configuration to test. Removing the disposable state directory forces a fresh enrollment; omit that line to preserve the development identity. diff --git a/frontend/desktop/package.json b/frontend/desktop/package.json index 216cf72a..816aa67e 100644 --- a/frontend/desktop/package.json +++ b/frontend/desktop/package.json @@ -27,25 +27,25 @@ "dependencies": { "@agentdesktop/ui": "workspace:*", "@tauri-apps/api": "^2.11.1", - "lucide-react": "^1.41.0", - "react": "^19.2.8", - "react-dom": "^19.2.8" + "lucide-react": "^1.47.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" }, "devDependencies": { "@resvg/resvg-js": "2.6.2", "@storybook/addon-a11y": "^10.6.0", "@storybook/addon-vitest": "^10.6.0", "@storybook/react-vite": "^10.6.0", - "@tauri-apps/cli": "^2.11.4", - "@types/node": "^26.4.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.7", + "@tauri-apps/cli": "^2.11.5", + "@types/node": "^26.6.2", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", - "@vitest/browser-playwright": "^5.0.0", + "@vitest/browser-playwright": "^5.0.1", "playwright": "^1.63.0", "storybook": "^10.6.0", "typescript": "^7.0.2", - "vite": "^8.2.2", - "vitest": "^5.0.0" + "vite": "^8.3.0", + "vitest": "^5.0.1" } } diff --git a/frontend/desktop/scripts/package-macos.test.mjs b/frontend/desktop/scripts/package-macos.test.mjs index 1ee02eab..352208d2 100644 --- a/frontend/desktop/scripts/package-macos.test.mjs +++ b/frontend/desktop/scripts/package-macos.test.mjs @@ -1,5 +1,13 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; @@ -110,3 +118,130 @@ test("installer hooks replace running processes without losing state", () => { assert.match(postinstall, /open -g/); assert.match(postinstall, /for uid in \$\{user_ids\}/); }); + +const releaseWorkflow = readFileSync( + path.resolve(nativeDirectory, "../../.github/workflows/release.yml"), + "utf8", +); +const signingCleanupSteps = [ + ...releaseWorkflow.matchAll( + /^ {6}- name: Remove macOS signing identity\n((?: {8}[^\n]*\n|\n)*)/gm, + ), +].map(([, body]) => { + const run = body.match(/^ {8}run: \|\n([\s\S]*)/m); + assert.ok(run, "Expected an inline signing cleanup script"); + return { body, script: run[1].replace(/^ {10}/gm, "") }; +}); + +test("both release signing cleanups always run with a short timeout", () => { + assert.equal(signingCleanupSteps.length, 2); + for (const { body } of signingCleanupSteps) { + assert.match(body, /^ {8}if: always\(\)/m); + const timeout = body.match(/^ {8}timeout-minutes: (\d+)$/m); + assert.ok(timeout, "Signing cleanup must have a step timeout"); + assert.ok(Number(timeout[1]) > 0 && Number(timeout[1]) <= 2); + assert.doesNotMatch(body, /continue-on-error: true/); + } +}); + +const signingFiles = [ + "agentdesktop-signing.keychain-db", + "agentdesktop-signing.pem", + "agentdesktop-signing.p12", +]; + +function signingCleanupFixture(t, files = signingFiles) { + const directory = mkdtempSync(path.join(tmpdir(), "agentdesktop signing-")); + t.after(() => rmSync(directory, { recursive: true, force: true })); + for (const file of [...files, "unrelated-file"]) { + writeFileSync(path.join(directory, file), "fixture"); + } + return directory; +} + +function runSigningCleanup(script, directory, failKeychainDeletion = false) { + // Intercept macOS commands before executing the real workflow script. + // Trust removal fails immediately here instead of hanging on authorization. + const mocks = ` + sudo() { + echo "Unexpected privileged trust-settings command" >&2 + echo sudo >> "$RUNNER_TEMP/unexpected-commands" + return 99 + } + security() { + if [[ "$#" != 2 || "$1" != delete-keychain || "$2" != "$RUNNER_TEMP/agentdesktop-signing.keychain-db" ]]; then + echo "Unexpected security command" >&2 + echo security >> "$RUNNER_TEMP/unexpected-commands" + return 98 + fi + printf '%s\\n' "$1" >> "$RUNNER_TEMP/security-calls" + if [[ "$FAIL_KEYCHAIN_DELETION" == 1 ]]; then + echo "Simulated keychain deletion failure" >&2 + return 42 + fi + rm -f "$2" + } + `; + const result = spawnSync( + "bash", + ["--noprofile", "--norc", "-e", "-o", "pipefail"], + { + input: `${mocks}\n${script}`, + encoding: "utf8", + timeout: 5000, + env: { + PATH: process.env.PATH, + RUNNER_TEMP: directory, + FAIL_KEYCHAIN_DELETION: failKeychainDeletion ? "1" : "0", + }, + }, + ); + assert.ifError(result.error); + assert.equal( + existsSync(path.join(directory, "unexpected-commands")), + false, + "Cleanup must not invoke trust settings, even with suppressed errors", + ); + return result; +} + +for (const [index, { script }] of signingCleanupSteps.entries()) { + test(`signing cleanup ${index + 1} removes only signing material and is repeatable`, (t) => { + const directory = signingCleanupFixture(t); + for (let attempt = 0; attempt < 2; attempt++) { + const result = runSigningCleanup(script, directory); + assert.equal(result.status, 0, result.stderr); + for (const file of signingFiles) { + assert.equal(existsSync(path.join(directory, file)), false, file); + } + assert.equal( + readFileSync(path.join(directory, "unrelated-file"), "utf8"), + "fixture", + ); + } + assert.equal( + readFileSync(path.join(directory, "security-calls"), "utf8"), + "delete-keychain\n", + ); + }); + + test(`signing cleanup ${index + 1} handles a partially imported identity`, (t) => { + const directory = signingCleanupFixture(t, signingFiles.slice(1)); + const result = runSigningCleanup(script, directory); + assert.equal(result.status, 0, result.stderr); + for (const file of signingFiles) { + assert.equal(existsSync(path.join(directory, file)), false, file); + } + assert.equal(existsSync(path.join(directory, "security-calls")), false); + }); + + test(`signing cleanup ${index + 1} deletes exported files even when keychain deletion fails`, (t) => { + const directory = signingCleanupFixture(t); + const result = runSigningCleanup(script, directory, true); + assert.equal(result.status, 42, result.stderr); + for (const file of signingFiles.slice(1)) { + assert.equal(existsSync(path.join(directory, file)), false, file); + } + assert.equal(existsSync(path.join(directory, signingFiles[0])), true); + }); +} diff --git a/frontend/desktop/src/components/DaemonInformation.tsx b/frontend/desktop/src/components/DaemonInformation.tsx index 4427f8be..d254830d 100644 --- a/frontend/desktop/src/components/DaemonInformation.tsx +++ b/frontend/desktop/src/components/DaemonInformation.tsx @@ -89,6 +89,14 @@ export function DaemonInformation({ ["Heartbeat interval", info.controller.heartbeatInterval], ); } + if (info?.llmProxy) { + fields.push([ + "Local LLM proxy", + info.llmProxy.bound + ? `${info.llmProxy.listen} (client ${info.llmProxy.clientId})` + : `${info.llmProxy.listen} (not bound${info.llmProxy.error ? `: ${info.llmProxy.error}` : ""})`, + ]); + } return (
diff --git a/frontend/desktop/src/stories/fixtures.ts b/frontend/desktop/src/stories/fixtures.ts index 9e72ab13..a74fd3d9 100644 --- a/frontend/desktop/src/stories/fixtures.ts +++ b/frontend/desktop/src/stories/fixtures.ts @@ -19,6 +19,17 @@ export const standaloneDaemonInfo: DaemonInfo = { stateDirectory: "/Users/developer/.local/state/agentdesktop", inventoryInterval: "15m", controller: null, + llmProxy: { listen: "127.0.0.1:18095", bound: true, clientId: "copilot-cli" }, +}; + +export const proxyUnboundDaemonInfo: DaemonInfo = { + ...standaloneDaemonInfo, + llmProxy: { + listen: "127.0.0.1:18095", + bound: false, + clientId: "copilot-cli", + error: "Address already in use (os error 98)", + }, }; export const managedDaemonInfo: DaemonInfo = { diff --git a/frontend/desktop/src/types.ts b/frontend/desktop/src/types.ts index 29a9e668..ede0f6aa 100644 --- a/frontend/desktop/src/types.ts +++ b/frontend/desktop/src/types.ts @@ -26,6 +26,13 @@ export interface DaemonInfo { caCertificatePath: string | null; heartbeatInterval: string; } | null; + llmProxy?: { + /** Bound address when `bound`, configured address otherwise. */ + listen: string; + bound: boolean; + clientId: string; + error?: string; + }; } export interface ConnectorRuntime { diff --git a/frontend/desktop/src/views/StatusView.stories.tsx b/frontend/desktop/src/views/StatusView.stories.tsx index 10796486..25c2a552 100644 --- a/frontend/desktop/src/views/StatusView.stories.tsx +++ b/frontend/desktop/src/views/StatusView.stories.tsx @@ -10,6 +10,7 @@ import { managedDaemonInfo, offlineConnector, populatedDiscovery, + proxyUnboundDaemonInfo, standaloneConnector, standaloneDaemonInfo, unconfiguredDevice, @@ -101,6 +102,9 @@ export const StandaloneReady: Story = { local.getByText(standaloneDaemonInfo.configPath), ).toBeVisible(); await expect(local.getByText("Not configured (standalone)")).toBeVisible(); + await expect( + local.getByText("127.0.0.1:18095 (client copilot-cli)"), + ).toBeVisible(); await expect(local.getAllByRole("button", { name: /^Copy / })).toHaveLength( 2, ); @@ -118,6 +122,31 @@ export const StandaloneReady: Story = { }, }; +export const StandaloneProxyUnbound: Story = { + args: { + connector: { + ...standaloneConnector, + runtime: { + ...standaloneConnector.runtime, + daemon: proxyUnboundDaemonInfo, + }, + }, + discovery: emptyDiscovery, + managedDevice: unconfiguredDevice, + }, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByText("Advanced")); + const local = within( + canvas.getByRole("region", { name: "Daemon information" }), + ); + await expect( + local.getByText( + "127.0.0.1:18095 (not bound: Address already in use (os error 98))", + ), + ).toBeVisible(); + }, +}; + export const NoCustomControllerCa: Story = { args: { connector: { diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1d3e3c67..f9406419 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,57 +14,57 @@ importers: specifier: workspace:* version: link:../ui lucide-react: - specifier: ^1.41.0 - version: 1.41.0(react@19.2.8) + specifier: ^1.47.0 + version: 1.47.0(react@19.3.0) react: - specifier: ^19.2.8 - version: 19.2.8 + specifier: ^19.3.0 + version: 19.3.0 react-dom: - specifier: ^19.2.8 - version: 19.2.8(react@19.2.8) + specifier: ^19.3.0 + version: 19.3.0(react@19.3.0) devDependencies: '@biomejs/biome': - specifier: ^2.5.12 - version: 2.5.12 + specifier: ^2.5.14 + version: 2.5.14 '@storybook/addon-a11y': specifier: ^10.6.0 - version: 10.6.0(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8)) + version: 10.6.0(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) '@storybook/addon-vitest': specifier: ^10.6.0 - version: 10.6.0(@vitest/browser-playwright@5.0.0)(@vitest/browser@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0))(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))(vitest@5.0.0) + version: 10.6.0(@vitest/browser-playwright@5.0.1)(@vitest/browser@4.1.11(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1))(@vitest/runner@4.1.11)(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(vitest@5.0.1) '@storybook/react-vite': specifier: ^10.6.0 - version: 10.6.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))(typescript@7.0.2)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) '@types/node': - specifier: ^26.4.1 - version: 26.4.1 + specifier: ^26.6.2 + version: 26.6.2 '@types/react': - specifier: ^19.2.18 - version: 19.2.18 + specifier: ^19.3.0 + version: 19.3.0 '@types/react-dom': - specifier: ^19.2.7 - version: 19.2.7(@types/react@19.2.18) + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.1.1 - version: 6.1.1(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + version: 6.1.1(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) '@vitest/browser-playwright': - specifier: ^5.0.0 - version: 5.0.0(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0) + specifier: ^5.0.1 + version: 5.0.1(playwright@1.63.0)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1) playwright: specifier: ^1.63.0 version: 1.63.0 storybook: specifier: ^10.6.0 - version: 10.6.0(@types/react@19.2.18)(react@19.2.8) + version: 10.6.0(@types/react@19.3.0)(react@19.3.0) typescript: specifier: ^7.0.2 version: 7.0.2 vite: - specifier: ^8.2.2 - version: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2) + specifier: ^8.3.0 + version: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2) vitest: - specifier: ^5.0.0 - version: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + specifier: ^5.0.1 + version: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) desktop: dependencies: @@ -75,60 +75,60 @@ importers: specifier: ^2.11.1 version: 2.11.1 lucide-react: - specifier: ^1.41.0 - version: 1.41.0(react@19.2.8) + specifier: ^1.47.0 + version: 1.47.0(react@19.3.0) react: - specifier: ^19.2.8 - version: 19.2.8 + specifier: ^19.3.0 + version: 19.3.0 react-dom: - specifier: ^19.2.8 - version: 19.2.8(react@19.2.8) + specifier: ^19.3.0 + version: 19.3.0(react@19.3.0) devDependencies: '@resvg/resvg-js': specifier: 2.6.2 version: 2.6.2 '@storybook/addon-a11y': specifier: ^10.6.0 - version: 10.6.0(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8)) + version: 10.6.0(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) '@storybook/addon-vitest': specifier: ^10.6.0 - version: 10.6.0(@vitest/browser-playwright@5.0.0)(@vitest/browser@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0))(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))(vitest@5.0.0) + version: 10.6.0(@vitest/browser-playwright@5.0.1)(@vitest/browser@4.1.11(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1))(@vitest/runner@4.1.11)(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(vitest@5.0.1) '@storybook/react-vite': specifier: ^10.6.0 - version: 10.6.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))(typescript@7.0.2)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + version: 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) '@tauri-apps/cli': - specifier: ^2.11.4 - version: 2.11.4 + specifier: ^2.11.5 + version: 2.11.5 '@types/node': - specifier: ^26.4.1 - version: 26.4.1 + specifier: ^26.6.2 + version: 26.6.2 '@types/react': - specifier: ^19.2.18 - version: 19.2.18 + specifier: ^19.3.0 + version: 19.3.0 '@types/react-dom': - specifier: ^19.2.7 - version: 19.2.7(@types/react@19.2.18) + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^6.1.1 - version: 6.1.1(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + version: 6.1.1(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) '@vitest/browser-playwright': - specifier: ^5.0.0 - version: 5.0.0(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0) + specifier: ^5.0.1 + version: 5.0.1(playwright@1.63.0)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1) playwright: specifier: ^1.63.0 version: 1.63.0 storybook: specifier: ^10.6.0 - version: 10.6.0(@types/react@19.2.18)(react@19.2.8) + version: 10.6.0(@types/react@19.3.0)(react@19.3.0) typescript: specifier: ^7.0.2 version: 7.0.2 vite: - specifier: ^8.2.2 - version: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2) + specifier: ^8.3.0 + version: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2) vitest: - specifier: ^5.0.0 - version: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + specifier: ^5.0.1 + version: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) ui: dependencies: @@ -140,8 +140,8 @@ importers: version: 19.2.8 devDependencies: '@types/react': - specifier: ^19.2.18 - version: 19.2.18 + specifier: ^19.3.0 + version: 19.3.0 packages: @@ -219,59 +219,59 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.5.12': - resolution: {integrity: sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw==} + '@biomejs/biome@2.5.14': + resolution: {integrity: sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.5.12': - resolution: {integrity: sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg==} + '@biomejs/cli-darwin-arm64@2.5.14': + resolution: {integrity: sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.5.12': - resolution: {integrity: sha512-vhPgwnh+6tN3ArdAXuET99xaNbFt7CG82Bqn+omHVLC5xdVx45JsYjGPmUIGNzjDek5XdNCP1HKksK7fn8+3bQ==} + '@biomejs/cli-darwin-x64@2.5.14': + resolution: {integrity: sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.5.12': - resolution: {integrity: sha512-couHYjFLL5uuI8ne6zhT7KwEsXo5YP7ry/2xmEqah7qanu0YmfDi3mwJg47YXSuv/NpZj22CZzcRH/5c4gjPSQ==} + '@biomejs/cli-linux-arm64-musl@2.5.14': + resolution: {integrity: sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.5.12': - resolution: {integrity: sha512-2gp8aVwXYKdAtmBfRFCUuyDMcfN1ahHqUkGfLYrZlNRFmryMATLVvJgWKvyA8wu4Rwn5OSxM1UcUmOuOFNGeBQ==} + '@biomejs/cli-linux-arm64@2.5.14': + resolution: {integrity: sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.5.12': - resolution: {integrity: sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w==} + '@biomejs/cli-linux-x64-musl@2.5.14': + resolution: {integrity: sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.5.12': - resolution: {integrity: sha512-SnvOs3TSTiuia4SQOUNe1aWC9RT4+YkjcKnOhL/nsKOV0k5ycgBkDzF0lUxKn1V7Q8CLTRq6iV23ZAivHomRoA==} + '@biomejs/cli-linux-x64@2.5.14': + resolution: {integrity: sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.5.12': - resolution: {integrity: sha512-b9vtoZFsuZt1pdjNwJvXl0f+BpayRzV008uS2+JpmwIKdSE2qdu4A/l04FESwLoou5g2E/Qlec0xwJydZplH+A==} + '@biomejs/cli-win32-arm64@2.5.14': + resolution: {integrity: sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.5.12': - resolution: {integrity: sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA==} + '@biomejs/cli-win32-x64@2.5.14': + resolution: {integrity: sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -998,79 +998,79 @@ packages: '@tauri-apps/api@2.11.1': resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} - '@tauri-apps/cli-darwin-arm64@2.11.4': - resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} + '@tauri-apps/cli-darwin-arm64@2.11.5': + resolution: {integrity: sha512-tggOiVOohjIHdiElbjeBfB41s6cmfsq+ZQ0PX0fEtCdsDdZ9cCi54e5gNK7tdPbR6JbdDe7RA4kbQdyyBUUk/Q==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@tauri-apps/cli-darwin-x64@2.11.4': - resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} + '@tauri-apps/cli-darwin-x64@2.11.5': + resolution: {integrity: sha512-SrO+KCbqvG1IvVPdlzQX4GEGZRQDISrmDY9bGcW68DLVFBCC06H+Loz6oyUZETUxsSMR6B4oDfvbQVgD4hxsqQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': - resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.5': + resolution: {integrity: sha512-qLrOaCa8hLD5QVPQRjAgOXvhlTFKXmTliVF773GgvADYcWZSdmncnF5zBFAkdu1uQK8KfQbDlBUwqQYZqBfBRQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@tauri-apps/cli-linux-arm64-gnu@2.11.4': - resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} + '@tauri-apps/cli-linux-arm64-gnu@2.11.5': + resolution: {integrity: sha512-Tq32xpQjiQEdIrvwDq7lwHhXemn070CXWdbCvr9rdi9rsv9NFTbQ6izVSIfNE+59QaerxuwtUtZ5IxDNWaoYBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-arm64-musl@2.11.4': - resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} + '@tauri-apps/cli-linux-arm64-musl@2.11.5': + resolution: {integrity: sha512-+OdWKj5Aq+C8HXypQ4t926V8cpVnc0ZcJIdhTXWjVAJHkaNuo8EuQR6KRZKHWIbJJgKdMPCDosvwzIFaBBqHKQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': - resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} + '@tauri-apps/cli-linux-riscv64-gnu@2.11.5': + resolution: {integrity: sha512-Q0V2hjpllyDW8dWQZSsITuQDfcY9BpBZuhXkMa2T5AFLZAn+FJPXQSyvnxWNVxRowdQ59Xn0QVtGm6HEHNUEvw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-gnu@2.11.4': - resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} + '@tauri-apps/cli-linux-x64-gnu@2.11.5': + resolution: {integrity: sha512-Xz1s87gFjZJZMQKAhLa77YeiCJM6yVog3f9OxDgVd/KxDIoB1dA5V2xOnMM+SRkOGk9juwIBqesVAjV0kUB0DA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@tauri-apps/cli-linux-x64-musl@2.11.4': - resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} + '@tauri-apps/cli-linux-x64-musl@2.11.5': + resolution: {integrity: sha512-6A88wqAZXZHgMaBrGiy/Bpa4GgUnftXM7uquKOeOXCuK78/XdBxEECicK/1oHgrT179XBFOaA6lQEHysjRmGig==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@tauri-apps/cli-win32-arm64-msvc@2.11.4': - resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} + '@tauri-apps/cli-win32-arm64-msvc@2.11.5': + resolution: {integrity: sha512-sRGgF/ObRfLcS3PtUdyTbJtkGq3E+PNZTknvAURwMNUtHw9DU/BF6S5si43y9lS5yEO9bWJCA+GGYvnn6cphQw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@tauri-apps/cli-win32-ia32-msvc@2.11.4': - resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} + '@tauri-apps/cli-win32-ia32-msvc@2.11.5': + resolution: {integrity: sha512-bHOv/yxqfKpI7ioIFQi5wOfZ7jkbPzJD/UhXY52I/hrmE3qNh71cohIPyqZB5GQn3LZeJWdCUOqmIg/w6XX+Wg==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] - '@tauri-apps/cli-win32-x64-msvc@2.11.4': - resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} + '@tauri-apps/cli-win32-x64-msvc@2.11.5': + resolution: {integrity: sha512-vaaOmavqdCeS7oMib2banBZemgrC5v54tSkwoBV+bxrXQZhQZrOwE2EO2i4x+CHNtvQUM/6bSs3iymZMAsycWg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@tauri-apps/cli@2.11.4': - resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} + '@tauri-apps/cli@2.11.5': + resolution: {integrity: sha512-YbeJ6tctNoo40purO4u3Eeo3RVqmsjJr0NoKQRu+4pvO4gMLzkjta7x0k96SxSLmx+rQ+8um1ThOlw+HLpgFug==} engines: {node: '>= 10'} hasBin: true @@ -1118,16 +1118,16 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/node@26.4.1': - resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==} + '@types/node@26.6.2': + resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} - '@types/react-dom@19.2.7': - resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} peerDependencies: - '@types/react': ^19.2.0 + '@types/react': ^19.3.0 - '@types/react@19.2.18': - resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} @@ -1268,21 +1268,21 @@ packages: oxc-transform-react: optional: true - '@vitest/browser-playwright@5.0.0': - resolution: {integrity: sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==} + '@vitest/browser-playwright@5.0.1': + resolution: {integrity: sha512-BlknN81ZeoCyP5CSm5u7+1BTlS7d0cDU5DfndNSjEC3SmbzOESGrp+DQpNhQmTlggawWc+yKVEM03mM/aBynqg==} peerDependencies: playwright: '*' - vitest: 5.0.0 + vitest: 5.0.1 '@vitest/browser@4.1.11': resolution: {integrity: sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==} peerDependencies: vitest: 4.1.11 - '@vitest/browser@5.0.0': - resolution: {integrity: sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==} + '@vitest/browser@5.0.1': + resolution: {integrity: sha512-s2UroEhP2BZPoen1qrXXTtVqnUcs6y2sxMLGCZ8tZDkH2iM/6tTC2GVTPCgF+oVF3X5M6wC/X2O0MvvoHNmH0Q==} peerDependencies: - vitest: 5.0.0 + vitest: 5.0.1 '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -1298,8 +1298,8 @@ packages: vite: optional: true - '@vitest/mocker@5.0.0': - resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + '@vitest/mocker@5.0.1': + resolution: {integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1315,8 +1315,8 @@ packages: '@vitest/pretty-format@4.1.11': resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} - '@vitest/pretty-format@5.0.0': - resolution: {integrity: sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==} + '@vitest/pretty-format@5.0.1': + resolution: {integrity: sha512-6guWwj5d9bguuefTOvJoq387tfpkzSv554YdUEGzjJH2PnnmvzTLQ1UQSuAk5wBFVhf2CUmy/S/palOcb6dmpA==} '@vitest/runner@4.1.11': resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} @@ -1327,13 +1327,13 @@ packages: '@vitest/spy@4.1.11': resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} - '@vitest/spy@5.0.0': - resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@vitest/spy@5.0.1': + resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} - '@vitest/ui@5.0.0': - resolution: {integrity: sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==} + '@vitest/ui@5.0.1': + resolution: {integrity: sha512-7PvQu/X9/pQoHYfNLAYL22qsD4/+sx2k7zpUA7XvjW0sc40r3/r0lGZ2fsEyOHMuO8Q1KjiO1QQVjJdnWUy/WQ==} peerDependencies: - vitest: 5.0.0 + vitest: 5.0.1 '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} @@ -1341,8 +1341,8 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} - '@vitest/utils@5.0.0': - resolution: {integrity: sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==} + '@vitest/utils@5.0.1': + resolution: {integrity: sha512-E9+yEA+jsfaoxZcUHFzEqUrQcoNh2EwrPT5efIqkUPUwD5Ua2Li9BRWaYeRwvzvdLgTSVrre8oKNeyrfg7KkdQ==} '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -1667,8 +1667,8 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lucide-react@1.41.0: - resolution: {integrity: sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q==} + lucide-react@1.47.0: + resolution: {integrity: sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1763,8 +1763,8 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} - postcss@8.5.26: - resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} pretty-format@27.5.1: @@ -1780,10 +1780,10 @@ packages: resolution: {integrity: sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==} engines: {node: ^20.9.0 || >=22} - react-dom@19.2.8: - resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: - react: ^19.2.8 + react: ^19.3.0 react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -1792,6 +1792,10 @@ packages: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + recast@0.23.21: resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} @@ -1814,8 +1818,8 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} @@ -1925,8 +1929,8 @@ packages: engines: {node: '>=16.20.0'} hasBin: true - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} update-browserslist-db@1.3.2: resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} @@ -1939,13 +1943,13 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - vite@8.2.2: - resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 || ^0.5.0 + '@vitejs/devtools': ^0.7.1 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -1982,20 +1986,20 @@ packages: yaml: optional: true - vitest@5.0.0: - resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + vitest@5.0.1: + resolution: {integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.0 - '@vitest/browser-preview': 5.0.0 + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-preview': 5.0.1 '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.0 - '@vitest/coverage-v8': 5.0.0 - '@vitest/ui': 5.0.0 + '@vitest/coverage-istanbul': 5.0.1 + '@vitest/coverage-v8': 5.0.1 + '@vitest/ui': 5.0.1 happy-dom: '*' jsdom: '*' vite: ^6.4.0 || ^7.0.0 || ^8.0.0 @@ -2153,39 +2157,39 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@biomejs/biome@2.5.12': + '@biomejs/biome@2.5.14': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.5.12 - '@biomejs/cli-darwin-x64': 2.5.12 - '@biomejs/cli-linux-arm64': 2.5.12 - '@biomejs/cli-linux-arm64-musl': 2.5.12 - '@biomejs/cli-linux-x64': 2.5.12 - '@biomejs/cli-linux-x64-musl': 2.5.12 - '@biomejs/cli-win32-arm64': 2.5.12 - '@biomejs/cli-win32-x64': 2.5.12 + '@biomejs/cli-darwin-arm64': 2.5.14 + '@biomejs/cli-darwin-x64': 2.5.14 + '@biomejs/cli-linux-arm64': 2.5.14 + '@biomejs/cli-linux-arm64-musl': 2.5.14 + '@biomejs/cli-linux-x64': 2.5.14 + '@biomejs/cli-linux-x64-musl': 2.5.14 + '@biomejs/cli-win32-arm64': 2.5.14 + '@biomejs/cli-win32-x64': 2.5.14 - '@biomejs/cli-darwin-arm64@2.5.12': + '@biomejs/cli-darwin-arm64@2.5.14': optional: true - '@biomejs/cli-darwin-x64@2.5.12': + '@biomejs/cli-darwin-x64@2.5.14': optional: true - '@biomejs/cli-linux-arm64-musl@2.5.12': + '@biomejs/cli-linux-arm64-musl@2.5.14': optional: true - '@biomejs/cli-linux-arm64@2.5.12': + '@biomejs/cli-linux-arm64@2.5.14': optional: true - '@biomejs/cli-linux-x64-musl@2.5.12': + '@biomejs/cli-linux-x64-musl@2.5.14': optional: true - '@biomejs/cli-linux-x64@2.5.12': + '@biomejs/cli-linux-x64@2.5.14': optional: true - '@biomejs/cli-win32-arm64@2.5.12': + '@biomejs/cli-win32-arm64@2.5.14': optional: true - '@biomejs/cli-win32-x64@2.5.12': + '@biomejs/cli-win32-x64@2.5.14': optional: true '@blazediff/core@1.10.0': {} @@ -2303,11 +2307,11 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@7.0.2)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@7.0.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@7.0.2) - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2) + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2) optionalDependencies: typescript: 7.0.2 @@ -2579,61 +2583,61 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.7 - '@storybook/addon-a11y@10.6.0(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))': + '@storybook/addon-a11y@10.6.0(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': dependencies: '@storybook/global': 5.0.0 axe-core: 4.13.0 - storybook: 10.6.0(@types/react@19.2.18)(react@19.2.8) + storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) - '@storybook/addon-vitest@10.6.0(@vitest/browser-playwright@5.0.0)(@vitest/browser@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0))(@vitest/runner@4.1.11)(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))(vitest@5.0.0)': + '@storybook/addon-vitest@10.6.0(@vitest/browser-playwright@5.0.1)(@vitest/browser@4.1.11(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1))(@vitest/runner@4.1.11)(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(vitest@5.0.1)': dependencies: '@storybook/global': 5.0.0 - '@storybook/icons': 2.1.0(react@19.2.8) - storybook: 10.6.0(@types/react@19.2.18)(react@19.2.8) + '@storybook/icons': 2.1.0(react@19.3.0) + storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) optionalDependencies: - '@vitest/browser': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0) - '@vitest/browser-playwright': 5.0.0(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0) + '@vitest/browser': 4.1.11(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1) + '@vitest/browser-playwright': 5.0.1(playwright@1.63.0)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1) '@vitest/runner': 4.1.11 - vitest: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) transitivePeerDependencies: - react - '@storybook/builder-vite@10.6.0(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))': + '@storybook/builder-vite@10.6.0(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))': dependencies: - storybook: 10.6.0(@types/react@19.2.18)(react@19.2.8) + storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) ts-dedent: 2.3.0 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2) + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2) '@storybook/global@5.0.0': {} - '@storybook/icons@2.1.0(react@19.2.8)': + '@storybook/icons@2.1.0(react@19.3.0)': dependencies: - react: 19.2.8 + react: 19.3.0 - '@storybook/react-dom-shim@10.6.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))': + '@storybook/react-dom-shim@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))': dependencies: - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - storybook: 10.6.0(@types/react@19.2.18)(react@19.2.8) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) - '@storybook/react-vite@10.6.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))(typescript@7.0.2)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))': + '@storybook/react-vite@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@7.0.2)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@7.0.2)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) '@rollup/pluginutils': 5.4.0 - '@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) - '@storybook/react': 10.6.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))(typescript@7.0.2) + '@storybook/builder-vite': 10.6.0(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) + '@storybook/react': 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2) empathic: 2.0.1 magic-string: 1.2.3 - react: 19.2.8 + react: 19.3.0 react-docgen: 8.0.3 - react-dom: 19.2.8(react@19.2.8) + react-dom: 19.3.0(react@19.3.0) resolve: 1.22.12 - storybook: 10.6.0(@types/react@19.2.18)(react@19.2.8) + storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) tsconfig-paths: 4.2.0 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2) + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2) optionalDependencies: typescript: 7.0.2 transitivePeerDependencies: @@ -2642,70 +2646,70 @@ snapshots: - rollup - supports-color - '@storybook/react@10.6.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8))(typescript@7.0.2)': + '@storybook/react@10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0))(typescript@7.0.2)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.6.0(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(storybook@10.6.0(@types/react@19.2.18)(react@19.2.8)) - react: 19.2.8 + '@storybook/react-dom-shim': 10.6.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.6.0(@types/react@19.3.0)(react@19.3.0)) + react: 19.3.0 react-docgen: 8.0.3 react-docgen-typescript: 2.4.0(typescript@7.0.2) - react-dom: 19.2.8(react@19.2.8) - storybook: 10.6.0(@types/react@19.2.18)(react@19.2.8) + react-dom: 19.3.0(react@19.3.0) + storybook: 10.6.0(@types/react@19.3.0)(react@19.3.0) optionalDependencies: - '@types/react': 19.2.18 - '@types/react-dom': 19.2.7(@types/react@19.2.18) + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) typescript: 7.0.2 transitivePeerDependencies: - supports-color '@tauri-apps/api@2.11.1': {} - '@tauri-apps/cli-darwin-arm64@2.11.4': + '@tauri-apps/cli-darwin-arm64@2.11.5': optional: true - '@tauri-apps/cli-darwin-x64@2.11.4': + '@tauri-apps/cli-darwin-x64@2.11.5': optional: true - '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.5': optional: true - '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + '@tauri-apps/cli-linux-arm64-gnu@2.11.5': optional: true - '@tauri-apps/cli-linux-arm64-musl@2.11.4': + '@tauri-apps/cli-linux-arm64-musl@2.11.5': optional: true - '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + '@tauri-apps/cli-linux-riscv64-gnu@2.11.5': optional: true - '@tauri-apps/cli-linux-x64-gnu@2.11.4': + '@tauri-apps/cli-linux-x64-gnu@2.11.5': optional: true - '@tauri-apps/cli-linux-x64-musl@2.11.4': + '@tauri-apps/cli-linux-x64-musl@2.11.5': optional: true - '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + '@tauri-apps/cli-win32-arm64-msvc@2.11.5': optional: true - '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + '@tauri-apps/cli-win32-ia32-msvc@2.11.5': optional: true - '@tauri-apps/cli-win32-x64-msvc@2.11.4': + '@tauri-apps/cli-win32-x64-msvc@2.11.5': optional: true - '@tauri-apps/cli@2.11.4': + '@tauri-apps/cli@2.11.5': optionalDependencies: - '@tauri-apps/cli-darwin-arm64': 2.11.4 - '@tauri-apps/cli-darwin-x64': 2.11.4 - '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 - '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 - '@tauri-apps/cli-linux-arm64-musl': 2.11.4 - '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 - '@tauri-apps/cli-linux-x64-gnu': 2.11.4 - '@tauri-apps/cli-linux-x64-musl': 2.11.4 - '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 - '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 - '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + '@tauri-apps/cli-darwin-arm64': 2.11.5 + '@tauri-apps/cli-darwin-x64': 2.11.5 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.5 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.5 + '@tauri-apps/cli-linux-arm64-musl': 2.11.5 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.5 + '@tauri-apps/cli-linux-x64-gnu': 2.11.5 + '@tauri-apps/cli-linux-x64-musl': 2.11.5 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.5 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.5 + '@tauri-apps/cli-win32-x64-msvc': 2.11.5 '@testing-library/dom@10.4.1': dependencies: @@ -2770,15 +2774,15 @@ snapshots: '@types/estree@1.0.9': {} - '@types/node@26.4.1': + '@types/node@26.6.2': dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 - '@types/react-dom@19.2.7(@types/react@19.2.18)': + '@types/react-dom@19.3.0(@types/react@19.3.0)': dependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 - '@types/react@19.2.18': + '@types/react@19.3.0': dependencies: csstype: 3.2.3 @@ -2844,34 +2848,34 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))': + '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2) + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2) - '@vitest/browser-playwright@5.0.0(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0)': + '@vitest/browser-playwright@5.0.1(playwright@1.63.0)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1)': dependencies: - '@vitest/browser': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0) - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + '@vitest/browser': 5.0.1(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1) + '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) playwright: 1.63.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0)': + '@vitest/browser@4.1.11(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + '@vitest/mocker': 4.1.11(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) '@vitest/utils': 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) ws: 8.21.3 transitivePeerDependencies: - bufferutil @@ -2880,17 +2884,17 @@ snapshots: - vite optional: true - '@vitest/browser@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0)': + '@vitest/browser@5.0.1(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1)': dependencies: '@blazediff/core': 1.10.0 - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) - '@vitest/ui': 5.0.0(vitest@5.0.0) - '@vitest/utils': 5.0.0 + '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) + '@vitest/ui': 5.0.1(vitest@5.0.1) + '@vitest/utils': 5.0.1 magic-string: 1.2.3 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) ws: 8.21.3 transitivePeerDependencies: - bufferutil @@ -2906,23 +2910,23 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))': + '@vitest/mocker@4.1.11(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2) + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2) optional: true - '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))': + '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))': dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.0 + '@vitest/spy': 5.0.1 estree-walker: 3.0.3 magic-string: 1.2.3 optionalDependencies: - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2) + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2) '@vitest/pretty-format@3.2.4': dependencies: @@ -2933,7 +2937,7 @@ snapshots: tinyrainbow: 3.1.1 optional: true - '@vitest/pretty-format@5.0.0': + '@vitest/pretty-format@5.0.1': dependencies: tinyrainbow: 3.1.1 @@ -2950,17 +2954,17 @@ snapshots: '@vitest/spy@4.1.11': optional: true - '@vitest/spy@5.0.0': {} + '@vitest/spy@5.0.1': {} - '@vitest/ui@5.0.0(vitest@5.0.0)': + '@vitest/ui@5.0.1(vitest@5.0.1)': dependencies: - '@vitest/utils': 5.0.0 + '@vitest/utils': 5.0.1 fflate: 0.8.3 flatted: 3.4.4 pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + vitest: 5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) '@vitest/utils@3.2.4': dependencies: @@ -2975,9 +2979,9 @@ snapshots: tinyrainbow: 3.1.1 optional: true - '@vitest/utils@5.0.0': + '@vitest/utils@5.0.1': dependencies: - '@vitest/pretty-format': 5.0.0 + '@vitest/pretty-format': 5.0.1 convert-source-map: 2.0.0 tinyrainbow: 3.1.1 @@ -3229,9 +3233,9 @@ snapshots: dependencies: react: 19.2.8 - lucide-react@1.41.0(react@19.2.8): + lucide-react@1.47.0(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 lz-string@1.5.0: {} @@ -3341,7 +3345,7 @@ snapshots: pngjs@7.0.0: {} - postcss@8.5.26: + postcss@8.5.28: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 @@ -3372,15 +3376,17 @@ snapshots: transitivePeerDependencies: - supports-color - react-dom@19.2.8(react@19.2.8): + react-dom@19.3.0(react@19.3.0): dependencies: - react: 19.2.8 - scheduler: 0.27.0 + react: 19.3.0 + scheduler: 0.28.0 react-is@17.0.2: {} react@19.2.8: {} + react@19.3.0: {} + recast@0.23.21: dependencies: ast-types: 0.16.3 @@ -3424,7 +3430,7 @@ snapshots: run-applescript@7.1.0: {} - scheduler@0.27.0: {} + scheduler@0.28.0: {} semver@6.3.1: {} @@ -3446,10 +3452,10 @@ snapshots: std-env@4.2.0: {} - storybook@10.6.0(@types/react@19.2.18)(react@19.2.8): + storybook@10.6.0(@types/react@19.3.0)(react@19.3.0): dependencies: '@storybook/global': 5.0.0 - '@storybook/icons': 2.1.0(react@19.2.8) + '@storybook/icons': 2.1.0(react@19.3.0) '@testing-library/dom': 10.4.1 '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.6(@testing-library/dom@10.4.1) @@ -3463,10 +3469,10 @@ snapshots: oxc-resolver: 11.21.2 recast: 0.23.21 semver: 7.8.5 - use-sync-external-store: 1.6.0(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.3.0) ws: 8.21.3 optionalDependencies: - '@types/react': 19.2.18 + '@types/react': 19.3.0 transitivePeerDependencies: - bufferutil - react @@ -3534,7 +3540,7 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 - undici-types@8.3.0: {} + undici-types@8.9.0: {} update-browserslist-db@1.3.2(browserslist@4.28.8): dependencies: @@ -3542,26 +3548,26 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - use-sync-external-store@1.6.0(react@19.2.8): + use-sync-external-store@1.6.0(react@19.3.0): dependencies: - react: 19.2.8 + react: 19.3.0 - vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2): + vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.26 + postcss: 8.5.28 rolldown: 1.2.6 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 26.4.1 + '@types/node': 26.6.2 esbuild: 0.28.2 fsevents: 2.3.3 - vitest@5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)): + vitest@5.0.1(@types/node@26.6.2)(@vitest/browser-playwright@5.0.1)(@vitest/ui@5.0.1)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)) + '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -3572,12 +3578,12 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2) + vite: 8.3.0(@types/node@26.6.2)(esbuild@0.28.2) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 26.4.1 - '@vitest/browser-playwright': 5.0.0(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2))(vitest@5.0.0) - '@vitest/ui': 5.0.0(vitest@5.0.0) + '@types/node': 26.6.2 + '@vitest/browser-playwright': 5.0.1(playwright@1.63.0)(vite@8.3.0(@types/node@26.6.2)(esbuild@0.28.2))(vitest@5.0.1) + '@vitest/ui': 5.0.1(vitest@5.0.1) transitivePeerDependencies: - msw diff --git a/frontend/ui/package.json b/frontend/ui/package.json index afd8dcc7..12edfce5 100644 --- a/frontend/ui/package.json +++ b/frontend/ui/package.json @@ -12,6 +12,6 @@ "react": "^19.2.8" }, "devDependencies": { - "@types/react": "^19.2.18" + "@types/react": "^19.3.0" } } diff --git a/frontend/ui/src/assets/cursor.svg b/frontend/ui/src/assets/cursor.svg new file mode 100644 index 00000000..3bc1fd46 --- /dev/null +++ b/frontend/ui/src/assets/cursor.svg @@ -0,0 +1 @@ +Cursor diff --git a/frontend/ui/src/tools.tsx b/frontend/ui/src/tools.tsx index 985f74a1..11f59a7d 100644 --- a/frontend/ui/src/tools.tsx +++ b/frontend/ui/src/tools.tsx @@ -11,6 +11,7 @@ import claudeCodeIcon from "./assets/claude-code.svg"; import claudeDesktopIcon from "./assets/claude-desktop.svg"; import codexIcon from "./assets/codex.svg"; import copilotIcon from "./assets/copilot.svg"; +import cursorIcon from "./assets/cursor.svg"; import grokIcon from "./assets/grok.svg"; import ollamaIcon from "./assets/ollama.svg"; import openCodeIcon from "./assets/opencode.svg"; @@ -50,8 +51,10 @@ const toolIcons: Record = { claude_desktop: claudeDesktopIcon, grok: grokIcon, "grok-build": grokIcon, + cursor: cursorIcon, opencode: openCodeIcon, vscode: copilotIcon, + copilot: copilotIcon, }; export function friendlyTool(kind: string) { @@ -66,6 +69,7 @@ export function friendlyTool(kind: string) { opencode: "OpenCode", vscode: "VS Code", cursor: "Cursor", + copilot: "GitHub Copilot CLI", }; return names[kind.toLowerCase()] ?? kind; } @@ -121,7 +125,9 @@ export function ModelRuntimeInventory({ export function ToolIcon({ kind }: { kind: string }) { const icon = toolIcons[kind.toLowerCase()]; - const monochrome = ["opencode", "vscode"].includes(kind.toLowerCase()); + const monochrome = ["opencode", "vscode", "cursor", "copilot"].includes( + kind.toLowerCase(), + ); return icon ? ( `).\n\nAt least one is required when a top-level `llmGateway` is configured.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/CopilotModel" + } + }, + "useLlmGateway": { + "description": "Whether this program uses the top-level LLM gateway.", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "CopilotModel": { + "description": "One Copilot CLI model entry. The entry's `id` is the map key; `provider`\nand `modelId` are the typed fields below; any other key (for example\n`wireModel`, the name sent upstream when it differs) is passed through to\nthe CLI unchanged.", + "type": "object", + "properties": { + "modelId": { + "description": "The CLI's `modelId` for the entry. Defaults to the entry's ID. The\nname sent to the gateway is `wireModel` when that pass-through key is\nset, else this one.", + "type": [ + "string", + "null" + ] + }, + "provider": { + "description": "Which managed provider entry serves the model.", + "$ref": "#/$defs/CopilotProvider", + "default": "agentdesktop" + } + }, + "additionalProperties": true + }, + "CopilotProvider": { + "description": "The two provider entries agentdesktop writes into `providers.json`, one per\nAPI shape the proxy's `/copilot-cli` route serves.", + "oneOf": [ + { + "description": "OpenAI-compatible, `http:///copilot-cli/v1`.", + "type": "string", + "const": "agentdesktop" + }, + { + "description": "Anthropic, `http:///copilot-cli`.", + "type": "string", + "const": "agentdesktop-anthropic" + } + ] + }, + "DaemonStartupConfig": { + "description": "Settings read only at local daemon startup. Paths use the process working directory.", + "type": "object", + "properties": { + "claudeCode": { + "description": "Claude Code paths.", + "$ref": "#/$defs/ToolConfigPath", + "default": {} + }, + "claudeDesktop": { + "description": "Claude Desktop paths.", + "$ref": "#/$defs/ClaudeDesktopStartupConfig", + "default": {} + }, + "codex": { + "description": "Codex paths.", + "$ref": "#/$defs/ToolConfigPath", + "default": {} + }, + "copilot": { + "description": "GitHub Copilot CLI paths (`config` = the `providers.json` to manage;\ndefaults to `COPILOT_PROVIDERS_CONFIG`, `$COPILOT_HOME/providers.json`,\nthen `~/.copilot/providers.json`).", + "$ref": "#/$defs/ToolConfigPath", + "default": {} + }, + "grok": { + "description": "Grok Build paths.", + "$ref": "#/$defs/ToolConfigPath", + "default": {} + }, + "llmProxy": { + "description": "Local loopback LLM proxy.", + "$ref": "#/$defs/LlmProxyStartupConfig", + "default": {} + }, + "oidcCallbackListen": { + "description": "Override the OIDC callback bind address.", + "type": [ + "string", + "null" + ], + "default": null + }, + "openCode": { + "description": "OpenCode paths.", + "$ref": "#/$defs/OpenCodeStartupConfig", + "default": {} + }, + "socket": { + "description": "Local API Unix socket or Windows named pipe.", + "type": [ + "string", + "null" + ], + "default": null + }, + "stateDir": { + "description": "Persistent daemon state directory.", + "type": [ + "string", + "null" + ], + "default": null + }, + "user": { + "description": "Manage the current user’s tool settings instead of system settings.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + }, "GitHubOAuthConfig": { "description": "GitHub App user authorization for Copilot requests through the local proxy.", "type": "object", @@ -305,6 +460,13 @@ } ] }, + "proxyUrl": { + "description": "Base URL the local LLM proxy forwards to, when it differs from `url`.\n\n`url` is shared by every program that sets `useLlmGateway`, so it can\nonly carry one path prefix. A gateway that puts each provider behind its\nown prefix therefore cannot serve both a program and the proxy from one\nvalue. Setting this leaves `url` to the programs and gives the proxy its\nown target.", + "type": [ + "string", + "null" + ] + }, "url": { "description": "Base HTTP or HTTPS URL of the LLM gateway.\n\nThe URL must include a host and cannot include credentials, a query, or a fragment.", "type": "string" @@ -315,6 +477,27 @@ "url" ] }, + "LlmProxyStartupConfig": { + "description": "Local loopback LLM proxy settings. User mode only: the proxy hands out the\ncurrent user's gateway credential, so a system daemon never runs one.", + "type": "object", + "properties": { + "clientId": { + "description": "Credential policy client ID used by the proxy. Defaults to `vscode`.", + "type": [ + "string", + "null" + ] + }, + "listen": { + "description": "Loopback address to listen on. Unset disables the proxy. Rejected in system mode.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "OpenCodeConfig": { "description": "Settings reconciled into OpenCode's system-managed configuration.\n\nValues under `managedConfig` are written to OpenCode's managed JSONC file.\nWhen generated LLM gateway settings overlap with those values,\nAgentdesktop's generated values take precedence.", "type": "object", @@ -343,6 +526,27 @@ }, "additionalProperties": false }, + "OpenCodeStartupConfig": { + "description": "Local OpenCode paths.", + "type": "object", + "properties": { + "config": { + "description": "Configuration file.", + "type": [ + "string", + "null" + ] + }, + "plugin": { + "description": "Credential plugin path.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, "ProgramAuthentication": { "description": "Upstream authentication selected by a managed agent.", "oneOf": [ @@ -390,6 +594,17 @@ } ] }, + "copilot": { + "description": "GitHub Copilot CLI managed configuration.", + "anyOf": [ + { + "$ref": "#/$defs/CopilotConfig" + }, + { + "type": "null" + } + ] + }, "grok": { "description": "Grok Build managed configuration.", "anyOf": [ @@ -502,6 +717,20 @@ "const": "tool.use.input" } ] + }, + "ToolConfigPath": { + "description": "Local tool configuration path.", + "type": "object", + "properties": { + "config": { + "description": "Configuration file.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false } } } diff --git a/schema/daemon-config.md b/schema/daemon-config.md index f0ea3ddf..1d89cb49 100644 --- a/schema/daemon-config.md +++ b/schema/daemon-config.md @@ -6,6 +6,28 @@ |`controller.address`|string|HTTPS address of the controller's fleet API.| |`controller.caCertificatePath`|string|Path to a PEM-encoded CA certificate used to verify the controller.

Omit this field to use the operating system's trusted certificate roots.| |`controller.heartbeatInterval`|string|Interval between device heartbeats. Defaults to `30s`.| +|`daemon`|object|Local daemon startup settings. Not accepted in controller-delivered policy.| +|`daemon.claudeCode`|object|Claude Code paths.| +|`daemon.claudeCode.config`|string|Configuration file.| +|`daemon.claudeDesktop`|object|Claude Desktop paths.| +|`daemon.claudeDesktop.config`|string|Configuration file.| +|`daemon.claudeDesktop.credentialHelper`|string|Credential helper path.| +|`daemon.codex`|object|Codex paths.| +|`daemon.codex.config`|string|Configuration file.| +|`daemon.copilot`|object|GitHub Copilot CLI paths (`config` = the `providers.json` to manage;
defaults to `COPILOT_PROVIDERS_CONFIG`, `$COPILOT_HOME/providers.json`,
then `~/.copilot/providers.json`).| +|`daemon.copilot.config`|string|Configuration file.| +|`daemon.grok`|object|Grok Build paths.| +|`daemon.grok.config`|string|Configuration file.| +|`daemon.llmProxy`|object|Local loopback LLM proxy.| +|`daemon.llmProxy.clientId`|string|Credential policy client ID used by the proxy. Defaults to `vscode`.| +|`daemon.llmProxy.listen`|string|Loopback address to listen on. Unset disables the proxy. Rejected in system mode.| +|`daemon.oidcCallbackListen`|string|Override the OIDC callback bind address.| +|`daemon.openCode`|object|OpenCode paths.| +|`daemon.openCode.config`|string|Configuration file.| +|`daemon.openCode.plugin`|string|Credential plugin path.| +|`daemon.socket`|string|Local API Unix socket or Windows named pipe.| +|`daemon.stateDir`|string|Persistent daemon state directory.| +|`daemon.user`|boolean|Manage the current user’s tool settings instead of system settings.| |`inventoryInterval`|string|Interval between inventory refreshes. Defaults to `15m`, and must be
greater than zero.

Discovery walks user home directories and developer-tool configuration
files, so this trades inventory freshness against local disk activity.| |`llmGateway`|object|LLM gateway used by managed developer tools.| |`llmGateway.authentication`|object|Authentication mechanism used when connecting to this gateway.| @@ -21,6 +43,7 @@ |`llmGateway.githubOAuth`|object|GitHub App OAuth used by the local proxy for the x-llm-token header.| |`llmGateway.githubOAuth.clientId`|string|GitHub App client ID. Required when `source` is `deviceFlow`, where the
App must also enable Device Flow. Unused when `source` is `request`.| |`llmGateway.githubOAuth.source`|enum|Where the GitHub credential comes from.
Possible values: `deviceFlow`, `request`.| +|`llmGateway.proxyUrl`|string|Base URL the local LLM proxy forwards to, when it differs from `url`.

`url` is shared by every program that sets `useLlmGateway`, so it can
only carry one path prefix. A gateway that puts each provider behind its
own prefix therefore cannot serve both a program and the proxy from one
value. Setting this leaves `url` to the programs and gives the proxy its
own target.| |`llmGateway.url`|string|Base HTTP or HTTPS URL of the LLM gateway.

The URL must include a host and cannot include credentials, a query, or a fragment.| |`programs`|object|Per-program settings reconciled on this device.| |`programs.claudeCode`|object|Claude Code managed-settings configuration. Arbitrary keys are passed through directly.| @@ -32,6 +55,11 @@ |`programs.codex`|object|Codex managed configuration.| |`programs.codex.managedConfig`|object|Arbitrary values written to Codex's organization-managed TOML configuration.

Use Codex's native snake_case configuration keys. TOML has no null value,
so null values cannot be reconciled.| |`programs.codex.useLlmGateway`|boolean|Whether this program uses the top-level LLM gateway.| +|`programs.copilot`|object|GitHub Copilot CLI managed configuration.| +|`programs.copilot.models`|object|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.| +|`programs.copilot.models.*.modelId`|string|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.| +|`programs.copilot.models.*.provider`|enum|Which managed provider entry serves the model.
Possible values: `agentdesktop`, `agentdesktop-anthropic`.| +|`programs.copilot.useLlmGateway`|boolean|Whether this program uses the top-level LLM gateway.| |`programs.grok`|object|Grok Build managed configuration.| |`programs.grok.managedConfig`|object|Arbitrary values written to Grok's organization-managed TOML configuration.

Use Grok's native snake_case configuration keys. TOML has no null value,
so null values cannot be reconciled.| |`programs.grok.model`|string|Catalog ID and API model used when pointing Grok at the LLM gateway.

This is required when a top-level `llmGateway` is configured. If `models`
is empty, agentdesktop creates a catalog entry with this ID.| diff --git a/scripts/tests/run-local.py b/scripts/tests/run-local.py deleted file mode 100644 index 959b76c9..00000000 --- a/scripts/tests/run-local.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Exercise launcher process cleanup without building or touching user settings.""" -import os -from pathlib import Path -import signal -import subprocess -import tempfile -import time - -SOURCE = Path(__file__).resolve().parents[2] / ".john/run-local" - - -def exercise(fail_ui=False): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "frontend").mkdir() - (root / "examples/standalone").mkdir(parents=True) - (root / "examples/standalone/config.yaml").write_text("{}") - (root / "scripts").mkdir() - launcher = root / "scripts/run-local" - launcher.write_bytes(SOURCE.read_bytes()) - launcher.chmod(0o755) - mocks = root / "bin" - mocks.mkdir() - for name in ("pnpm", "cargo"): - path = mocks / name - path.write_text("#!/bin/sh\nexit 0\n") - path.chmod(0o755) - # Delay Dex readiness to verify native processes wait for authentication. - docker = mocks / "docker" - docker.write_text('''#!/usr/bin/env bash -case "$*" in - *version*|*down*) exit 0 ;; -esac -[[ "$COMPOSE_MENU" == false ]] || exit 8 -if read -r -t 1 line; then exit 9; fi -sleep 300 & -wait -''') - docker.chmod(0o755) - curl = mocks / "curl" - curl.write_text('''#!/bin/sh -sleep 1 -touch "$TEST_ROOT/ready" -''') - curl.chmod(0o755) - for profile in ("debug", "release"): - binary = root / f"target/{profile}/agentdesktop" - binary.parent.mkdir(parents=True) - binary.write_text('''#!/usr/bin/env bash -[[ -f "$TEST_ROOT/ready" ]] || exit 10 -printf '%s\\n' "$*" >> "$TEST_ROOT/args" -sleep 300 & -echo "$!" >> "$TEST_ROOT/children" -if [[ "$*" != *daemon* && "$FAIL_UI" == 1 ]]; then sleep 1; exit 7; fi -wait -''') - binary.chmod(0o755) - env = dict(os.environ, PATH=f"{mocks}:{os.environ['PATH']}", - TEST_ROOT=str(root), FAIL_UI=str(int(fail_ui))) - args = [str(launcher), "--user", "--dex"] + ([] if fail_ui else ["--dev"]) - with (root / "output").open("w") as output: - process = subprocess.Popen(args, env=env, stdout=output, stderr=output) - try: - deadline = time.monotonic() + 10 - while time.monotonic() < deadline: - children = root / "children" - if children.exists() and len(children.read_text().splitlines()) == 2: - break - if process.poll() is not None: - raise AssertionError((root / "output").read_text()) - time.sleep(0.05) - else: - raise AssertionError("Components did not start") - if not fail_ui: - process.send_signal(signal.SIGTERM) - assert process.wait(timeout=10) == (7 if fail_ui else 143) - assert "--user" in (root / "args").read_text() - for pid in children.read_text().splitlines(): - # A killed orphan can briefly remain as a zombie in containers. - result = subprocess.run(["ps", "-o", "stat=", "-p", pid], - capture_output=True, text=True) - assert not result.stdout.strip() or result.stdout.strip().startswith("Z"), pid - finally: - if process.poll() is None: - process.kill() - process.wait() - - -exercise() -exercise(fail_ui=True) -print("run-local: signal and component-exit cleanup passed")