From 5f24223cb86f65fdd867eac56807c675f46efd70 Mon Sep 17 00:00:00 2001 From: Sommelier Date: Sat, 24 Oct 2026 23:12:38 +0300 Subject: [PATCH 1/5] docs: document Nginx to Mac VPN topology --- docs/how-to-connect.md | 77 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/docs/how-to-connect.md b/docs/how-to-connect.md index 5091a22..744e102 100644 --- a/docs/how-to-connect.md +++ b/docs/how-to-connect.md @@ -136,6 +136,83 @@ per-user workspaces. Tokens are checked on every request without caching; provider outages deny access. Live ChatGPT sign-in depends on your provider configuration and has not been verified by the project's mock-provider tests. +### Separate Nginx server to a Mac over VPN + +Topology: ChatGPT -> Nginx (public HTTPS) -> Mac (private/VPN HTTP) -> +local QueryMT. +This is a template, not a working ChatGPT connection yet: an external OAuth +provider is still required, and none is configured for this deployment. TLS and +proxying alone do not enable ChatGPT authentication; a static bearer is not a +substitute. An arbitrary OIDC provider may lack the introspection endpoint or +required claims described above. + +Replace every `mcp.example.com` with your public domain and every `10.0.0.42` +with the Mac's actual private/VPN IP; both are placeholders. Point public DNS to +Nginx, not the Mac, and use a valid TLS certificate for that domain on Nginx. + +On the Mac, keep QueryMT on `ws://127.0.0.1:3030/acp/ws` as described above. +Prepare `oauth.json` using the provider's actual settings and set `resource` to +`https://mcp.example.com/mcp` (with your domain). Inject the provider-issued +introspection secret as `QMCP_OAUTH_CLIENT_SECRET` through protected service +secret settings. Do not put it in JSON, command arguments, shell history, logs, +or version control; do not print the environment. Leave `QMCP_BEARER_TOKEN` +unset. Once the provider and secret are configured, run on the Mac: + +```sh +qmcp --transport http --bind 10.0.0.42:3031 \ + --allowed-host mcp.example.com --oauth-config /absolute/path/to/oauth.json +``` + +`--bind` takes an IP:port socket address, not a URL or hostname. This binds only +the Mac's VPN/private interface; OAuth is required for this non-loopback bind. +Use the installed qmcp binary's absolute path if it is not on `PATH`. + +On the separate Nginx host, adapt this HTTPS server template; certificate paths +are placeholders too. Keep the upstream without a URI suffix so all three paths +and their query strings reach qmcp unchanged: + +```nginx +server { + listen 443 ssl; + server_name mcp.example.com; + ssl_certificate /path/to/fullchain.pem; + ssl_certificate_key /path/to/privkey.pem; + + location ~ ^/(mcp|\.well-known/oauth-protected-resource(/mcp)?)$ { + proxy_pass http://10.0.0.42:3031; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Authorization $http_authorization; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_request_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + location / { return 404; } +} +``` + +The Host must match `--allowed-host`; do not replace it with the upstream IP. +Forward Authorization unchanged; allow token-free discovery and preserve +qmcp's `WWW-Authenticate` responses. Do not strip Origin to bypass qmcp's +checks. +Buffering/cache are disabled for Streamable HTTP; the example timeouts are +inactivity limits, not a guarantee of task duration. Align any other proxy or +load-balancer timeouts with long-lived requests. + +Allow inbound TCP 3031 on the Mac only from the Nginx host's VPN source IP, +over the VPN interface; deny other sources. Do not publish/port-forward 3031 or +expose QueryMT's ACP port. Plain HTTP here assumes the proxy-to-Mac hop uses the +encrypted VPN, not an untrusted LAN. Verify Nginx can route to that Mac address; +keep the VPN connected, the Mac awake/reachable, and QueryMT and qmcp running. +Validate the adapted Nginx configuration with `nginx -t` before deploying it. +After deployment, both discovery paths should return metadata without a token, +and `/mcp` should reject missing/invalid tokens. Only then follow the OAuth +ChatGPT setup above using `https://mcp.example.com/mcp`, not the private IP. + ## Remote Access Checklist - Use HTTPS: qmcp itself does not terminate TLS. Never send tokens over public plaintext HTTP. From bf7747e61659174618edb9981ab5388a2ff911ca Mon Sep 17 00:00:00 2001 From: Sommelier Date: Sat, 24 Oct 2026 23:22:43 +0300 Subject: [PATCH 2/5] feat(oauth): add embedded single-owner authorization server --- AGENTS.md | 4 +- Cargo.lock | 5 + Cargo.toml | 5 + README.md | 22 +- docs/how-to-connect.md | 168 ++++++-- src/lib.rs | 1 + src/local_oauth.rs | 853 +++++++++++++++++++++++++++++++++++++ src/local_oauth/tests.rs | 893 +++++++++++++++++++++++++++++++++++++++ src/main.rs | 87 +++- src/transport.rs | 18 + 10 files changed, 2013 insertions(+), 43 deletions(-) create mode 100644 src/local_oauth.rs create mode 100644 src/local_oauth/tests.rs diff --git a/AGENTS.md b/AGENTS.md index 22f0357..515e7fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,8 @@ - Keep code, comments, documentation, and diagnostics in English. - The project, crate, and binary are named `qmcp` (lowercase). - Support only stdio and Streamable HTTP. -- Require static Bearer or external-provider OAuth authentication for non-loopback HTTP binds; preserve Host/Origin validation and never log credentials. -- Keep OAuth minimal: resource discovery and token introspection only; login, PKCE, consent and token issuance belong to the provider. +- Require static Bearer, embedded single-owner OAuth, or external-provider OAuth authentication for non-loopback HTTP binds; preserve Host/Origin validation and never log credentials. +- Keep external-provider introspection in oauth.rs; isolate embedded single-owner login, consent, PKCE and volatile token issuance in local_oauth.rs. Never silently authorize or accept human-chosen owner passwords. - Keep stdout exclusively for MCP JSON-RPC in stdio mode; diagnostics use stderr. - Use the flake devShell on NixOS (`nix develop -c ...`) and the declared Rust toolchain. - Run `cargo check`, formatting checks, Clippy, and relevant tests before finishing. diff --git a/Cargo.lock b/Cargo.lock index 27b2027..7c3afcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1880,9 +1880,13 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64", "clap", + "form_urlencoded", "futures-util", + "getrandom 0.4.3", "jsonschema", + "libc", "qmacp", "reqwest", "rmcp", @@ -1893,6 +1897,7 @@ dependencies = [ "subtle", "tokio", "tokio-tungstenite", + "tower", "tracing", "tracing-subscriber", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 56ff32a..29c8c8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,10 @@ path = "src/main.rs" [dependencies] anyhow = "1" axum = "0.8" +base64 = "0.23" +form_urlencoded = "1.2" +getrandom = "0.4" +libc = "0.2" clap = { version = "4", features = ["derive", "env"] } qmacp = { git = "https://github.com/querymt/qmacp", tag = "v0.1.1" } reqwest = { version = "0.13", default-features = false, features = ["json", "form", "rustls"] } @@ -32,3 +36,4 @@ uuid = { version = "1", features = ["v4"] } futures-util = "0.3" jsonschema = { version = "0.60", default-features = false } tokio-tungstenite = "0.29" +tower = { version = "0.5", features = ["util"] } diff --git a/README.md b/README.md index 1c641b4..eba555c 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,26 @@ cargo install --locked --path . ## Connect +For a single-owner ChatGPT connection on macOS, qmcp includes OAuth login, +consent, public-client registration and PKCE. No external provider or Docker +service is required. Use an HTTPS reverse proxy and a generated owner credential +in a private `0600` file: -See [How to connect](docs/how-to-connect.md) for local stdio, Bearer over HTTPS, -private tunnels, and OAuth/ChatGPT setup. The guide includes client examples, -authentication requirements, and remote-access precautions. +```sh +qmcp --transport http --permission cancel \ + --oauth-local-resource https://mcp.example.com/mcp \ + --oauth-owner-secret-file /absolute/private/path/to/owner-secret +``` + +These are example values, not a deployment configuration. See +[How to connect](docs/how-to-connect.md#embedded-oauth-on-macos-chatgpt) for secret +generation, proxy setup and ChatGPT sign-in. Embedded OAuth is single-owner and +volatile: restarting invalidates tokens **and registered client IDs**, so you may +need to re-add the server in ChatGPT. Registrations also expire after one hour; +tokens last 30 minutes, with no refresh tokens. + +Local stdio, static Bearer over HTTPS, private tunnels, and optional external +OAuth introspection remain available in the [connection guide](docs/how-to-connect.md). ## Tools diff --git a/docs/how-to-connect.md b/docs/how-to-connect.md index 744e102..c924962 100644 --- a/docs/how-to-connect.md +++ b/docs/how-to-connect.md @@ -5,9 +5,10 @@ | Setup | Best for | Authentication | | --- | --- | --- | | [Local stdio](#local-stdio) | Desktop and CLI MCP clients | Local process permissions | +| [Embedded OAuth on macOS](#embedded-oauth-on-macos-chatgpt) | Single-owner public ChatGPT connections | Built-in owner login + consent; no external provider | | [Bearer over HTTPS](#bearer-over-https) | Clients with custom authorization headers | Shared token | | [Private tunnel](#private-tunnel-chatgpt) | ChatGPT without a public endpoint | OpenAI tunnel access | -| [OAuth over HTTPS](#oauth-over-https-chatgpt) | Public authenticated ChatGPT connections | External OAuth provider | +| [External OAuth provider](#external-oauth-provider-optional) | Deployments with an existing identity provider | Optional external introspection | Bearer and OAuth are HTTP authentication modes. The tunnel privately connects ChatGPT to qmcp over stdio; it is not another MCP transport. @@ -92,9 +93,119 @@ to keep qmcp private. Your account/workspace must allow tunnels and custom MCP s 4. Select the plugin with `@` in a chat and ask: **"Use qmcp to list QueryMT profiles."** Keep QueryMT and `tunnel-client` running. No public qmcp endpoint is needed. -## OAuth over HTTPS (ChatGPT) +## Embedded OAuth on macOS (ChatGPT) -Use an external [OAuth provider](https://developers.openai.com/plugins/build/auth) +qmcp includes a single-owner OAuth authorization server. No Docker, external +identity provider, or separate login service is needed. Start QueryMT locally, +then put qmcp behind a trusted HTTPS reverse proxy. qmcp does not terminate TLS. +The resource must be a canonical HTTPS URL ending in exactly `/mcp`, without +userinfo, query, or fragment. Its origin is the issuer (no trailing slash). + +### Generate the owner credential + +Generate a new secret, not a human-chosen password. This example creates a +private file without printing its contents, and refuses to overwrite an existing +file. Run it only when preparing a deployment, not as part of a source review: + +```sh +umask 077 +mkdir -p "$HOME/.config/qmcp" +chmod 700 "$HOME/.config/qmcp" +(set -C; openssl rand -hex 32 > "$HOME/.config/qmcp/owner-secret") +chmod 600 "$HOME/.config/qmcp/owner-secret" +``` + +Keep the generated value in a trusted password manager for browser login. Never +put it in command arguments, URLs, environment variables, logs, source control, +or a PR. qmcp never creates or prints an owner credential. It accepts 32..64 +random bytes encoded as 64..128 lowercase hex characters, optionally followed +by a newline. Length/encoding and obvious-weakness checks cannot prove entropy; +**only use a cryptographically generated secret**, not a phrase or password. +The absolute file path must name a regular file owned by qmcp's user, mode +`0600`, with no final-component symlink or hard links. Keep its parent directory +private and free of untrusted symlinks/ACLs. Secret-file protection currently +requires Unix (including macOS). + +### Run and connect + +Leave `QMCP_BEARER_TOKEN` and `QMCP_OAUTH_CLIENT_SECRET` unset, then run after +reviewing your deployment configuration: + +```sh +qmcp --transport http --permission cancel \ + --oauth-local-resource https://mcp.example.com/mcp \ + --oauth-owner-secret-file "$HOME/.config/qmcp/owner-secret" +``` + +The default bind is `127.0.0.1:3031`. For a proxy on another host, see the +[Nginx/VPN example](#separate-nginx-server-to-a-mac-over-vpn). Embedded OAuth +accepts only the canonical public resource Host, which is also added to MCP's +Host allowlist; additional host aliases do not broaden that outer guard. +Local OAuth, external OAuth (`--oauth-config`), and static Bearer are mutually +exclusive. HTTP authentication options are rejected in stdio mode; an owner +secret flag without a local resource is rejected too. + +Forward **all** these paths through the HTTPS proxy, preserving Host, +Authorization, Origin, and query strings: + +- `/mcp` +- `/.well-known/oauth-authorization-server` +- `/.well-known/oauth-protected-resource` and its `/mcp` variant +- `/oauth/register`, `/oauth/authorize`, `/oauth/token` + +In ChatGPT, add a custom MCP server with `https://mcp.example.com/mcp` and select +**OAuth**. Use dynamic client registration (DCR); do not enter the owner secret +as a client ID/secret. During sign-in, verify the public domain, the unverified +client name, and its exact callback URL. Enter the generated owner secret on +qmcp's **Owner login + authorize** page and explicitly choose **Log in and +Approve**, or choose **Deny**. Registration alone grants no access. Workspace +settings must allow custom MCP servers. The flow is covered by local tests; +live ChatGPT interoperability still needs deployment validation. + +### Security and operational limits + +- Only public DCR clients, authorization-code grants, and PKCE `S256` are + supported. No implicit grant, refresh tokens, CIMD URL fetching, or client + secret authentication. Registration never fetches a supplied URL. +- The exact registered redirect, public resource, client, and `qmcp:access` + scope are bound to authorization and code exchange. HTTPS callbacks are + required except literal loopback/localhost HTTP callbacks. Redirects with + fragments, credentials, or reserved OAuth response query fields are rejected. + Unknown registration metadata is rejected; up to eight redirects and a + 128-byte client name are accepted within an 8 KiB request body. +- Each five-minute browser transaction requires a random CSRF token and a + Secure, HttpOnly, SameSite=Lax, path-scoped cookie. Form POSTs require the + exact issuer Origin. MCP and all other routes reject Origin; never strip it + at the proxy to bypass validation. Each submitted transaction has one owner + login attempt. There is no remembered login or silent approval. +- Codes expire after 90 seconds and are consumed atomically, including on a + failed binding/PKCE check. Access tokens expire after 30 minutes. Only + SHA-256 digests of owner credentials, cookies, CSRF tokens, codes and access + tokens are retained in server state. Responses use no-store and restrictive + CSP, framing and referrer policies. +- All state is **volatile**. Restart invalidates grants, tokens, transactions, + and DCR client IDs; ChatGPT may require removing and re-adding the server, + not just signing in again. Client registrations also expire after one hour; + their expiration may require re-registration/re-adding. There is no database, + registration management API, per-client revocation, or persistence. +- Capacity is 128 clients, 128 browser transactions, 128 codes and 256 tokens. + Expired entries are pruned on use. Global fixed-memory limits per minute are + 16 registrations, 60 authorizations, 8 login POSTs and 120 token requests. + AS bodies have an 8 KiB limit, 16 concurrent readers and a ten-second read + timeout. Limits deliberately do not trust client-IP/proxy headers. An attacker + can temporarily exhaust capacity/rate windows; put deployment-specific + connection/rate limits at the trusted proxy. No automatic eviction of live + grants is attempted. +- This is a **single-owner backend**, not multi-user isolation. Approval grants + all tools on the connected QueryMT instance. `--permission cancel` controls + agent tool approval, not OAuth scope. Protect the owner secret accordingly. + For rotation or global revocation, replace the protected file and perform a + reviewed restart. Keep TLS termination, proxy logs (no query/body/header or + token-response logging for OAuth), firewall and the private upstream secure. + +## External OAuth Provider (Optional) + +For an existing identity provider, use an external [OAuth provider](https://developers.openai.com/plugins/build/auth) with authorization-code + PKCE (`S256`) and ChatGPT client registration (pre-registered, CIMD, or DCR). qmcp provides discovery and token introspection; the provider handles login, consent, token issuance, and revocation. @@ -136,31 +247,30 @@ per-user workspaces. Tokens are checked on every request without caching; provider outages deny access. Live ChatGPT sign-in depends on your provider configuration and has not been verified by the project's mock-provider tests. -### Separate Nginx server to a Mac over VPN +## Separate Nginx server to a Mac over VPN Topology: ChatGPT -> Nginx (public HTTPS) -> Mac (private/VPN HTTP) -> local QueryMT. -This is a template, not a working ChatGPT connection yet: an external OAuth -provider is still required, and none is configured for this deployment. TLS and +This is a deployment template, not a claim of a verified live connection. +Use embedded OAuth on the Mac; an external provider is optional. TLS and proxying alone do not enable ChatGPT authentication; a static bearer is not a -substitute. An arbitrary OIDC provider may lack the introspection endpoint or -required claims described above. +substitute. If you choose external OAuth instead, an arbitrary OIDC provider +may lack the introspection endpoint or claims described in the optional section. Replace every `mcp.example.com` with your public domain and every `10.0.0.42` with the Mac's actual private/VPN IP; both are placeholders. Point public DNS to Nginx, not the Mac, and use a valid TLS certificate for that domain on Nginx. On the Mac, keep QueryMT on `ws://127.0.0.1:3030/acp/ws` as described above. -Prepare `oauth.json` using the provider's actual settings and set `resource` to -`https://mcp.example.com/mcp` (with your domain). Inject the provider-issued -introspection secret as `QMCP_OAUTH_CLIENT_SECRET` through protected service -secret settings. Do not put it in JSON, command arguments, shell history, logs, -or version control; do not print the environment. Leave `QMCP_BEARER_TOKEN` -unset. Once the provider and secret are configured, run on the Mac: +Generate the protected owner secret as described above. Leave +`QMCP_BEARER_TOKEN` and `QMCP_OAUTH_CLIENT_SECRET` unset. After reviewing the +configuration, run on the Mac (all addresses and paths here are examples): ```sh qmcp --transport http --bind 10.0.0.42:3031 \ - --allowed-host mcp.example.com --oauth-config /absolute/path/to/oauth.json + --allowed-host mcp.example.com --permission cancel \ + --oauth-local-resource https://mcp.example.com/mcp \ + --oauth-owner-secret-file /absolute/private/path/to/owner-secret ``` `--bind` takes an IP:port socket address, not a URL or hostname. This binds only @@ -168,8 +278,8 @@ the Mac's VPN/private interface; OAuth is required for this non-loopback bind. Use the installed qmcp binary's absolute path if it is not on `PATH`. On the separate Nginx host, adapt this HTTPS server template; certificate paths -are placeholders too. Keep the upstream without a URI suffix so all three paths -and their query strings reach qmcp unchanged: +are placeholders too. Keep the upstream without a URI suffix so MCP, discovery, +and all embedded authorization-server endpoints and queries reach qmcp unchanged: ```nginx server { @@ -178,11 +288,13 @@ server { ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/privkey.pem; - location ~ ^/(mcp|\.well-known/oauth-protected-resource(/mcp)?)$ { + location / { proxy_pass http://10.0.0.42:3031; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header Authorization $http_authorization; + proxy_set_header Origin $http_origin; + access_log off; # OAuth queries contain authorization codes/state. proxy_set_header Connection ""; proxy_buffering off; proxy_request_buffering off; @@ -191,7 +303,6 @@ server { proxy_send_timeout 3600s; } - location / { return 404; } } ``` @@ -209,17 +320,22 @@ expose QueryMT's ACP port. Plain HTTP here assumes the proxy-to-Mac hop uses the encrypted VPN, not an untrusted LAN. Verify Nginx can route to that Mac address; keep the VPN connected, the Mac awake/reachable, and QueryMT and qmcp running. Validate the adapted Nginx configuration with `nginx -t` before deploying it. -After deployment, both discovery paths should return metadata without a token, -and `/mcp` should reject missing/invalid tokens. Only then follow the OAuth -ChatGPT setup above using `https://mcp.example.com/mcp`, not the private IP. +After deployment, the AS metadata and both resource-discovery paths should +return metadata without a token, and `/mcp` should reject missing/invalid tokens. +Only then follow the embedded OAuth ChatGPT setup above using +`https://mcp.example.com/mcp`, not the private IP. Deployment and real sign-in +verification are separate from source/build review; do not restart a live +service merely to run tests. ## Remote Access Checklist - Use HTTPS: qmcp itself does not terminate TLS. Never send tokens over public plaintext HTTP. - Keep qmcp's backend port private to the proxy with a firewall/private network. -- Forward Authorization and an allowed Host. Browser Origin headers are rejected. -- Non-loopback binds require Bearer or OAuth auth. Wildcard binds also require - explicit `--allowed-host` values (repeatable, optionally `host:port`). +- Forward Authorization, Host and Origin unchanged. MCP rejects browser Origin; + only embedded OAuth consent POSTs accept the exact public issuer Origin. +- Non-loopback binds require Bearer or OAuth auth. Wildcard binds require an + explicit Host allowlist (`--allowed-host`, repeatable, optionally `host:port`); + embedded OAuth supplies its canonical resource Host automatically. - Use `--bind` only when the proxy needs another interface. Keep the default loopback listener when the proxy runs on the same host. - `--permission` controls QueryMT tool approval, not MCP client authentication. diff --git a/src/lib.rs b/src/lib.rs index 39a52a5..78bfdd8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ mod output; pub use qmacp::policy; +pub mod local_oauth; pub mod oauth; pub mod server; pub mod transport; diff --git a/src/local_oauth.rs b/src/local_oauth.rs new file mode 100644 index 0000000..43001fa --- /dev/null +++ b/src/local_oauth.rs @@ -0,0 +1,853 @@ +//! Embedded, volatile, single-owner authorization server. External OAuth stays separate. + +use std::{ + collections::{HashMap, HashSet}, + io::Read, + path::Path, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use anyhow::{Context, Result, bail}; +use axum::{ + Json, Router, + body::to_bytes, + extract::{Request, State}, + http::{HeaderMap, HeaderValue, Method, StatusCode, header}, + middleware::{self, Next}, + response::{Html, IntoResponse, Response}, + routing::{get, post}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use reqwest::Url; +use serde::Deserialize; +use serde_json::json; +use sha2::{Digest, Sha256}; +use subtle::ConstantTimeEq; + +use crate::transport::bearer_credential; + +const SCOPE: &str = "qmcp:access"; +const CLIENT_TTL: Duration = Duration::from_secs(3600); +const TRANSACTION_TTL: Duration = Duration::from_secs(300); +const CODE_TTL: Duration = Duration::from_secs(90); +const TOKEN_TTL: Duration = Duration::from_secs(1800); +const MAX_CLIENTS: usize = 128; +const MAX_TRANSACTIONS: usize = 128; +const MAX_CODES: usize = 128; +const MAX_TOKENS: usize = 256; +const MAX_BODY: usize = 8192; +const COOKIE: &str = "__Secure-qmcp_oauth"; +type Digest32 = [u8; 32]; +type Fields = HashMap; + +/// Deliberately no Debug: owner secrets, forms, codes and tokens must not be logged. +#[derive(Clone)] +pub struct LocalOAuth(Arc); + +struct Server { + resource: String, + issuer: String, + authority: String, + owner: Digest32, + store: Mutex, + body_slots: tokio::sync::Semaphore, +} + +#[derive(Default)] +struct Store { + clients: HashMap, + transactions: HashMap, + codes: HashMap, + tokens: HashMap, + registration_rate: Rate, + authorization_rate: Rate, + login_rate: Rate, + exchange_rate: Rate, +} + +struct Client { + redirects: Vec, + name: String, + expires: Instant, +} + +struct Grant { + client: String, + redirect: String, + resource: String, + scope: String, + challenge: Digest32, +} + +struct Transaction { + grant: Grant, + state: Option, + csrf: Digest32, + expires: Instant, +} + +struct Code { + grant: Grant, + expires: Instant, +} + +struct Token { + client: String, + resource: String, + scope: String, + expires: Instant, +} + +/// Global fixed-memory windows deliberately ignore spoofable proxy/IP headers. +#[derive(Default)] +struct Rate { + start: Option, + used: usize, +} + +impl Rate { + fn allow(&mut self, now: Instant, limit: usize) -> bool { + if self + .start + .is_none_or(|start| now.duration_since(start) >= Duration::from_secs(60)) + { + self.start = Some(now); + self.used = 0; + } + if self.used >= limit { + return false; + } + self.used += 1; + true + } +} + +impl Store { + fn prune(&mut self, now: Instant) { + self.clients.retain(|_, c| c.expires > now); + self.transactions.retain(|_, t| t.expires > now); + self.codes.retain(|_, c| c.expires > now); + self.tokens.retain(|_, t| t.expires > now); + } +} + +impl LocalOAuth { + /// Read a generated hex credential (32..64 random bytes) from a protected file. + pub fn from_secret_file(resource: &str, path: &Path) -> Result { + if !path.is_absolute() { + bail!("owner secret file must be an absolute path"); + } + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + // Reject final-component symlinks and avoid blocking on a FIFO/device. + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let file = options + .open(path) + .context("open protected owner secret file")?; + let metadata = file.metadata().context("stat owner secret file")?; + if !metadata.is_file() || metadata.len() > 130 { + bail!("owner secret must be a small regular file"); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // SAFETY: geteuid has no arguments or memory-safety preconditions. + let uid = unsafe { libc::geteuid() }; + if metadata.mode() & 0o7777 != 0o600 || metadata.uid() != uid || metadata.nlink() != 1 { + bail!("owner secret file must be owned by this user, mode 0600, with one link"); + } + } + #[cfg(not(unix))] + bail!("embedded OAuth secret-file protection currently requires Unix"); + let mut secret = String::new(); + file.take(131) + .read_to_string(&mut secret) + .context("read owner secret file")?; + Self::new(resource, secret.trim_end_matches(['\r', '\n'])) + } + + fn new(resource: &str, secret: &str) -> Result { + let url = Url::parse(resource).context("invalid local OAuth resource URL")?; + if resource.len() > 2048 + || url.scheme() != "https" + || url.host_str().is_none() + || url.path() != "/mcp" + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.as_str() != resource + { + bail!( + "local OAuth resource must be a canonical HTTPS /mcp URL without userinfo, query or fragment" + ); + } + // Encoding/length and obvious-weakness checks cannot prove random generation. + // Only use a CSPRNG-generated credential, never a password or a derived phrase. + if !(64..=128).contains(&secret.len()) + || !secret.len().is_multiple_of(2) + || !secret + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + bail!("owner secret must be 32..64 random bytes encoded as lowercase hex"); + } + let decoded: Vec = secret + .as_bytes() + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + .collect(); + if decoded.iter().collect::>().len() < 16 { + bail!("owner secret is obviously weak; generate it using a secure random generator"); + } + let issuer = url.origin().ascii_serialization(); + let authority = issuer.strip_prefix("https://").unwrap().to_owned(); + Ok(Self(Arc::new(Server { + resource: resource.into(), + issuer, + authority, + owner: digest(secret), + store: Mutex::new(Store::default()), + body_slots: tokio::sync::Semaphore::new(16), + }))) + } + + pub(crate) fn authority(&self) -> &str { + &self.0.authority + } + + pub(crate) fn routes(&self) -> Router { + Router::new() + .route("/.well-known/oauth-authorization-server", get(as_metadata)) + .route( + "/.well-known/oauth-protected-resource", + get(resource_metadata), + ) + .route( + "/.well-known/oauth-protected-resource/mcp", + get(resource_metadata), + ) + .route("/oauth/register", post(register)) + .route("/oauth/authorize", get(authorize).post(consent)) + .route("/oauth/token", post(exchange)) + .with_state(self.clone()) + } + + pub(crate) fn guard(&self, router: Router) -> Router { + router.layer(middleware::from_fn_with_state(self.clone(), guard)) + } + + fn accepts(&self, credential: &str) -> bool { + let now = Instant::now(); + let mut store = self.0.store.lock().unwrap(); + store.prune(now); + store.tokens.get(&digest(credential)).is_some_and(|token| { + token.resource == self.0.resource && token.scope == SCOPE && !token.client.is_empty() + }) + } +} + +fn digest(value: &str) -> Digest32 { + Sha256::digest(value.as_bytes()).into() +} + +struct Failure(StatusCode, &'static str); + +impl IntoResponse for Failure { + fn into_response(self) -> Response { + error(self.0, self.1) + } +} + +fn random() -> Result { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes) + .map_err(|_| Failure(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"))?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +fn secure(mut response: Response) -> Response { + for (key, value) in [ + ("cache-control", "no-store"), + ("pragma", "no-cache"), + ( + "content-security-policy", + "default-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ), + ("x-frame-options", "DENY"), + ("referrer-policy", "no-referrer"), + ("x-content-type-options", "nosniff"), + ] { + response + .headers_mut() + .insert(key, HeaderValue::from_static(value)); + } + response +} + +fn error(status: StatusCode, name: &'static str) -> Response { + secure((status, Json(json!({"error": name}))).into_response()) +} + +fn one_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + let mut values = headers.get_all(name).iter(); + let value = values.next()?.to_str().ok()?; + values.next().is_none().then_some(value) +} + +async fn guard(State(auth): State, request: Request, next: Next) -> Response { + let host_ok = one_header(request.headers(), "host") + .is_some_and(|host| host.eq_ignore_ascii_case(&auth.0.authority)); + let browser_post = + request.method() == Method::POST && request.uri().path() == "/oauth/authorize"; + let origin = one_header(request.headers(), "origin"); + let origin_ok = if browser_post { + origin == Some(auth.0.issuer.as_str()) + } else { + !request.headers().contains_key("origin") + }; + if !host_ok || !origin_ok { + return error(StatusCode::FORBIDDEN, "access_denied"); + } + if request.method() == Method::POST && request.uri().query().is_some() { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } + if request.uri().to_string().len() > MAX_BODY { + return error(StatusCode::URI_TOO_LONG, "invalid_request"); + } + secure(next.run(request).await) +} + +pub(crate) async fn require_token( + State(auth): State, + mut request: Request, + next: Next, +) -> Response { + if !bearer_credential(request.headers()).is_some_and(|token| auth.accepts(token)) { + let mut response = error(StatusCode::UNAUTHORIZED, "invalid_token"); + let challenge = format!( + "Bearer resource_metadata=\"{}/.well-known/oauth-protected-resource/mcp\", scope=\"{SCOPE}\"", + auth.0.issuer + ); + response + .headers_mut() + .insert(header::WWW_AUTHENTICATE, challenge.parse().unwrap()); + return response; + } + request.headers_mut().remove(header::AUTHORIZATION); + next.run(request).await +} + +async fn as_metadata(State(auth): State) -> Response { + let issuer = &auth.0.issuer; + Json(json!({ + "issuer": issuer, + "authorization_endpoint": format!("{issuer}/oauth/authorize"), + "token_endpoint": format!("{issuer}/oauth/token"), + "registration_endpoint": format!("{issuer}/oauth/register"), + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "token_endpoint_auth_methods_supported": ["none"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": [SCOPE], + "authorization_response_iss_parameter_supported": true, + })) + .into_response() +} + +async fn resource_metadata(State(auth): State) -> Response { + Json(json!({ + "resource": auth.0.resource, + "authorization_servers": [auth.0.issuer], + "scopes_supported": [SCOPE], + "bearer_methods_supported": ["header"], + })) + .into_response() +} + +async fn body(auth: &LocalOAuth, request: Request, content_type: &str) -> Result, Failure> { + if !one_header(request.headers(), "content-type").is_some_and(|v| { + v.split(';') + .next() + .is_some_and(|v| v.trim() == content_type) + }) { + return Err(Failure( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "invalid_request", + )); + } + // Bound concurrent AS buffers and slow uploads without timing out MCP work. + let _permit = auth + .0 + .body_slots + .try_acquire() + .map_err(|_| Failure(StatusCode::TOO_MANY_REQUESTS, "slow_down"))?; + let bytes = tokio::time::timeout( + Duration::from_secs(10), + to_bytes(request.into_body(), MAX_BODY), + ) + .await + .map_err(|_| Failure(StatusCode::REQUEST_TIMEOUT, "invalid_request"))? + .map_err(|_| Failure(StatusCode::PAYLOAD_TOO_LARGE, "invalid_request"))?; + Ok(bytes.to_vec()) +} + +fn fields(encoded: &[u8]) -> Result { + if encoded.len() > MAX_BODY { + return Err(Failure(StatusCode::BAD_REQUEST, "invalid_request")); + } + // Reject malformed escapes/UTF-8 rather than silently changing the client's state. + let mut decoded = Vec::with_capacity(encoded.len()); + let mut index = 0; + while index < encoded.len() { + if encoded[index] == b'%' { + let byte = encoded + .get(index + 1..index + 3) + .filter(|pair| pair.iter().all(u8::is_ascii_hexdigit)) + .and_then(|pair| std::str::from_utf8(pair).ok()) + .and_then(|pair| u8::from_str_radix(pair, 16).ok()) + .ok_or(Failure(StatusCode::BAD_REQUEST, "invalid_request"))?; + decoded.push(byte); + index += 3; + } else { + decoded.push(encoded[index]); + index += 1; + } + } + std::str::from_utf8(&decoded) + .map_err(|_| Failure(StatusCode::BAD_REQUEST, "invalid_request"))?; + let mut result = Fields::new(); + for (key, value) in form_urlencoded::parse(encoded) { + if key.len() > 64 + || value.len() > 2048 + || result.len() >= 16 + || result + .insert(key.into_owned(), value.into_owned()) + .is_some() + { + return Err(Failure(StatusCode::BAD_REQUEST, "invalid_request")); + } + } + Ok(result) +} + +fn param<'a>(fields: &'a Fields, name: &str) -> &'a str { + fields.get(name).map_or("", String::as_str) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Registration { + redirect_uris: Vec, + client_name: Option, + token_endpoint_auth_method: Option, + grant_types: Option>, + response_types: Option>, + scope: Option, +} + +fn valid_redirect(value: &str) -> bool { + let Ok(url) = Url::parse(value) else { + return false; + }; + let loopback = url.host_str().is_some_and(|host| { + host == "localhost" + || host + .trim_matches(['[', ']']) + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }); + value.len() <= 2048 + && url.host_str().is_some() + && (url.scheme() == "https" || (url.scheme() == "http" && loopback)) + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none() + && url.as_str() == value + && !url.query_pairs().any(|(key, _)| { + matches!( + key.as_ref(), + "code" | "state" | "iss" | "error" | "error_description" + ) + }) +} + +async fn register(State(auth): State, request: Request) -> Response { + let now = Instant::now(); + { + let mut store = auth.0.store.lock().unwrap(); + store.prune(now); + if !store.registration_rate.allow(now, 16) { + return error(StatusCode::TOO_MANY_REQUESTS, "slow_down"); + } + } + let bytes = match body(&auth, request, "application/json").await { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let registration: Registration = match serde_json::from_slice(&bytes) { + Ok(r) => r, + Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_client_metadata"), + }; + if registration.redirect_uris.is_empty() + || registration.redirect_uris.len() > 8 + || registration + .redirect_uris + .iter() + .any(|u| !valid_redirect(u)) + || registration + .client_name + .as_ref() + .is_some_and(|n| n.len() > 128 || n.chars().any(char::is_control)) + || registration + .token_endpoint_auth_method + .as_deref() + .is_some_and(|m| m != "none") + || registration + .grant_types + .as_ref() + .is_some_and(|g| g != &["authorization_code"]) + || registration + .response_types + .as_ref() + .is_some_and(|r| r != &["code"]) + || registration.scope.as_deref().is_some_and(|s| s != SCOPE) + { + return error(StatusCode::BAD_REQUEST, "invalid_client_metadata"); + } + let id = match random() { + Ok(id) => id, + Err(e) => return e.into_response(), + }; + let mut store = auth.0.store.lock().unwrap(); + if store.clients.len() >= MAX_CLIENTS { + return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"); + } + let response = ( + StatusCode::CREATED, + Json(json!({ + "client_id": id, "client_id_expires_in": CLIENT_TTL.as_secs(), + "redirect_uris": registration.redirect_uris, "client_name": registration.client_name, + "token_endpoint_auth_method": "none", "grant_types": ["authorization_code"], + "response_types": ["code"], "scope": SCOPE, + })), + ) + .into_response(); + store.clients.insert( + id, + Client { + redirects: registration.redirect_uris, + name: registration + .client_name + .unwrap_or_else(|| "Unnamed public client".into()), + expires: now + CLIENT_TTL, + }, + ); + response +} + +fn redirect( + auth: &LocalOAuth, + target: &str, + state: Option<&str>, + key: &str, + value: &str, +) -> Response { + let mut url = Url::parse(target).expect("validated registered redirect"); + { + let mut pairs = url.query_pairs_mut(); + pairs + .append_pair(key, value) + .append_pair("iss", &auth.0.issuer); + if let Some(state) = state { + pairs.append_pair("state", state); + } + } + (StatusCode::SEE_OTHER, [(header::LOCATION, url.as_str())]).into_response() +} + +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn cookie(value: &str, max_age: u64) -> HeaderValue { + format!( + "{COOKIE}={value}; Max-Age={max_age}; Path=/oauth/authorize; Secure; HttpOnly; SameSite=Lax" + ) + .parse() + .unwrap() +} + +async fn authorize(State(auth): State, request: Request) -> Response { + let fields = match fields(request.uri().query().unwrap_or("").as_bytes()) { + Ok(f) => f, + Err(e) => return e.into_response(), + }; + // Never accept a credential in a URL, even as an unknown extension parameter. + if fields.keys().any(|k| { + !matches!( + k.as_str(), + "client_id" + | "redirect_uri" + | "response_type" + | "scope" + | "resource" + | "state" + | "code_challenge" + | "code_challenge_method" + ) + }) || fields.get("state").is_some_and(|s| s.len() > 1024) + { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } + let now = Instant::now(); + let mut store = auth.0.store.lock().unwrap(); + store.prune(now); + if !store.authorization_rate.allow(now, 60) { + return error(StatusCode::TOO_MANY_REQUESTS, "slow_down"); + } + let Some(client) = store.clients.get(param(&fields, "client_id")) else { + return error(StatusCode::BAD_REQUEST, "invalid_client"); + }; + let target = param(&fields, "redirect_uri"); + if !client.redirects.iter().any(|r| r == target) { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + } + let state = fields.get("state").cloned(); + let fail = |name| redirect(&auth, target, state.as_deref(), "error", name); + if param(&fields, "response_type") != "code" { + return fail("unsupported_response_type"); + } + if param(&fields, "resource") != auth.0.resource { + return fail("invalid_target"); + } + if param(&fields, "scope") != SCOPE { + return fail("invalid_scope"); + } + let challenge = param(&fields, "code_challenge"); + let challenge_bytes = URL_SAFE_NO_PAD.decode(challenge).ok(); + if param(&fields, "code_challenge_method") != "S256" + || challenge.len() != 43 + || !challenge_bytes + .as_ref() + .is_some_and(|b| b.len() == 32 && URL_SAFE_NO_PAD.encode(b) == challenge) + { + return fail("invalid_request"); + } + if store.transactions.len() >= MAX_TRANSACTIONS { + return fail("temporarily_unavailable"); + } + let name = escape(&client.name); + let transaction = match random() { + Ok(v) => v, + Err(e) => return e.into_response(), + }; + let csrf = match random() { + Ok(v) => v, + Err(e) => return e.into_response(), + }; + let html = format!( + "qmcp owner login and authorization

Owner login + authorize

Unverified public client: {name}

Redirect: {}

Approve grants all QueryMT tools on {} (scope {SCOPE}) for 30 minutes. Only approve a client and callback you trust.

", + escape(target), + escape(&auth.0.resource) + ); + let grant = Grant { + client: param(&fields, "client_id").into(), + redirect: target.into(), + resource: auth.0.resource.clone(), + scope: SCOPE.into(), + challenge: challenge_bytes.unwrap().try_into().unwrap(), + }; + store.transactions.insert( + digest(&transaction), + Transaction { + grant, + state, + csrf: digest(&csrf), + expires: now + TRANSACTION_TTL, + }, + ); + let mut response = Html(html).into_response(); + response.headers_mut().insert( + header::SET_COOKIE, + cookie(&transaction, TRANSACTION_TTL.as_secs()), + ); + response +} + +fn browser_cookie(headers: &HeaderMap) -> Option<&str> { + let mut found = None; + for header in headers.get_all(header::COOKIE) { + for item in header.to_str().ok()?.split(';') { + let Some((key, value)) = item.trim().split_once('=') else { + continue; + }; + if key == COOKIE { + if found.is_some() || value.len() != 43 { + return None; + } + found = Some(value); + } + } + } + found +} + +async fn consent(State(auth): State, request: Request) -> Response { + let transaction = browser_cookie(request.headers()).map(digest); + let bytes = match body(&auth, request, "application/x-www-form-urlencoded").await { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let fields = match fields(&bytes) { + Ok(f) => f, + Err(e) => return e.into_response(), + }; + let now = Instant::now(); + let mut store = auth.0.store.lock().unwrap(); + store.prune(now); + if !store.login_rate.allow(now, 8) { + return error(StatusCode::TOO_MANY_REQUESTS, "slow_down"); + } + // Each submitted transaction gets exactly one attempt, including CSRF/owner failures. + let Some(transaction) = transaction.and_then(|id| store.transactions.remove(&id)) else { + return error(StatusCode::BAD_REQUEST, "invalid_request"); + }; + if !bool::from(transaction.csrf.ct_eq(&digest(param(&fields, "csrf")))) { + return error(StatusCode::FORBIDDEN, "access_denied"); + } + let grant = transaction.grant; + let result = if param(&fields, "decision") != "approve" + || fields + .keys() + .any(|k| !matches!(k.as_str(), "csrf" | "decision" | "owner_secret")) + || !bool::from(auth.0.owner.ct_eq(&digest(param(&fields, "owner_secret")))) + { + redirect( + &auth, + &grant.redirect, + transaction.state.as_deref(), + "error", + "access_denied", + ) + } else if store.codes.len() >= MAX_CODES || !store.clients.contains_key(&grant.client) { + redirect( + &auth, + &grant.redirect, + transaction.state.as_deref(), + "error", + "temporarily_unavailable", + ) + } else { + let code = match random() { + Ok(v) => v, + Err(e) => return e.into_response(), + }; + let result = redirect( + &auth, + &grant.redirect, + transaction.state.as_deref(), + "code", + &code, + ); + store.codes.insert( + digest(&code), + Code { + grant, + expires: now + CODE_TTL, + }, + ); + result + }; + let mut result = result; + result + .headers_mut() + .insert(header::SET_COOKIE, cookie("", 0)); + result +} + +async fn exchange(State(auth): State, request: Request) -> Response { + if request.headers().contains_key(header::AUTHORIZATION) { + return error(StatusCode::BAD_REQUEST, "invalid_client"); + } + let now = Instant::now(); + { + let mut store = auth.0.store.lock().unwrap(); + store.prune(now); + if !store.exchange_rate.allow(now, 120) { + return error(StatusCode::TOO_MANY_REQUESTS, "slow_down"); + } + } + let bytes = match body(&auth, request, "application/x-www-form-urlencoded").await { + Ok(b) => b, + Err(e) => return e.into_response(), + }; + let fields = match fields(&bytes) { + Ok(f) => f, + Err(e) => return e.into_response(), + }; + let mut store = auth.0.store.lock().unwrap(); + // Remove under the same lock as validation/issuance: one use even on wrong bindings/PKCE. + let Some(code) = store.codes.remove(&digest(param(&fields, "code"))) else { + return error(StatusCode::BAD_REQUEST, "invalid_grant"); + }; + let verifier = param(&fields, "code_verifier"); + let verifier_ok = (43..=128).contains(&verifier.len()) + && verifier + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-._~".contains(&b)); + if code.expires <= Instant::now() + || fields.keys().any(|k| { + !matches!( + k.as_str(), + "grant_type" + | "client_id" + | "redirect_uri" + | "resource" + | "scope" + | "code" + | "code_verifier" + ) + }) + || param(&fields, "grant_type") != "authorization_code" + || param(&fields, "client_id") != code.grant.client + || param(&fields, "redirect_uri") != code.grant.redirect + || param(&fields, "resource") != code.grant.resource + || fields.get("scope").is_some_and(|s| s != &code.grant.scope) + || !verifier_ok + || !bool::from(code.grant.challenge.ct_eq(&digest(verifier))) + { + return error(StatusCode::BAD_REQUEST, "invalid_grant"); + } + if store.tokens.len() >= MAX_TOKENS { + return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"); + } + let token = match random() { + Ok(v) => v, + Err(e) => return e.into_response(), + }; + store.tokens.insert( + digest(&token), + Token { + client: code.grant.client, + resource: code.grant.resource, + scope: code.grant.scope, + expires: Instant::now() + TOKEN_TTL, + }, + ); + Json(json!({ "access_token": token, "token_type": "Bearer", "expires_in": TOKEN_TTL.as_secs(), "scope": SCOPE })).into_response() +} + +#[cfg(test)] +mod tests; diff --git a/src/local_oauth/tests.rs b/src/local_oauth/tests.rs new file mode 100644 index 0000000..f63bde6 --- /dev/null +++ b/src/local_oauth/tests.rs @@ -0,0 +1,893 @@ +use super::*; +use crate::{BridgeConfig, QmcpServer, policy::PermissionPolicy, transport}; +use axum::body::Body; +use serde_json::Value; +use tower::ServiceExt; + +const RESOURCE: &str = "https://mcp.example.com/mcp"; +const ISSUER: &str = "https://mcp.example.com"; +const CALLBACK: &str = "https://client.example/callback?existing=1"; +// Public deterministic test fixture, not a runtime credential. +const SECRET: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; +const VERIFIER: &str = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG._~-"; +const STATE: &str = "a&b=+ /?#%\"<>\0"; + +struct Harness { + auth: LocalOAuth, + app: Router, +} +struct Reply { + status: StatusCode, + headers: HeaderMap, + text: String, +} + +impl Reply { + fn json(&self) -> Value { + serde_json::from_str(&self.text).unwrap() + } + fn location(&self) -> Url { + Url::parse(self.headers[header::LOCATION].to_str().unwrap()).unwrap() + } + fn parameter(&self, key: &str) -> String { + self.location() + .query_pairs() + .find(|(k, _)| k == key) + .unwrap() + .1 + .into_owned() + } +} + +fn encoded(pairs: &[(&str, &str)]) -> String { + form_urlencoded::Serializer::new(String::new()) + .extend_pairs(pairs.iter().copied()) + .finish() +} + +impl Harness { + fn new() -> Self { + let auth = LocalOAuth::new(RESOURCE, SECRET).unwrap(); + let server = QmcpServer::new( + BridgeConfig::new("127.0.0.1:1", false, PermissionPolicy::Cancel).unwrap(), + ) + .unwrap(); + let app = transport::http_router_with_local_oauth( + server, + "127.0.0.1:3031".parse().unwrap(), + auth.clone(), + &[], + ) + .unwrap(); + Self { auth, app } + } + + async fn request( + &self, + method: &str, + uri: &str, + body: String, + headers: &[(&str, &str)], + ) -> Reply { + let mut request = Request::builder() + .method(method) + .uri(uri) + .header("host", "mcp.example.com"); + for (key, value) in headers { + request = request.header(*key, *value); + } + let response = self + .app + .clone() + .oneshot(request.body(Body::from(body)).unwrap()) + .await + .unwrap(); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + assert_eq!(response.headers()["x-frame-options"], "DENY"); + let status = response.status(); + let headers = response.headers().clone(); + let text = String::from_utf8( + to_bytes(response.into_body(), 65536) + .await + .unwrap() + .to_vec(), + ) + .unwrap(); + Reply { + status, + headers, + text, + } + } + + async fn register(&self, value: Value) -> Reply { + self.request( + "POST", + "/oauth/register", + value.to_string(), + &[("content-type", "application/json")], + ) + .await + } + + async fn client(&self) -> String { + let response = self.register(json!({ "redirect_uris": [CALLBACK], "client_name": "" })).await; + assert_eq!(response.status, StatusCode::CREATED, "{}", response.text); + response.json()["client_id"].as_str().unwrap().into() + } + + async fn authorize(&self, client: &str, changes: &[(&str, &str)]) -> Reply { + let challenge = URL_SAFE_NO_PAD.encode(digest(VERIFIER)); + let mut pairs = vec![ + ("client_id", client), + ("redirect_uri", CALLBACK), + ("resource", RESOURCE), + ("scope", SCOPE), + ("response_type", "code"), + ("code_challenge_method", "S256"), + ("code_challenge", challenge.as_str()), + ("state", STATE), + ]; + for (key, value) in changes { + pairs.retain(|(k, _)| k != key); + pairs.push((key, value)); + } + self.request( + "GET", + &format!("/oauth/authorize?{}", encoded(&pairs)), + String::new(), + &[], + ) + .await + } + + fn browser(reply: &Reply) -> (String, String) { + assert_eq!(reply.status, StatusCode::OK, "{}", reply.text); + let cookie = reply.headers[header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let csrf = reply + .text + .split("name=\"csrf\" value=\"") + .nth(1) + .unwrap() + .split('"') + .next() + .unwrap() + .to_owned(); + (cookie, csrf) + } + + async fn consent( + &self, + cookie: &str, + csrf: &str, + secret: &str, + decision: &str, + origin: Option<&str>, + ) -> Reply { + let mut headers = vec![ + ("content-type", "application/x-www-form-urlencoded"), + ("cookie", cookie), + ]; + if let Some(origin) = origin { + headers.push(("origin", origin)); + } + self.request( + "POST", + "/oauth/authorize", + encoded(&[ + ("csrf", csrf), + ("owner_secret", secret), + ("decision", decision), + ]), + &headers, + ) + .await + } + + async fn code(&self, client: &str) -> String { + let page = self.authorize(client, &[]).await; + let (cookie, csrf) = Self::browser(&page); + let response = self + .consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) + .await; + assert_eq!(response.status, StatusCode::SEE_OTHER); + assert_eq!(response.parameter("iss"), ISSUER); + assert_eq!(response.parameter("state"), STATE); + assert_eq!(response.parameter("existing"), "1"); + response.parameter("code") + } + + async fn exchange(&self, client: &str, code: &str, changes: &[(&str, &str)]) -> Reply { + let mut pairs = vec![ + ("grant_type", "authorization_code"), + ("client_id", client), + ("code", code), + ("redirect_uri", CALLBACK), + ("resource", RESOURCE), + ("code_verifier", VERIFIER), + ]; + for (key, value) in changes { + pairs.retain(|(k, _)| k != key); + pairs.push((key, value)); + } + self.request( + "POST", + "/oauth/token", + encoded(&pairs), + &[("content-type", "application/x-www-form-urlencoded")], + ) + .await + } +} + +#[tokio::test] +async fn discovery_and_all_route_host_origin_guards() { + let h = Harness::new(); + for path in [ + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp", + ] { + let r = h.request("GET", path, String::new(), &[]).await; + assert_eq!(r.status, StatusCode::OK); + assert_eq!(r.json()["resource"], RESOURCE); + assert_eq!(r.json()["authorization_servers"], json!([ISSUER])); + } + let r = h + .request( + "GET", + "/.well-known/oauth-authorization-server", + String::new(), + &[], + ) + .await; + let metadata = r.json(); + assert_eq!(metadata["issuer"], ISSUER); + assert_eq!( + metadata["code_challenge_methods_supported"], + json!(["S256"]) + ); + assert_eq!( + metadata["grant_types_supported"], + json!(["authorization_code"]) + ); + assert_eq!( + metadata["token_endpoint_auth_methods_supported"], + json!(["none"]) + ); + assert_eq!( + metadata["authorization_response_iss_parameter_supported"], + true + ); + assert!(metadata.get("refresh_token").is_none()); + for (key, path) in [ + ("authorization_endpoint", "/oauth/authorize"), + ("token_endpoint", "/oauth/token"), + ("registration_endpoint", "/oauth/register"), + ] { + assert_eq!(metadata[key], format!("{ISSUER}{path}")); + } + for path in [ + "/mcp", + "/unknown", + "/oauth/authorize", + "/oauth/register", + "/oauth/token", + "/.well-known/oauth-authorization-server", + "/.well-known/oauth-protected-resource", + ] { + let r = h + .request("GET", path, String::new(), &[("host", "evil.example")]) + .await; + assert_eq!(r.status, StatusCode::FORBIDDEN, "{path}"); + let r = h + .request("GET", path, String::new(), &[("origin", ISSUER)]) + .await; + assert_eq!(r.status, StatusCode::FORBIDDEN, "{path}"); + } + let r = h + .request( + "POST", + "/oauth/token?owner_secret=not-allowed", + String::new(), + &[], + ) + .await; + assert_eq!(r.status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn dcr_validation_body_bounds_and_rate_limit() { + for value in [ + json!({"redirect_uris": []}), + json!({"redirect_uris": ["http://remote.example/cb"]}), + json!({"redirect_uris": ["https://u:p@client.example/cb"]}), + json!({"redirect_uris": ["https://client.example/cb#x"]}), + json!({"redirect_uris": ["https://client.example/cb?code=x"]}), + json!({"redirect_uris": [CALLBACK], "token_endpoint_auth_method": "client_secret_post"}), + json!({"redirect_uris": [CALLBACK], "grant_types": ["refresh_token"]}), + json!({"redirect_uris": [CALLBACK], "scope": "other"}), + json!({"redirect_uris": [CALLBACK], "logo_uri": "https://internal.example/ssrf"}), + json!({"redirect_uris": [CALLBACK], "client_name": "x".repeat(129)}), + json!({"redirect_uris": vec![CALLBACK; 9]}), + ] { + let h = Harness::new(); + let r = h.register(value).await; + assert_eq!(r.status, StatusCode::BAD_REQUEST, "{}", r.text); + assert!(h.auth.0.store.lock().unwrap().clients.is_empty()); + } + for uri in [ + "http://localhost:3210/callback", + "http://127.0.0.1:3210/callback", + "http://[::1]:3210/callback", + CALLBACK, + ] { + assert!(valid_redirect(uri), "{uri}"); + } + for uri in [ + "http://localhost.evil/cb", + "http://192.168.0.1/cb", + "javascript:alert(1)", + "https://client.example", + "https://client.example/cb?iss=x", + ] { + assert!(!valid_redirect(uri), "{uri}"); + } + let h = Harness::new(); + let r = h + .request( + "POST", + "/oauth/register", + "x".repeat(MAX_BODY + 1), + &[("content-type", "application/json")], + ) + .await; + assert_eq!(r.status, StatusCode::PAYLOAD_TOO_LARGE); + for _ in 0..15 { + h.client().await; + } + assert_eq!( + h.register(json!({"redirect_uris": [CALLBACK]})) + .await + .status, + StatusCode::TOO_MANY_REQUESTS + ); +} + +#[tokio::test] +async fn owner_login_consent_csrf_and_origin_are_mandatory() { + let h = Harness::new(); + let client = h.client().await; + let page = h.authorize(&client, &[]).await; + assert!( + page.text + .contains("<script>untrusted & client</script>") + ); + assert!(!page.text.contains("", + listener.local_addr().unwrap() + ); + let r = h + .register(json!({ + "redirect_uris": [CALLBACK], "client_uri": untrusted, "logo_uri": untrusted, + "policy_uri": untrusted, "tos_uri": untrusted, "extension": {"nested": [1, 2]} + })) + .await; + assert_eq!(r.status, StatusCode::CREATED); + assert!(!r.text.contains("not-rendered")); + let client = r.json()["client_id"].as_str().unwrap().to_owned(); + let page = h + .authorize(&client, &[("future_extension", &untrusted)]) + .await; + assert_eq!(page.status, StatusCode::OK); + assert!(!page.text.contains("not-rendered")); + let (cookie, csrf) = Harness::browser(&page); + let result = h + .consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) + .await; + let token = h + .exchange( + &client, + &result.parameter("code"), + &[("future_extension", &untrusted)], + ) + .await; + assert_eq!(token.status, StatusCode::OK); + assert!(!token.text.contains("not-rendered")); + assert!( + tokio::time::timeout(Duration::from_millis(100), listener.accept()) + .await + .is_err(), + "metadata URL was fetched" + ); + let duplicate = format!( + "{{\"redirect_uris\":[\"{CALLBACK}\"],\"token_endpoint_auth_method\":\"none\",\"token_endpoint_auth_method\":\"none\"}}" + ); + assert_eq!( + h.request( + "POST", + "/oauth/register", + duplicate, + &[("content-type", "application/json")] + ) + .await + .status, + StatusCode::BAD_REQUEST + ); + assert!(fields(b"future_extension=one&future_extension=two").is_ok()); + for key in [ + "client_id", + "resource", + "code", + "code_verifier", + "grant_type", + "code_challenge_method", + "client_assertion", + "client_assertion_type", + ] { + assert!(fields(format!("{key}=one&{key}=two").as_bytes()).is_err()); + } + let code = h.code(&client).await; + assert_eq!( + h.exchange(&client, &code, &[("grant_type", "refresh_token")]) + .await + .json()["error"], + "invalid_grant" + ); + assert_eq!( + h.exchange(&client, &code, &[("grant_type", "client_credentials")]) + .await + .json()["error"], + "unsupported_grant_type" + ); + // Dispatching another grant must not consume an authorization code. + assert_eq!(h.exchange(&client, &code, &[]).await.status, StatusCode::OK); + assert_eq!( + h.authorize(&client, &[("response_mode", "fragment")]) + .await + .status, + StatusCode::BAD_REQUEST + ); +} + +#[tokio::test] +async fn client_code_token_and_registration_origin_limits_are_isolated_and_bounded() { + let h = Harness::new(); + let attacker = h.client().await; + let legitimate = h.client().await; + { + let mut store = h.auth.0.store.lock().unwrap(); + for i in 0..MAX_CLIENT_CODES { + store.codes.insert( + digest(&format!("code-{i}")), + Code { + grant: grant(&attacker), + expires: Instant::now() + CODE_TTL, + }, + ); + } + } + let page = h.authorize(&attacker, &[]).await; + let (cookie, csrf) = Harness::browser(&page); + assert_eq!( + h.consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) + .await + .parameter("error"), + "temporarily_unavailable" + ); + assert_eq!(h.auth.0.store.lock().unwrap().codes.len(), MAX_CLIENT_CODES); + let code = h.code(&legitimate).await; + assert_eq!( + h.exchange(&legitimate, &code, &[]).await.status, + StatusCode::OK + ); + { + let mut store = h.auth.0.store.lock().unwrap(); + store.codes.clear(); + for i in 0..MAX_CLIENT_TOKENS { + store.tokens.insert( + digest(&format!("token-{i}")), + Token { + family: None, + client: attacker.clone(), + resource: RESOURCE.into(), + scope: SCOPE.into(), + expires: Instant::now() + TOKEN_TTL, + }, + ); + } + } + let code = h.code(&attacker).await; + assert_eq!( + h.exchange(&attacker, &code, &[]).await.status, + StatusCode::SERVICE_UNAVAILABLE + ); + let code = h.code(&legitimate).await; + assert_eq!( + h.exchange(&legitimate, &code, &[]).await.status, + StatusCode::OK + ); + + let h = Harness::new(); + for _ in 0..16 { + h.client().await; + } + assert_eq!( + h.register(json!({"redirect_uris": [CALLBACK]})) + .await + .status, + StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!( + h.register(json!({"redirect_uris": ["https://unrelated.example/callback"]})) + .await + .status, + StatusCode::CREATED + ); + { + let mut store = h.auth.0.store.lock().unwrap(); + for i in store.registration_origins.len()..MAX_REGISTRATION_ORIGINS { + store.registration_origins.insert( + format!("https://origin-{i}.example"), + Rate { + start: Some(Instant::now()), + used: 1, + }, + ); + } + } + assert_eq!( + h.register(json!({"redirect_uris": ["https://new-origin.example/callback"]})) + .await + .status, + StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!( + h.auth.0.store.lock().unwrap().registration_origins.len(), + MAX_REGISTRATION_ORIGINS + ); + h.auth + .0 + .store + .lock() + .unwrap() + .prune(Instant::now() + Duration::from_secs(61)); + assert!( + h.auth + .0 + .store + .lock() + .unwrap() + .registration_origins + .is_empty() + ); + assert_eq!( + h.register(json!({"redirect_uris": ["https://new-origin.example/callback"]})) + .await + .status, + StatusCode::CREATED + ); +} + +#[tokio::test] +async fn recognized_authentication_fields_and_null_duplicates_stay_strict() { + let h = Harness::new(); + let duplicate = format!( + "{{\"redirect_uris\":[\"{CALLBACK}\"],\"token_endpoint_auth_method\":null,\"token_endpoint_auth_method\":\"none\"}}" + ); + assert_eq!( + h.request( + "POST", + "/oauth/register", + duplicate, + &[("content-type", "application/json")] + ) + .await + .status, + StatusCode::BAD_REQUEST + ); + let client = h.client().await; + for key in ["client_secret", "client_assertion", "client_assertion_type"] { + assert_eq!( + h.authorize(&client, &[(key, "not-supported")]).await.status, + StatusCode::BAD_REQUEST + ); + let code = h.code(&client).await; + assert_eq!( + h.exchange(&client, &code, &[(key, "not-supported")]) + .await + .status, + StatusCode::BAD_REQUEST + ); + } +} + +#[tokio::test] +async fn chatgpt_dcr_grant_sets_are_normalized_and_metadata_urls_are_not_fetched() { + let h = Harness::new(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let metadata_url = format!("http://{}/metadata", listener.local_addr().unwrap()); + for grants in [ + json!(["authorization_code", "refresh_token"]), + json!(["refresh_token", "authorization_code"]), + ] { + let reply = h + .register(json!({ + "redirect_uris": [CALLBACK], "client_name": "ChatGPT", + "token_endpoint_auth_method": "none", "grant_types": grants, + "response_types": ["code"], "scope": SCOPE, + "client_uri": metadata_url, "logo_uri": metadata_url, + "application_type": "web", "unknown_safe": {"nested": true} + })) + .await; + assert_eq!(reply.status, StatusCode::CREATED); + assert_eq!( + reply.json()["grant_types"], + json!(["authorization_code", "refresh_token"]) + ); + assert_eq!(reply.json()["token_endpoint_auth_method"], "none"); + assert!(!reply.text.contains("metadata")); + let client = reply.json()["client_id"].as_str().unwrap().to_owned(); + assert!(h.auth.0.store.lock().unwrap().clients[&client].refresh_allowed); + } + for metadata in [ + json!({"redirect_uris": [CALLBACK]}), + json!({"redirect_uris": [CALLBACK], "grant_types": ["authorization_code"]}), + ] { + let reply = h.register(metadata).await; + assert_eq!(reply.status, StatusCode::CREATED); + assert_eq!(reply.json()["grant_types"], json!(["authorization_code"])); + let client = reply.json()["client_id"].as_str().unwrap().to_owned(); + assert!(!h.auth.0.store.lock().unwrap().clients[&client].refresh_allowed); + let tokens = h.initial_tokens(&client).await; + assert!(tokens.get("refresh_token").is_none()); + } + assert!( + tokio::time::timeout(Duration::from_millis(100), listener.accept()) + .await + .is_err() + ); +} + +#[tokio::test] +async fn browser_consent_discloses_registered_grant_lifetimes_before_owner_login() { + assert_eq!(TOKEN_TTL.as_secs() % 60, 0); + assert_eq!(FAMILY_TTL.as_secs() % 3600, 0); + let h = Harness::new(); + let callback = format!("{CALLBACK}&label=%3Cuntrusted%3E"); + for (grants, refresh_allowed) in [ + (None, false), + (Some(json!(["authorization_code"])), false), + (Some(json!(["authorization_code", "refresh_token"])), true), + (Some(json!(["refresh_token", "authorization_code"])), true), + ] { + let mut metadata = json!({ + "redirect_uris": [callback], "client_name": "", + "token_endpoint_auth_method": "none", + "refresh_allowed": !refresh_allowed, + "consent_duration": "" + }); + if let Some(grants) = grants { + metadata["grant_types"] = grants; + } + let registration = h.register(metadata).await; + assert_eq!(registration.status, StatusCode::CREATED); + let client = registration.json()["client_id"] + .as_str() + .unwrap() + .to_owned(); + let page = h + .authorize( + &client, + &[ + ("redirect_uri", &callback), + ( + "refresh_allowed", + if refresh_allowed { "false" } else { "true" }, + ), + ( + "grant_types", + if refresh_allowed { + "authorization_code" + } else { + "refresh_token" + }, + ), + ("consent_duration", ""), + ], + ) + .await; + // Inspect the GET response only: no owner credentials or approval submitted. + Harness::browser(&page); + assert!(page.text.contains(&format!( + "Approve grants all QueryMT tools on {RESOURCE} (scope {SCOPE})." + ))); + assert!( + page.text + .contains("Only approve a client and callback you trust.") + ); + assert_eq!( + page.text.contains("Access lasts up to 30 minutes."), + !refresh_allowed + ); + assert_eq!( + page.text + .contains("Each access token lasts up to 30 minutes."), + refresh_allowed + ); + assert_eq!( + page.text.contains("Access can be renewed without a new login for up to 24 hours from the initial code exchange; renewal does not extend this deadline."), + refresh_allowed + ); + assert_eq!(page.text.contains("24 hours"), refresh_allowed); + assert_eq!(page.text.contains("renew"), refresh_allowed); + assert!(!page.text.contains("for 30 minutes")); + assert!( + page.text + .contains("<script>untrusted & client</script>") + ); + assert!( + page.text + .contains(&format!("Redirect: {}", escape(&callback))) + ); + for forbidden in ["= start + FAMILY_TTL); + assert!(store.families[&digest(raw)].expires <= Instant::now() + FAMILY_TTL); + store.tokens.get_mut(&digest(access)).unwrap().expires = + Instant::now() - Duration::from_secs(1); + } + assert!(!h.auth.accepts(access)); + let rotated = h.refresh(&client, raw, &[]).await; + assert_eq!(rotated.status, StatusCode::OK); + let rotated = rotated.json(); + let current = rotated["refresh_token"].as_str().unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + h.auth + .0 + .store + .lock() + .unwrap() + .families + .get_mut(&digest(raw)) + .unwrap() + .expires = deadline; + let last = h.refresh(&client, current, &[]).await; + assert_eq!(last.status, StatusCode::OK); + assert!(last.json()["expires_in"].as_u64().unwrap() <= 10); + { + let mut store = h.auth.0.store.lock().unwrap(); + assert_eq!(store.families[&digest(raw)].expires, deadline); + assert!( + store.tokens[&digest(last.json()["access_token"].as_str().unwrap())].expires + <= deadline + ); + store.prune(deadline); + assert!(store.families.is_empty()); + assert!(store.refresh.is_empty()); + assert!(store.tokens.is_empty()); + assert!(store.codes.is_empty()); + assert!(store.transactions.is_empty()); + } + assert_eq!( + h.refresh(&client, current, &[]).await.json()["error"], + "invalid_grant" + ); + let fresh = h.initial_tokens(&client).await; + let restarted = Harness::new(); + assert!( + !restarted + .auth + .accepts(fresh["access_token"].as_str().unwrap()) + ); + assert_eq!( + restarted + .refresh(&client, fresh["refresh_token"].as_str().unwrap(), &[]) + .await + .json()["error"], + "invalid_grant" + ); + assert!(restarted.auth.0.store.lock().unwrap().clients.is_empty()); + assert_eq!( + restarted.authorize(&client, &[]).await.status, + StatusCode::BAD_REQUEST + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn concurrent_refresh_exchanges_issue_once_then_replay_revokes_the_winner() { + let h = Arc::new(Harness::new()); + let client = h.refresh_client().await; + let tokens = h.initial_tokens(&client).await; + let raw = tokens["refresh_token"].as_str().unwrap().to_owned(); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let mut jobs = Vec::new(); + for _ in 0..2 { + let (h, client, raw, barrier) = (h.clone(), client.clone(), raw.clone(), barrier.clone()); + jobs.push(tokio::spawn(async move { + barrier.wait().await; + h.refresh(&client, &raw, &[]).await + })); + } + let a = jobs.remove(0).await.unwrap(); + let b = jobs.remove(0).await.unwrap(); + assert_eq!( + [a.status, b.status] + .iter() + .filter(|s| **s == StatusCode::OK) + .count(), + 1 + ); + let (winner, replay) = if a.status == StatusCode::OK { + (a, b) + } else { + (b, a) + }; + assert_eq!(replay.status, StatusCode::BAD_REQUEST); + assert_eq!(replay.json()["error"], "invalid_grant"); + let winner = winner.json(); + assert_eq!( + h.refresh(&client, winner["refresh_token"].as_str().unwrap(), &[]) + .await + .json()["error"], + "invalid_grant" + ); + assert_eq!( + h.mcp_status(winner["access_token"].as_str().unwrap()).await, + StatusCode::UNAUTHORIZED + ); + let store = h.auth.0.store.lock().unwrap(); + assert_eq!(store.families.len(), 1); + assert_eq!(store.refresh.len(), 2); + assert!(store.tokens.is_empty()); +} + +// Synthetic full tables exercise rejection paths without thousands of owner logins. +fn fill_capacity(store: &mut Store, kind: &str, client: &str, now: Instant) { + match kind { + "families" | "client_families" => { + let (limit, owner) = if kind == "families" { + (MAX_FAMILIES, "unrelated") + } else { + (MAX_CLIENT_FAMILIES, client) + }; + while if kind == "families" { + store.families.len() + } else { + store + .families + .values() + .filter(|f| f.client == client) + .count() + } < limit + { + let id = digest(&format!("filler-family-{}", store.families.len())); + store.families.insert( + id, + Family { + client: owner.into(), + resource: RESOURCE.into(), + scope: SCOPE.into(), + expires: now + FAMILY_TTL, + records: 1, + revoked: false, + }, + ); + store.refresh.insert( + id, + RefreshRecord { + family: id, + consumed: false, + }, + ); + } + } + "records" => { + while store.refresh.len() < MAX_REFRESH_RECORDS { + let family_id = digest(&format!("filler-record-family-{}", store.families.len())); + let count = MAX_FAMILY_RECORDS.min(MAX_REFRESH_RECORDS - store.refresh.len()); + store.families.insert( + family_id, + Family { + client: "unrelated".into(), + resource: RESOURCE.into(), + scope: SCOPE.into(), + expires: now + FAMILY_TTL, + records: count, + revoked: false, + }, + ); + for i in 0..count { + store.refresh.insert( + digest(&format!("filler-record-{}-{i}", store.families.len())), + RefreshRecord { + family: family_id, + consumed: i + 1 != count, + }, + ); + } + } + assert!(store.families.len() <= MAX_FAMILIES); + } + "access" | "client_access" => { + let (limit, owner) = if kind == "access" { + (MAX_TOKENS, "unrelated") + } else { + (MAX_CLIENT_TOKENS, client) + }; + while if kind == "access" { + store.tokens.len() + } else { + store.tokens.values().filter(|t| t.client == client).count() + } < limit + { + store.tokens.insert( + digest(&format!("filler-access-{}", store.tokens.len())), + Token { + family: None, + client: owner.into(), + resource: RESOURCE.into(), + scope: SCOPE.into(), + expires: now + TOKEN_TTL, + }, + ); + } + } + _ => panic!("unknown capacity fixture"), + } +} + +fn expire_capacity_fillers(store: &mut Store, original_family: Option) { + let expired = Instant::now() - Duration::from_secs(1); + for (id, family) in &mut store.families { + if Some(*id) != original_family { + family.expires = expired; + } + } + for token in store.tokens.values_mut() { + if token.family.is_none() { + token.expires = expired; + } + } + store.prune(Instant::now()); +} + +#[tokio::test] +async fn initial_refresh_family_capacity_failure_preserves_code_and_pruning_releases_slots() { + for kind in [ + "families", + "client_families", + "records", + "access", + "client_access", + ] { + let h = Harness::new(); + let client = h.refresh_client().await; + let code = h.code(&client).await; + { + let mut store = h.auth.0.store.lock().unwrap(); + fill_capacity(&mut store, kind, &client, Instant::now()); + } + let reply = h.exchange(&client, &code, &[]).await; + assert_eq!(reply.status, StatusCode::SERVICE_UNAVAILABLE, "{kind}"); + assert_eq!(reply.json(), json!({"error": "temporarily_unavailable"})); + { + let mut store = h.auth.0.store.lock().unwrap(); + assert!(store.codes.contains_key(&digest(&code))); + assert!(!store.tokens.values().any(|t| t.family.is_some())); + expire_capacity_fillers(&mut store, None); + assert!(store.families.is_empty()); + assert!(store.refresh.is_empty()); + assert!(store.tokens.is_empty()); + } + let reply = h.exchange(&client, &code, &[]).await; + assert_eq!(reply.status, StatusCode::OK, "{kind}"); + assert!(reply.json()["refresh_token"].is_string()); + let store = h.auth.0.store.lock().unwrap(); + assert_eq!(store.families.len(), 1); + assert_eq!(store.refresh.len(), 1); + assert_eq!(store.tokens.len(), 1); + assert!(store.codes.is_empty()); + } +} + +#[tokio::test] +async fn refresh_capacity_and_rate_failures_preserve_current_token_and_replay_evidence() { + for kind in ["records", "access", "client_access", "rate"] { + let h = Harness::new(); + let client = h.refresh_client().await; + let original = h.initial_tokens(&client).await; + let old = original["refresh_token"].as_str().unwrap(); + let rotated = h.refresh(&client, old, &[]).await.json(); + let current = rotated["refresh_token"].as_str().unwrap(); + { + let mut store = h.auth.0.store.lock().unwrap(); + if kind == "rate" { + store.clients.get_mut(&client).unwrap().exchange_rate.used = 60; + } else { + fill_capacity(&mut store, kind, &client, Instant::now()); + } + } + let reply = h.refresh(&client, current, &[]).await; + assert_eq!( + reply.status, + if kind == "rate" { + StatusCode::TOO_MANY_REQUESTS + } else { + StatusCode::SERVICE_UNAVAILABLE + }, + "{kind}" + ); + { + let mut store = h.auth.0.store.lock().unwrap(); + assert!(!store.refresh[&digest(current)].consumed); + assert!(store.refresh[&digest(old)].consumed); + assert!(!store.families[&digest(old)].revoked); + assert_eq!(store.families[&digest(old)].records, 2); + expire_capacity_fillers(&mut store, Some(digest(old))); + store.clients.get_mut(&client).unwrap().exchange_rate = Rate::default(); + } + assert!(h.auth.accepts(rotated["access_token"].as_str().unwrap())); + assert_eq!( + h.refresh(&client, current, &[]).await.status, + StatusCode::OK + ); + assert_eq!( + h.refresh(&client, old, &[]).await.json()["error"], + "invalid_grant" + ); + assert!(!h.auth.accepts(rotated["access_token"].as_str().unwrap())); + } +} + +#[tokio::test] +async fn refresh_chain_is_bounded_without_evicting_history_or_extending_absolute_lifetime() { + let h = Harness::new(); + let client = h.refresh_client().await; + let original = h.initial_tokens(&client).await; + let old = original["refresh_token"].as_str().unwrap(); + let family_id = digest(old); + let deadline = h.auth.0.store.lock().unwrap().families[&family_id].expires; + let start = Instant::now(); + let mut current = old.to_owned(); + for i in 1..MAX_FAMILY_RECORDS { + // Ten-minute virtual steps exercise real access expiry and fixed rate windows. + let now = start + Duration::from_secs(i as u64 * 600); + let request = fields( + encoded(&[ + ("grant_type", "refresh_token"), + ("client_id", &client), + ("refresh_token", ¤t), + ]) + .as_bytes(), + ) + .ok() + .unwrap(); + let response = { + let mut store = h.auth.0.store.lock().unwrap(); + store.prune(now); + exchange_refresh(&mut store, &request, now) + }; + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), MAX_BODY).await.unwrap(); + let response: Value = serde_json::from_slice(&body).unwrap(); + current = response["refresh_token"].as_str().unwrap().to_owned(); + } + { + let mut store = h.auth.0.store.lock().unwrap(); + assert_eq!(store.refresh.len(), MAX_FAMILY_RECORDS); + assert_eq!(store.families[&family_id].records, MAX_FAMILY_RECORDS); + assert_eq!(store.families[&family_id].expires, deadline); + assert!(store.refresh[&digest(old)].consumed); + let now = start + Duration::from_secs(MAX_FAMILY_RECORDS as u64 * 600); + store.prune(now); + let request = + fields(encoded(&[("client_id", &client), ("refresh_token", ¤t)]).as_bytes()) + .ok() + .unwrap(); + assert_eq!( + exchange_refresh(&mut store, &request, now).status(), + StatusCode::SERVICE_UNAVAILABLE + ); + assert!(!store.refresh[&digest(¤t)].consumed); + let replay = fields(encoded(&[("client_id", &client), ("refresh_token", old)]).as_bytes()) + .ok() + .unwrap(); + assert_eq!( + exchange_refresh(&mut store, &replay, now).status(), + StatusCode::BAD_REQUEST + ); + assert!(store.families[&family_id].revoked); + assert!(store.tokens.is_empty()); + assert_eq!(store.refresh.len(), MAX_FAMILY_RECORDS); + store.prune(deadline); + assert!(store.families.is_empty()); + assert!(store.refresh.is_empty()); + } +} + +// Optional browser harness: only a public test credential and a fresh loopback port. +// The driver forwards browser-generated headers; it never reads deployment files. +#[tokio::test] +#[ignore = "driven by target/oauth-review/browser-check.py"] +async fn browser_fixture_server() { + let dir = + std::env::var_os("QMCP_OAUTH_BROWSER_FIXTURE_DIR").expect("explicit fixture directory"); + let dir = std::path::PathBuf::from(dir); + let h = Harness::new(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + std::fs::write( + dir.join("port"), + listener.local_addr().unwrap().port().to_string(), + ) + .unwrap(); + axum::serve(listener, h.app) + .with_graceful_shutdown(async move { + for _ in 0..1200 { + if dir.join("done").exists() { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .unwrap(); +} + #[test] fn resource_and_secret_validation_do_not_echo_credentials() { for resource in [ From 7179524bcb1509b5a17b0c69685ebe70cd7c8c22 Mon Sep 17 00:00:00 2001 From: Sommelier Date: Sun, 25 Oct 2026 10:45:24 +0200 Subject: [PATCH 4/5] feat: remember session workspaces and switch directories --- README.md | 66 ++- docs/how-to-connect.md | 60 +++ src/commands.rs | 214 ++++++---- src/lib.rs | 1 + src/oauth.rs | 2 +- src/server.rs | 132 +++++- src/workspace.rs | 724 +++++++++++++++++++++++++++++++++ tests/bridge.rs | 894 ++++++++++++++++++++++++++++++++++++++++- 8 files changed, 2007 insertions(+), 86 deletions(-) create mode 100644 src/workspace.rs diff --git a/README.md b/README.md index 6214d70..445be40 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Start with `caps`, `profiles`, and `models` to discover available capabilities. | Tools | Purpose | | --- | --- | | `caps`, `profiles`, `models` | Discover protocol features, profiles, and models | -| `new`, `sessions`, `find` | Create, list, and search sessions | +| `workspace`, `new`, `sessions`, `find` | Select a workspace without prompting; create, list, and search sessions | | `status`, `inspect`, `runtime` | Read configuration, history, and live runtime | | `get_lifecycle`, `set_lifecycle` | Read or explicitly patch resolved/archived state | | `pin`, `unpin` | Set session pin state | @@ -65,10 +65,66 @@ Start with `caps`, `profiles`, and `models` to discover available capabilities. | `cancel`, `close` | Request turn cancellation or explicitly close a session | | `exec` | Run a batch sequentially on one ACP connection | -Use `prompt` with `new: true` to start a session, then its returned `sessionId` -as `session_id` to continue. Pass an explicit `cwd` for workspace operations; -paths refer to the QueryMT host. `profiles`, `models`, lifecycle, and pin tools -return provider data under `result`, separate from bridge status and event metadata. +Select a workspace once, without sending a prompt, then reuse its returned ID: + +```text +workspace({"cwd":"/srv/projects/app","profile":"my-profile"}) + -> {"sessionId":"A","cwd":"/srv/projects/app","created":true,"switched":false,...} +prompt({"session_id":"A","text":"Explain this project"}) +status({"session_id":"A"}) +workspace({"session_id":"A","cwd":"../other"}) + -> {"sessionId":"B","previousSessionId":"A","cwd":"/srv/projects/other","created":true,"switched":true,...} +prompt({"session_id":"B","text":"Work in the other project"}) +``` + +`profile` is optional; discover actual IDs with `profiles`. A different directory +creates a **new QueryMT session**, not a rebind of the old one. Always use the +returned **new** `sessionId` as `session_id` in subsequent calls. The old ID keeps +its workspace and work; switching does not close, cancel, or alias it. There is +no global active chat workspace: subsequent calls still need an explicit ID. + +`new` and `prompt` with `new: true` also remember cwd. Continuing `prompt` can +omit cwd; an explicit different cwd transparently creates a new session and +returns `previousSessionId`. `status`, `inspect`, and `follow` use remembered cwd +when omitted and reject a different cwd, directing you to `workspace` instead. +`profiles`, `models`, lifecycle, and pin tools return provider data under +`result`, separate from bridge status and event metadata. + +### Workspace Rules + +- Paths refer to the **QueryMT host**, not necessarily the qmcp host. Paths are + normalized lexically (`.` and `..`), without filesystem checks, symlink + resolution, or local canonicalization; the backend owns path validation. + This is not a sandbox or directory allowlist. +- `workspace` without `session_id` requires an explicit initial cwd. Relative + initial/new paths use qmcp's once-captured startup cwd; omitted cwd for `new` + or `prompt(new=true)` preserves that startup-directory default. Prefer absolute + backend paths, especially remotely. Relative paths with a selected ID use that + session's remembered cwd. Only POSIX paths are supported; empty/control-character, + Windows drive/UNC, and `~` paths are rejected. Home expansion is never guessed. +- Profiles explicitly selected at creation, or reported in authoritative list + metadata, can be inherited on switches. Otherwise a new session uses the backend + default, reported as `profileKnown:false` and `profileSource:"backend-default-unknown"`. + Same-workspace profile changes are rejected (including explicit profiles when + the old profile is unknown); create a workspace without an ID to choose one. +- Bindings are process-local, shared across server clones and HTTP requests, and + immutable per ID. `close` retains the binding for later resume. The store holds + at most 4096 IDs, with no eviction; capacity errors occur before session creation. + No workspace lock is held across network requests or long prompts. +- After restart, or for an unknown ID, qmcp first discovers its authoritative cwd + through unfiltered, paginated ACP `session/list`, without loading history. + Discovery scans at most 20 pages and rejects repeated cursors. Missing sessions, + unsupported/unavailable metadata, absent/non-absolute cwd, or incomplete scans + fail closed, even if a caller supplies cwd. No guessed cwd or implicit creation. + Metadata must accurately describe the backend's session; external backend changes + and symlink equivalence cannot be detected from a cached lexical binding. +- Acknowledged new IDs and cwd are recorded before configuration/status/prompt + steps. Later failure or cancellation includes recovery details. If a remote + creation is never acknowledged, qmcp cannot know its ID; inspect backend metadata + before retrying. Timeouts/disconnects never implicitly cancel remote work. + +See [the connection guide](docs/how-to-connect.md#select-a-session-workspace) +for the complete selection workflow. ## Important Behavior diff --git a/docs/how-to-connect.md b/docs/how-to-connect.md index 51d8e93..4f66551 100644 --- a/docs/how-to-connect.md +++ b/docs/how-to-connect.md @@ -25,6 +25,66 @@ qmcp connects to `ws://127.0.0.1:3030/acp/ws` by default. Change it with `--url` or `QMCP_ACP_URL`. Prefer `wss://` for remote ACP connections; plaintext non-loopback ACP requires `--allow-insecure`. qmcp does not launch QueryMT. +## Select a Session Workspace + +After connecting using any transport, use `workspace` to choose a directory on +**the QueryMT host** without sending a prompt. There is no chat-global active cwd; +the returned `sessionId` is the explicit handle for later calls. + +```text +workspace({"cwd":"/srv/projects/app"}) + -> {"sessionId":"A","cwd":"/srv/projects/app","created":true,"switched":false,...} +prompt({"session_id":"A","text":"Describe the project"}) +status({"session_id":"A"}) +inspect({"session_id":"A"}) +workspace({"session_id":"A","cwd":"../other"}) + -> {"sessionId":"B","previousSessionId":"A","cwd":"/srv/projects/other","created":true,"switched":true,...} +prompt({"session_id":"B","text":"Use this project now"}) +``` + +Always keep and use the returned **new ID** after a switch. QueryMT does not +reliably rebind a materialized session, so changing directories creates a new +backend session. The old session stays unchanged, with no implicit close, cancel, +or ID alias. Omitted cwd or an equivalent normalized path selects the same ID +with `created:false, switched:false`. Continuing `prompt` with a different cwd +has the same new-session behavior and returns `previousSessionId` too. +Read-only `status`, `inspect`, and `follow` remember cwd when omitted and reject +explicit different cwd, suggesting `workspace`; they never create a session. + +`workspace` without an ID requires cwd. `new` and `prompt(new=true)` also pin cwd +immediately on acknowledgement, even when later configuration fails. They retain +qmcp's startup-directory default if cwd is omitted. Initial/new relative paths +resolve from the once-captured qmcp startup cwd; relative paths with an existing +ID resolve from that session's cwd. Prefer absolute paths for remote QueryMT. +Normalization is lexical POSIX `.`/`..` processing, not a local filesystem check +or symlink resolution. `~` is rejected: supply an absolute backend home path +instead. Empty/control-character and Windows drive/UNC paths are rejected too. +The backend validates paths; workspace selection is not a sandbox or allowlist. + +Optionally supply `profile` on creation (discover IDs with `profiles`). Switches +inherit a known profile selected at creation or reported by backend list metadata. +If unknown, the backend default is used and the result explicitly reports +`profileKnown:false`, `profileSource:"backend-default-unknown"`. A same-workspace +explicit different/unknown profile is rejected; profile changing is not part of +workspace selection. Start without an ID to select a new profile independently. + +Bindings live in qmcp memory (4096 IDs, no eviction), shared by all server clones, +HTTP requests, and sequential `exec` steps. Closing a session retains its binding +for resume. Unknown IDs and IDs after qmcp restart require authoritative cwd from +unfiltered paginated `session/list`, at most 20 pages with repeated-cursor detection. +No history is loaded before cwd is known. Unsupported/missing cwd metadata, +not-found sessions, and incomplete scans produce distinct fail-closed errors, +not guessed paths or implicit sessions, even with explicit caller cwd. Accurate +backend metadata is required; cached lexical bindings cannot detect external +workspace changes or symlink equivalence. No locks span network operations, so +long prompts do not block session-ID-only runtime/steer/cancel calls. + +Check `recovery` on failures/timeouts before retrying; acknowledged new IDs remain +usable without cwd. A creation whose acknowledgement never arrives may still +exist remotely but has no locally known ID. Cancellation/disconnect does not +implicitly cancel or close remote work. This feature does not alter or deploy +the running QueryMT service or change global backend configuration. + ## Local stdio Configure your MCP client to launch qmcp: diff --git a/src/commands.rs b/src/commands.rs index 880a525..96214f5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -10,6 +10,7 @@ use serde_json::{Map, Value, json}; use qmacp::{client::AcpClient, error::CliError, session}; use crate::output::{self, BatchState, OperationState}; +use crate::workspace::{Binding, Workspaces}; const MIN_SECONDS: u64 = 1; const MAX_SECONDS: u64 = 86_400; @@ -86,7 +87,13 @@ pub enum Command { query: Option, provider: Option, }, - /// Create a session without sending a prompt. + /// Select a sticky per-session workspace without sending a prompt. With no session_id, an explicit cwd is required and creates a NEW QueryMT session. With an existing ID, omitted/same cwd selects that ID; different cwd creates a NEW ID and returns previousSessionId. Use the returned sessionId in later calls; the old session is untouched. Known profiles are inherited on switches, otherwise the backend default is used (unknown). Profile changes on the same workspace are not supported. Paths are POSIX paths on the QueryMT host; no ~ expansion. + Workspace { + session_id: Option, + cwd: Option, + profile: Option, + }, + /// Create a session without sending a prompt. cwd is remembered immediately; later session calls can omit it. Relative cwd uses qmcp startup cwd; paths refer to the QueryMT host, with no ~ expansion. New { cwd: Option, profile: Option, @@ -130,7 +137,7 @@ pub enum Command { Pin { session_id: String }, /// Unpin a session. Unpin { session_id: String }, - /// Read compact session history, or the full raw snapshot when full is true. + /// Read compact session history, or the full raw snapshot when full is true. Omit cwd to use the session's remembered or authoritative metadata workspace. Different cwd is rejected; use workspace to create a NEW session. Never creates or rebinds a session. Inspect { session_id: String, cwd: Option, @@ -140,7 +147,7 @@ pub enum Command { #[serde(default = "default_true")] tools: bool, }, - /// Current mode/model/profile/effort for a session. + /// Read current mode/model/profile/effort for a session. Omit cwd to use its remembered or authoritative metadata workspace. Different cwd is rejected; use workspace to create a NEW session. Never creates or rebinds a session. Status { session_id: String, cwd: Option, @@ -151,7 +158,7 @@ pub enum Command { SetModel { session_id: String, model: String }, /// Set reasoning effort (auto, low, medium, high, or max). SetEffort { session_id: String, effort: String }, - /// Send text to a session, or create one with new=true. Auto delivery prompts when idle, steers when possible, otherwise queues. + /// Send text to a session, or create one with new=true. cwd is sticky per session: omit it on later calls. Explicit different cwd creates a NEW QueryMT session, returns previousSessionId and the NEW sessionId to use later, and leaves the old session untouched. Relative cwd uses the selected session cwd (or qmcp startup cwd for new=true). No ~ expansion. Known profiles are inherited on switches; unknown uses the backend default. Same-workspace profile changes are rejected. Auto delivery prompts when idle, steers when possible, otherwise queues. Prompt { #[serde(default)] new: bool, @@ -173,7 +180,7 @@ pub enum Command { }, /// Current QueryMT runtime state for a session. Runtime { session_id: String }, - /// Collect compact session events until idle. Supply cwd for the session workspace. + /// Collect compact session events until idle. Omit cwd to use its remembered or authoritative metadata workspace. Different cwd is rejected; use workspace to create a NEW session. Never creates or rebinds a session. Follow { session_id: String, cwd: Option, @@ -245,6 +252,11 @@ pub fn decode(name: &str, mut arguments: Map) -> Result let command: Command = serde_json::from_value(Value::Object(arguments)).context("invalid tool arguments")?; match &command { + Command::Workspace { + session_id: None, + cwd: None, + .. + } => bail!("workspace requires an explicit initial cwd when session_id is omitted"), Command::Prompt { new, session_id, @@ -384,7 +396,15 @@ fn constrain_input_schema(name: &str, input: &mut Map) { if matches!(name, "prompt" | "steer" | "queue") { properties["text"]["pattern"] = json!(NON_BLANK_TEXT_PATTERN); } - if name == "prompt" { + if name == "workspace" { + input.insert( + "anyOf".into(), + json!([ + {"required": ["session_id"], "properties": {"session_id": {"type": "string"}}}, + {"required": ["cwd"], "properties": {"cwd": {"type": "string"}}}, + ]), + ); + } else if name == "prompt" { input.insert("oneOf".into(), json!([ {"required": ["new"], "properties": {"new": {"const": true}, "session_id": {"type": "null"}}}, {"required": ["session_id"], "properties": {"new": {"const": false}, "session_id": {"type": "string"}}}, @@ -416,6 +436,7 @@ pub async fn run( command: Command, state: &mut OperationState, deadline: Option, + workspaces: &Workspaces, ) -> Value { let deadline = deadline.or_else(|| { command @@ -426,7 +447,7 @@ pub async fn run( state.recovery.clear(); let operation = async { client.clear_assistant_text().await; - run_inner(client, command, state).await + run_inner(client, command, state, workspaces).await }; let outcome = match deadline { Some(deadline) => tokio::time::timeout_at(deadline, operation) @@ -453,6 +474,7 @@ async fn run_inner( client: &AcpClient, command: Command, state: &mut OperationState, + workspaces: &Workspaces, ) -> Result { let result = match command { Command::Caps {} => caps(client).await?, @@ -462,13 +484,31 @@ async fn run_inner( query, provider, } => models(client, refresh, query, provider).await?, + Command::Workspace { + session_id, + cwd, + profile, + } => workspaces + .select(client, state, session_id, cwd, profile) + .await? + .result(), Command::New { cwd, profile, mode, model, effort, - } => new_session(client, state, cwd, profile, mode, model, effort).await?, + } => { + new_session( + client, + state, + workspaces, + cwd, + profile, + (mode, model, effort), + ) + .await? + } Command::Sessions { cwd, cursor, @@ -480,7 +520,8 @@ async fn run_inner( } => { sessions( client, - cwd, + cwd.map(|cwd| workspaces.initial_cwd(Some(cwd))) + .transpose()?, cursor, all, limit, @@ -503,7 +544,8 @@ async fn run_inner( } => { find( client, - cwd, + cwd.map(|cwd| workspaces.initial_cwd(Some(cwd))) + .transpose()?, query, phase, limit, @@ -529,8 +571,14 @@ async fn run_inner( full, messages, tools, - } => inspect(client, session_id, cwd, full, messages, tools).await?, - Command::Status { session_id, cwd } => status(client, session_id, cwd).await?, + } => { + let cwd = workspaces.read_cwd(client, &session_id, cwd).await?; + inspect(client, session_id, cwd, full, messages, tools).await? + } + Command::Status { session_id, cwd } => { + let cwd = workspaces.read_cwd(client, &session_id, cwd).await?; + status(client, session_id, cwd).await? + } Command::SetMode { session_id, mode } => set_mode(client, session_id, mode).await?, Command::SetModel { session_id, model } => set_model(client, session_id, model).await?, Command::SetEffort { session_id, effort } => set_effort(client, session_id, effort).await?, @@ -559,7 +607,7 @@ async fn run_inner( delivery, until_idle, }; - prompt(client, state, args).await? + prompt(client, state, workspaces, args).await? } Command::Runtime { session_id } => runtime(client, session_id).await?, Command::Follow { @@ -567,7 +615,10 @@ async fn run_inner( cwd, interval, timeout, - } => follow(client, session_id, cwd, interval, timeout).await?, + } => { + let cwd = workspaces.read_cwd(client, &session_id, cwd).await?; + follow(client, session_id, cwd, interval, timeout).await? + } Command::Watch { session_id, interval, @@ -604,7 +655,7 @@ async fn run_inner( session_id, input_id, } => discard_queued(client, session_id, input_id).await?, - Command::Exec { calls } => exec(client, calls, state).await?, + Command::Exec { calls } => exec(client, calls, state, workspaces).await?, Command::Cancel { session_id } => cancel(client, session_id).await?, Command::Close { session_id } => close(client, session_id).await?, }; @@ -676,30 +727,33 @@ async fn models( async fn new_session( client: &AcpClient, state: &mut OperationState, + workspaces: &Workspaces, cwd: Option, profile: Option, - mode: Option, - model: Option, - effort: Option, + config: (Option, Option, Option), ) -> Result { - client.initialize().await.map_err(CliError::from_request)?; - let cwd = session::resolve_cwd(cwd).map_err(CliError::rpc)?; - let (created, created_raw) = client - .new_session(cwd.clone(), profile.as_deref()) - .await - .map_err(CliError::from_request)?; - let session_id = created.session_id.to_string(); - state.session(&session_id, &cwd, true); - apply_config(client, &session_id, mode, model, effort).await?; + let binding = Binding { + cwd: workspaces.initial_cwd(cwd)?, + profile, + }; + let source = if binding.profile.is_some() { + "explicit" + } else { + "backend-default-unknown" + }; + let (selected, created, created_raw) = workspaces + .create(client, state, binding, None, source) + .await?; + let session_id = &selected.session_id; + let cwd = &selected.binding.cwd; + apply_config(client, session_id, config.0, config.1, config.2).await?; let status = match open_session(client, session_id.clone(), cwd.clone()).await { Ok(opened) => opened.status, - Err(_) => session::config_status(&session_id, created.modes.as_ref(), &created_raw), + Err(_) => session::config_status(session_id, created.modes.as_ref(), &created_raw), }; - Ok(json!({ - "sessionId": session_id, - "cwd": cwd, - "status": status, - })) + let mut result = selected.result(); + result["status"] = status; + Ok(result) } async fn sessions( @@ -999,7 +1053,7 @@ async fn set_pinned( async fn inspect( client: &AcpClient, session_id: String, - cwd: Option, + cwd: PathBuf, full: bool, messages: Option, tools: bool, @@ -1008,7 +1062,6 @@ async fn inspect( if !initialized.agent_capabilities.load_session { return Err(CliError::rpc("agent does not support session/load")); } - let cwd = session::resolve_cwd(cwd).map_err(CliError::rpc)?; let (loaded, load_value) = client .load_session(session_id.clone(), cwd.clone()) .await @@ -1035,14 +1088,10 @@ async fn inspect( )) } -async fn status( - client: &AcpClient, - session_id: String, - cwd: Option, -) -> Result { +async fn status(client: &AcpClient, session_id: String, cwd: PathBuf) -> Result { client.initialize().await.map_err(CliError::from_request)?; - let cwd = session::resolve_cwd(cwd).map_err(CliError::rpc)?; - let opened = open_session(client, session_id.clone(), cwd).await?; + let mut opened = open_session(client, session_id.clone(), cwd.clone()).await?; + opened.status["cwd"] = json!(cwd); Ok(opened.status) } @@ -1114,6 +1163,7 @@ struct PromptArgs { async fn prompt( client: &AcpClient, state: &mut OperationState, + workspaces: &Workspaces, args: PromptArgs, ) -> Result { let PromptArgs { @@ -1129,28 +1179,39 @@ async fn prompt( until_idle, } = args; let initialized = client.initialize().await.map_err(CliError::from_request)?; - let cwd = session::resolve_cwd(cwd).map_err(CliError::rpc)?; - let session_id = if new { - let (created, _) = client - .new_session(cwd.clone(), profile.as_deref()) - .await - .map_err(CliError::from_request)?; - let session_id = created.session_id.to_string(); - state.session(&session_id, &cwd, true); - session_id + let selected = if new { + let binding = Binding { + cwd: workspaces.initial_cwd(cwd)?, + profile, + }; + let source = if binding.profile.is_some() { + "explicit" + } else { + "backend-default-unknown" + }; + workspaces + .create(client, state, binding, None, source) + .await? + .0 } else { let session_id = session_id .ok_or_else(|| CliError::Usage("prompt requires session_id or new=true".into()))?; - state.session(&session_id, &cwd, false); - open_session(client, session_id.clone(), cwd.clone()).await?; - session_id + workspaces + .select(client, state, Some(session_id), cwd, profile) + .await? }; + selected.checkpoint(state); + let session_id = selected.session_id.clone(); + let cwd = selected.binding.cwd.clone(); + if !selected.created { + open_session(client, session_id.clone(), cwd.clone()).await?; + } // Keep the session id observable even if configuration or the prompt later fails. client .emit(json!({ "type": "session", "sessionId": session_id, "cwd": cwd })) .await; apply_config(client, &session_id, mode, model, effort).await?; - let chosen = if new { + let chosen = if selected.created { Delivery::Prompt } else { choose_delivery(client, &session_id, delivery).await? @@ -1185,15 +1246,22 @@ async fn prompt( wait_until_idle(client, &session_id, Duration::from_secs(u32::MAX.into())).await?; } let text = client.take_assistant_text().await; - Ok(json!({ - "type": "done", - "sessionId": session_id, - "delivery": delivery_name(chosen), - "stopReason": stop_reason, - "text": text, - "submitted": submitted, - "loadSession": initialized.agent_capabilities.load_session, - })) + let mut result = selected.result(); + result.as_object_mut().unwrap().extend( + json!({ + "type": "done", + "sessionId": session_id, + "delivery": delivery_name(chosen), + "stopReason": stop_reason, + "text": text, + "submitted": submitted, + "loadSession": initialized.agent_capabilities.load_session, + }) + .as_object() + .unwrap() + .clone(), + ); + Ok(result) } fn delivery_name(delivery: Delivery) -> &'static str { @@ -1272,12 +1340,11 @@ async fn wait_until_idle_every( async fn follow( client: &AcpClient, session_id: String, - cwd: Option, + cwd: PathBuf, interval: u64, timeout: u64, ) -> Result { client.initialize().await.map_err(CliError::from_request)?; - let cwd = session::resolve_cwd(cwd).map_err(CliError::rpc)?; open_session(client, session_id.clone(), cwd.clone()).await?; client.clear_assistant_text().await; wait_until_idle_every( @@ -1289,14 +1356,22 @@ async fn follow( .await?; let mut text = client.take_assistant_text().await; if text.is_empty() - && let Ok(snapshot) = - inspect(client, session_id.clone(), Some(cwd), false, Some(1), false).await + && let Ok(snapshot) = inspect( + client, + session_id.clone(), + cwd.clone(), + false, + Some(1), + false, + ) + .await { text = session::last_assistant_text(&snapshot).unwrap_or_default(); } Ok(json!({ "type": "done", "sessionId": session_id, + "cwd": cwd, "stopReason": "idle", "text": text, })) @@ -1400,12 +1475,13 @@ async fn exec( client: &AcpClient, calls: Vec, state: &mut OperationState, + workspaces: &Workspaces, ) -> Result { state.batch = Some(BatchState::default()); for call in calls { state.batch.as_mut().expect("batch initialized").active_call = Some(call.name.clone()); let command = decode(&call.name, call.arguments).map_err(CliError::rpc)?; - let result = Box::pin(run(client, command, state, None)).await; + let result = Box::pin(run(client, command, state, None, workspaces)).await; let batch = state.batch.as_mut().expect("batch initialized"); batch.text_truncated |= result["textTruncated"] == true; if output::failed(&result) { diff --git a/src/lib.rs b/src/lib.rs index 78bfdd8..61372fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod commands; mod output; +mod workspace; pub use qmacp::policy; diff --git a/src/oauth.rs b/src/oauth.rs index 7bb3a89..c42ecda 100644 --- a/src/oauth.rs +++ b/src/oauth.rs @@ -496,7 +496,7 @@ mod tests { .as_array() .unwrap() .len(), - 25 + 26 ); current.lock().unwrap()["active"] = json!(false); let response = call(Some("active-token")).await.unwrap(); diff --git a/src/server.rs b/src/server.rs index b1661f1..c6262a8 100644 --- a/src/server.rs +++ b/src/server.rs @@ -45,6 +45,7 @@ impl BridgeConfig { pub struct QmcpServer { config: Arc, tools: Arc>, + workspaces: Arc, } impl QmcpServer { @@ -53,6 +54,7 @@ impl QmcpServer { Ok(Self { config: Arc::new(config), tools: Arc::new(tool_catalog()?), + workspaces: Arc::new(crate::workspace::Workspaces::new()?), }) } @@ -92,7 +94,7 @@ impl QmcpServer { let mut log = EventLog::default(); let mut state = OperationState::default(); let result = { - let operation = commands::run(&client, command, &mut state, deadline); + let operation = commands::run(&client, command, &mut state, deadline, &self.workspaces); tokio::pin!(operation); loop { tokio::select! { @@ -117,7 +119,7 @@ impl ServerHandler for QmcpServer { fn get_info(&self) -> ServerConfig { ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) .with_server_info(Implementation::new("qmcp", env!("CARGO_PKG_VERSION"))) - .with_instructions("Control an existing QueryMT ACP WebSocket server. Discover capabilities, profiles, models, and session ids rather than inventing values. Keep returned sessionId values. Pass cwd for session operations. Timeouts and MCP cancellation do not cancel remote work: check runtime before retrying. Only close sessions when explicitly requested. Streaming ACP events are collected in each tool result.") + .with_instructions("Control an existing QueryMT ACP WebSocket server. Discover capabilities, profiles, models, and session ids rather than inventing values. Keep returned sessionId values and pass them as session_id; there is no global active workspace. Use workspace with an explicit initial cwd to select without prompting. cwd is sticky per session across calls. workspace or prompt with a different cwd creates a NEW QueryMT session; use its new sessionId, not previousSessionId. Read-only status/inspect/follow never rebind or create sessions. Timeouts and MCP cancellation do not cancel remote work: check runtime before retrying. Only close sessions when explicitly requested. Streaming ACP events are collected in each tool result.") } async fn list_tools( @@ -196,10 +198,13 @@ fn tool_catalog() -> Result> { name.as_str(), "set_lifecycle" | "pin" | "unpin" | "set_mode" | "set_model" | "set_effort" ); - let annotations = ToolAnnotations::new() + let mut annotations = ToolAnnotations::new() .read_only(read_only) .idempotent(idempotent) .open_world(true); + if name == "workspace" { + annotations = annotations.destructive(false); + } tools.push(Tool::new(name, description, input).with_annotations(annotations)); } if tools.is_empty() { @@ -274,6 +279,9 @@ mod tests { json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true, "sessionCapabilities": {"resume": {}}}}) } "session/resume" | "session/load" => json!({}), + "session/list" => { + json!({"sessions": [{"sessionId": "fixture-session", "cwd": "/workspace"}]}) + } "_querymt/session/runtimeState" if input_id.is_null() => { json!({"phase": "tools", "steerable": true}) } @@ -335,6 +343,100 @@ mod tests { assert_eq!(result["recovery"]["submission"]["clientInputId"], input_id); } + #[tokio::test] + async fn cancelled_creation_keeps_workspace_binding_across_server_clones() { + use futures_util::{SinkExt, StreamExt}; + use std::time::Duration; + use tokio_tungstenite::{accept_async, tungstenite::Message}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}/acp/ws", listener.local_addr().unwrap()); + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let peer = tokio::spawn(async move { + let mut ready = Some(ready_tx); + for _ in 0..2 { + let (socket, _) = listener.accept().await.unwrap(); + let mut socket = accept_async(socket).await.unwrap(); + while let Some(Ok(message)) = socket.next().await { + let Message::Text(text) = message else { + continue; + }; + let request: Value = serde_json::from_str(&text).unwrap(); + let result = match request["method"].as_str().unwrap() { + "initialize" => { + json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true, "sessionCapabilities": {"resume": {}}}}) + } + "session/new" => { + assert_eq!(request["params"]["cwd"], "/remote/cancelled-creation"); + json!({"sessionId": "created-before-cancellation"}) + } + "session/set_mode" => { + // Creation was acknowledged and configuration now stalls. + ready.take().unwrap().send(()).unwrap(); + continue; + } + "session/resume" | "session/load" => { + assert_eq!(request["params"]["cwd"], "/remote/cancelled-creation"); + assert_eq!( + request["params"]["sessionId"], + "created-before-cancellation" + ); + json!({}) + } + _ => panic!( + "no rediscovery, implicit cancel, close, or extra creation: {request}" + ), + }; + socket + .send(Message::Text( + json!({"jsonrpc": "2.0", "id": request["id"], "result": result}) + .to_string() + .into(), + )) + .await + .unwrap(); + } + } + }); + let server = + QmcpServer::new(BridgeConfig::new(&url, false, PermissionPolicy::Cancel).unwrap()) + .unwrap(); + let clone = server.clone(); + assert!(Arc::ptr_eq(&server.workspaces, &clone.workspaces)); + let command = commands::decode("prompt", json!({"new": true, "cwd": "/remote/cancelled-creation", "text": "not sent", "mode": "stall"}).as_object().unwrap().clone()).unwrap(); + let result = tokio::time::timeout( + Duration::from_secs(2), + server.execute_with_cancellation(command, async { + ready_rx.await.unwrap(); + }), + ) + .await + .unwrap(); + assert_eq!(result["ok"], false, "{result}"); + assert_eq!( + result["recovery"]["sessionId"], + "created-before-cancellation" + ); + assert_eq!(result["recovery"]["sessionCreated"], true); + let command = commands::decode( + "status", + json!({"session_id": "created-before-cancellation"}) + .as_object() + .unwrap() + .clone(), + ) + .unwrap(); + let result = clone + .execute_with_cancellation(command, std::future::pending()) + .await; + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["cwd"], "/remote/cancelled-creation"); + tokio::time::timeout(Duration::from_secs(2), peer) + .await + .unwrap() + .unwrap(); + } + #[test] fn advertised_schemas_match_decoder_boundaries() { let tools = tool_catalog().unwrap(); @@ -453,6 +555,23 @@ mod tests { ] { check("exec", json!({"calls": [entry]})); } + for arguments in [ + json!({}), + json!({"session_id": null}), + json!({"cwd": null}), + json!({"cwd": "/remote"}), + json!({"session_id": "s"}), + json!({"session_id": "s", "cwd": null}), + json!({"session_id": "s", "cwd": "/remote", "profile": "p"}), + json!({"session_id": "s", "extra": true}), + ] { + check("workspace", arguments); + } + check( + "exec", + json!({"calls": [{"name": "workspace", "arguments": {"cwd": "/remote"}}]}), + ); + check("exec", json!({"calls": [{"name": "workspace"}]})); check("prompt", json!({"new": true, "text": "x", "extra": true})); check("caps", json!({"extra": true})); } @@ -460,7 +579,7 @@ mod tests { #[test] fn catalog_contains_self_contained_object_schemas_and_native_prompt_fields() { let tools = tool_catalog().unwrap(); - assert_eq!(tools.len(), 25); + assert_eq!(tools.len(), 26); for tool in &tools { assert_eq!(tool.input_schema["type"], "object"); assert!( @@ -475,6 +594,11 @@ mod tests { .contains("\"$ref\"") ); } + let workspace = tools.iter().find(|t| t.name == "workspace").unwrap(); + let annotations = json!(workspace.annotations); + assert_eq!(annotations["readOnlyHint"], false); + assert_eq!(annotations["destructiveHint"], false); + assert_eq!(annotations["idempotentHint"], false); let prompt = tools.iter().find(|t| t.name == "prompt").unwrap(); assert_eq!(prompt.input_schema["properties"]["text"]["type"], "string"); assert_eq!( diff --git a/src/workspace.rs b/src/workspace.rs new file mode 100644 index 0000000..a8b31e7 --- /dev/null +++ b/src/workspace.rs @@ -0,0 +1,724 @@ +//! Process-local, immutable session workspace bindings. No lock spans an ACP request. +use std::{ + collections::{HashMap, HashSet}, + path::{Component, Path, PathBuf}, + sync::Mutex, +}; + +use qmacp::{client::AcpClient, error::CliError, protocol::schema::v1 as acp, session}; +use serde_json::{Value, json}; + +use crate::output::OperationState; + +const CAPACITY: usize = 4096; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Binding { + pub cwd: PathBuf, + pub profile: Option, +} + +struct CachedBinding { + binding: Binding, + // Entries are never removed: insertion order is bounded by store capacity, + // not by the (unbounded) number of operations or cancelled reservations. + generation: usize, + creation_claimed: bool, +} + +#[derive(Default)] +struct Store { + bindings: HashMap, + reserved: usize, +} + +pub(crate) struct Workspaces { + startup_cwd: PathBuf, + store: Mutex, + capacity: usize, +} + +pub(crate) struct Selection { + pub session_id: String, + pub binding: Binding, + pub created: bool, + pub previous: Option, + profile_source: &'static str, +} + +impl Selection { + pub fn result(&self) -> Value { + let mut value = json!({ + "sessionId": self.session_id, "cwd": self.binding.cwd, + "created": self.created, "switched": self.previous.is_some(), + "profile": self.binding.profile, "profileKnown": self.binding.profile.is_some(), + "profileSource": self.profile_source, + }); + if let Some(previous) = &self.previous { + value["previousSessionId"] = json!(previous); + } + value + } + + pub fn checkpoint(&self, state: &mut OperationState) { + state.session(&self.session_id, &self.binding.cwd, self.created); + state + .recovery + .extend(self.result().as_object().unwrap().clone()); + } +} + +// Capacity is reserved before session/new, released on rejection or cancellation, +// and committed synchronously on acknowledgement, before the next await point. +struct Reservation<'a> { + workspaces: &'a Workspaces, + generation: usize, +} + +impl Drop for Reservation<'_> { + fn drop(&mut self) { + self.workspaces.store.lock().unwrap().reserved -= 1; + } +} + +impl Workspaces { + pub fn new() -> anyhow::Result { + let startup = std::env::current_dir()?; + Ok(Self { + startup_cwd: normalize(&startup, &startup).map_err(anyhow::Error::msg)?, + store: Mutex::new(Store::default()), + capacity: CAPACITY, + }) + } + + pub fn initial_cwd(&self, cwd: Option) -> Result { + normalize( + cwd.as_deref().unwrap_or(&self.startup_cwd), + &self.startup_cwd, + ) + .map_err(CliError::Usage) + } + + fn reserve(&self) -> Result, CliError> { + let mut store = self.store.lock().unwrap(); + if store.bindings.len() + store.reserved >= self.capacity { + return Err(CliError::rpc( + "workspace binding capacity reached; no session was created", + )); + } + store.reserved += 1; + Ok(Reservation { + workspaces: self, + generation: store.bindings.len(), + }) + } + + fn insert( + &self, + id: &str, + binding: Binding, + reservation: Option<&Reservation<'_>>, + ) -> Result { + let mut store = self.store.lock().unwrap(); + if let Some(known) = store.bindings.get_mut(id) { + if reservation + .is_some_and(|r| known.generation < r.generation || known.creation_claimed) + { + return Err(CliError::rpc( + "session/new returned an already bound session id; refusing to alias or overwrite the existing workspace", + )); + } + if known.binding.cwd != binding.cwd + || matches!((&known.binding.profile, &binding.profile), (Some(a), Some(b)) if a != b) + { + return Err(CliError::rpc( + "backend returned conflicting workspace metadata for an immutable session id; binding was not changed", + )); + } + if known.binding.profile.is_none() { + known.binding.profile = binding.profile; + } + // Only matching authoritative metadata first cached after this + // reservation may be claimed. A second ACK cannot claim it again. + known.creation_claimed |= reservation.is_some(); + return Ok(known.binding.clone()); + } + if reservation.is_none() && store.bindings.len() + store.reserved >= self.capacity { + return Err(CliError::rpc("workspace binding capacity reached")); + } + let generation = store.bindings.len(); + store.bindings.insert( + id.to_owned(), + CachedBinding { + binding: binding.clone(), + generation, + creation_claimed: reservation.is_some(), + }, + ); + Ok(binding) + } + + pub async fn binding(&self, client: &AcpClient, id: &str) -> Result { + if let Some(known) = self.store.lock().unwrap().bindings.get(id) { + return Ok(known.binding.clone()); + } + client.initialize().await.map_err(CliError::from_request)?; + let mut cursor = None; + let mut seen = HashSet::new(); + for _ in 0..session::find_page_cap() { + // Equivalent to list_sessions(None, cursor, None): no cwd or metadata + // filters. Retain raw JSON because ACP's typed list decoder can omit + // malformed summaries (including a target without cwd). + let mut request = acp::ListSessionsRequest::new(); + if let Some(cursor) = cursor { + request = request.cursor(cursor); + } + let (_page, raw) = client.request_json(request).await.map_err(|error| { + CliError::rpc(format!("authoritative workspace metadata unavailable from session/list: {error}; refusing to guess cwd or create a session")) + })?; + let next_cursor = match raw.get("nextCursor") { + None | Some(Value::Null) => None, + Some(Value::String(next)) => Some(next.clone()), + Some(_) => { + return Err(CliError::rpc( + "authoritative workspace metadata unavailable: session/list nextCursor must be a string or null; discovery is incomplete, not an exhaustive not-found result", + )); + } + }; + let summaries = raw.get("sessions").and_then(Value::as_array).ok_or_else(|| CliError::rpc("authoritative workspace metadata unavailable: session/list has no sessions array"))?; + for item in summaries { + if item.get("sessionId").and_then(Value::as_str) != Some(id) { + continue; + } + let cwd = item.get("cwd").and_then(Value::as_str).map(PathBuf::from).ok_or_else(|| CliError::rpc("authoritative workspace metadata unavailable: target session cwd is absent; refusing to guess cwd (even from an explicit caller path)"))?; + if !cwd.is_absolute() { + return Err(CliError::rpc( + "authoritative session cwd is missing or not absolute; refusing to guess cwd (even from an explicit caller path)", + )); + } + let cwd = normalize(&cwd, &self.startup_cwd).map_err(|error| { + CliError::rpc(format!("authoritative session cwd is invalid: {error}")) + })?; + let profile = item + .pointer("/_meta/querymt/profile_id") + .and_then(Value::as_str) + .map(str::to_owned); + return self.insert(id, Binding { cwd, profile }, None); + } + match next_cursor { + None => { + return Err(CliError::rpc(format!( + "session {id} not found in authoritative session/list; no workspace was selected or created" + ))); + } + Some(next) if !seen.insert(next.clone()) => { + return Err(CliError::rpc( + "workspace metadata discovery incomplete: session/list repeated a cursor; no workspace was selected or created", + )); + } + Some(next) => cursor = Some(next), + } + } + Err(CliError::rpc( + "workspace metadata discovery incomplete: session/list page cap reached; session may exist beyond the scan; no workspace was selected or created", + )) + } + + pub async fn read_cwd( + &self, + client: &AcpClient, + id: &str, + cwd: Option, + ) -> Result { + let binding = self.binding(client, id).await?; + if let Some(cwd) = cwd { + let requested = normalize(&cwd, &binding.cwd).map_err(CliError::Usage)?; + if requested != binding.cwd { + return Err(CliError::Usage("read-only session operations cannot change cwd; use workspace with this session_id and cwd to create a NEW session, then use its returned sessionId".into())); + } + } + Ok(binding.cwd) + } + + pub async fn create( + &self, + client: &AcpClient, + state: &mut OperationState, + binding: Binding, + previous: Option, + profile_source: &'static str, + ) -> Result<(Selection, acp::NewSessionResponse, Value), CliError> { + let reservation = self.reserve()?; + client.initialize().await.map_err(CliError::from_request)?; + let (created, raw) = client + .new_session(binding.cwd.clone(), binding.profile.as_deref()) + .await + .map_err(CliError::from_request)?; + let session_id = created.session_id.to_string(); + let binding = match self.insert(&session_id, binding, Some(&reservation)) { + Ok(binding) => binding, + Err(error) => { + state.recovery.insert( + "workspaceConflict".into(), + json!({ + "acknowledgedSessionId": session_id, + "selectionCommitted": false, + }), + ); + return Err(error); + } + }; + let profile_source = + if binding.profile.is_some() && profile_source == "backend-default-unknown" { + "authoritative-metadata" + } else { + profile_source + }; + let selection = Selection { + session_id, + binding, + created: true, + previous, + profile_source, + }; + selection.checkpoint(state); + drop(reservation); + Ok((selection, created, raw)) + } + + pub async fn select( + &self, + client: &AcpClient, + state: &mut OperationState, + id: Option, + cwd: Option, + profile: Option, + ) -> Result { + let Some(id) = id else { + let cwd = cwd.ok_or_else(|| { + CliError::Usage( + "workspace requires an explicit initial cwd when session_id is omitted".into(), + ) + })?; + let binding = Binding { + cwd: self.initial_cwd(Some(cwd))?, + profile, + }; + let source = if binding.profile.is_some() { + "explicit" + } else { + "backend-default-unknown" + }; + return self + .create(client, state, binding, None, source) + .await + .map(|created| created.0); + }; + let binding = self.binding(client, &id).await?; + let requested = cwd + .map(|cwd| normalize(&cwd, &binding.cwd).map_err(CliError::Usage)) + .transpose()? + .unwrap_or(binding.cwd.clone()); + if requested == binding.cwd { + if profile + .as_ref() + .is_some_and(|profile| Some(profile) != binding.profile.as_ref()) + { + return Err(CliError::Usage("profile changes on an existing session are not supported by workspace/prompt; the requested profile differs or the existing profile is unknown; create a new workspace without session_id to select a profile".into())); + } + return Ok(Selection { + session_id: id, + binding, + created: false, + previous: None, + profile_source: "remembered-or-metadata", + }); + } + let source = if profile.is_some() { + "explicit" + } else if binding.profile.is_some() { + "inherited" + } else { + "backend-default-unknown" + }; + let binding = Binding { + cwd: requested, + profile: profile.or(binding.profile), + }; + self.create(client, state, binding, Some(id), source) + .await + .map(|created| created.0) + } +} + +/// POSIX lexical paths on the ACP host, not filesystem canonicalization on qmcp. +fn normalize(path: &Path, base: &Path) -> Result { + let text = path.to_str().ok_or("cwd must be valid UTF-8")?; + if text.is_empty() || text.chars().any(char::is_control) { + return Err("cwd must not be empty or contain control characters".into()); + } + if text.starts_with('~') { + return Err("cwd cannot use ~ expansion on a remote host; provide an explicit absolute path on the QueryMT host".into()); + } + if text.contains('\\') || text.as_bytes().get(1) == Some(&b':') || text.starts_with("//") { + return Err("cwd must be an unambiguous POSIX path, not a Windows drive/UNC path".into()); + } + let joined = if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + }; + let mut result = PathBuf::new(); + for component in joined.components() { + match component { + Component::RootDir => result.push("/"), + Component::CurDir => {} + Component::ParentDir => { + result.pop(); + } + Component::Normal(name) => result.push(name), + Component::Prefix(_) => return Err("cwd must be a POSIX path".into()), + } + } + if !result.is_absolute() { + return Err("cwd must resolve to an absolute POSIX path".into()); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_are_lexical_and_do_not_require_a_local_filesystem() { + let base = Path::new("/remote/nonexistent/project"); + for (input, expected) in [ + (".", "/remote/nonexistent/project"), + ("a/../b", "/remote/nonexistent/project/b"), + ("../other", "/remote/nonexistent/other"), + ("/a/./b/../../..", "/"), + ] { + assert_eq!( + normalize(Path::new(input), base).unwrap(), + Path::new(expected) + ); + } + for input in [ + "", + "~", + "~/project", + "~user/project", + "C:", + "C:/work", + "a\nb", + "a\0b", + "//host/work", + "C:\\work", + ] { + assert!(normalize(Path::new(input), base).is_err(), "{input:?}"); + } + } + + #[test] + fn startup_relative_paths_and_concurrent_initial_bindings_are_stable() { + let workspaces = Workspaces::new().unwrap(); + let expected = + normalize(Path::new("relative/../project"), &workspaces.startup_cwd).unwrap(); + assert_eq!( + workspaces + .initial_cwd(Some("relative/../project".into())) + .unwrap(), + expected + ); + assert_eq!( + workspaces.initial_cwd(None).unwrap(), + workspaces.startup_cwd + ); + let outcomes = std::thread::scope(|scope| { + let one = scope.spawn(|| { + workspaces.insert( + "same-id", + Binding { + cwd: "/one".into(), + profile: None, + }, + None, + ) + }); + let two = scope.spawn(|| { + workspaces.insert( + "same-id", + Binding { + cwd: "/two".into(), + profile: None, + }, + None, + ) + }); + [one.join().unwrap(), two.join().unwrap()] + }); + assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!(workspaces.store.lock().unwrap().bindings.len(), 1); + } + + #[tokio::test] + async fn full_store_fails_before_any_new_session_request() { + use futures_util::StreamExt; + use qmacp::policy::PermissionPolicy; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}/acp/ws", listener.local_addr().unwrap()); + let peer = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(stream).await.unwrap(); + while let Some(Ok(message)) = socket.next().await { + assert!( + !message.is_text(), + "a full workspace store must not send any ACP request" + ); + } + }); + let (events, _receiver) = tokio::sync::mpsc::channel(16); + let client = AcpClient::connect_with_events(&url, &url, PermissionPolicy::Cancel, events) + .await + .unwrap(); + let mut workspaces = Workspaces::new().unwrap(); + workspaces.capacity = 0; + let mut state = OperationState::default(); + let result = workspaces + .create( + &client, + &mut state, + Binding { + cwd: "/remote".into(), + profile: None, + }, + None, + "backend-default-unknown", + ) + .await; + assert!(matches!(result, Err(error) if error.to_string().contains("capacity"))); + assert!(state.recovery.is_empty()); + drop(client); + tokio::time::timeout(std::time::Duration::from_secs(2), peer) + .await + .unwrap() + .unwrap(); + } + + #[test] + fn reservation_generations_reconcile_once_and_refund_at_full_capacity() { + let mut workspaces = Workspaces::new().unwrap(); + workspaces.capacity = 3; + let binding = Binding { + cwd: "/remote".into(), + profile: Some("inherited".into()), + }; + let first = workspaces.reserve().unwrap(); + let second = workspaces.reserve().unwrap(); + workspaces + .insert( + "fresh", + Binding { + profile: None, + ..binding.clone() + }, + None, + ) + .unwrap(); + assert!(workspaces.reserve().is_err()); + assert_eq!( + workspaces + .insert("fresh", binding.clone(), Some(&first)) + .unwrap(), + binding + ); + drop(first); + assert!( + workspaces + .insert("fresh", binding.clone(), Some(&second)) + .is_err() + ); + drop(second); + { + let store = workspaces.store.lock().unwrap(); + assert_eq!(store.reserved, 0); + assert_eq!(store.bindings.len(), 1); + } + let reuse = workspaces.reserve().unwrap(); + let spare = workspaces.reserve().unwrap(); + assert!(workspaces.reserve().is_err()); + drop(reuse); + drop(spare); + // An ID discovered before the reservation is never a fresh creation, + // even when cwd and profile are identical and it is metadata-only. + workspaces + .insert("prebound", binding.clone(), None) + .unwrap(); + let reservation = workspaces.reserve().unwrap(); + assert!( + workspaces + .insert("prebound", binding, Some(&reservation)) + .is_err() + ); + drop(reservation); + assert_eq!(workspaces.store.lock().unwrap().reserved, 0); + } + + #[test] + fn conflicting_metadata_does_not_mutate_bindings_or_consume_creation_claim() { + let workspaces = Workspaces::new().unwrap(); + let reservation = workspaces.reserve().unwrap(); + let binding = Binding { + cwd: "/remote".into(), + profile: Some("authoritative".into()), + }; + workspaces.insert("fresh", binding.clone(), None).unwrap(); + for conflict in [ + Binding { + cwd: "/wrong".into(), + ..binding.clone() + }, + Binding { + profile: Some("wrong".into()), + ..binding.clone() + }, + ] { + assert!( + workspaces + .insert("fresh", conflict, Some(&reservation)) + .is_err() + ); + let store = workspaces.store.lock().unwrap(); + assert_eq!(store.bindings["fresh"].binding, binding); + assert!(!store.bindings["fresh"].creation_claimed); + } + assert_eq!( + workspaces + .insert( + "fresh", + Binding { + profile: None, + ..binding.clone() + }, + Some(&reservation) + ) + .unwrap(), + binding + ); + drop(reservation); + assert_eq!(workspaces.store.lock().unwrap().reserved, 0); + } + + #[tokio::test] + async fn cancellation_while_awaiting_creation_ack_releases_capacity_without_inventing_an_id() { + use futures_util::{SinkExt, StreamExt}; + use qmacp::policy::PermissionPolicy; + use tokio_tungstenite::tungstenite::Message; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("ws://{}/acp/ws", listener.local_addr().unwrap()); + let (ready, observed) = tokio::sync::oneshot::channel(); + let peer = tokio::spawn(async move { + let mut ready = Some(ready); + for attempt in 0..2 { + let (socket, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_async(socket).await.unwrap(); + while let Some(Ok(message)) = socket.next().await { + let Message::Text(text) = message else { continue }; + let request: Value = serde_json::from_str(&text).unwrap(); + let result = match request["method"].as_str().unwrap() { + "initialize" => json!({"protocolVersion": 1, "agentCapabilities": {}}), + "session/new" if attempt == 0 => { + // The backend may have created an orphan, but no ID is acknowledged. + ready.take().unwrap().send(()).unwrap(); + continue; + } + "session/new" => json!({"sessionId": "acknowledged"}), + _ => panic!("no implicit remote cancellation, close, load or prompt: {request}"), + }; + socket.send(Message::Text(json!({"jsonrpc": "2.0", "id": request["id"], "result": result}).to_string().into())).await.unwrap(); + } + } + }); + let mut workspaces = Workspaces::new().unwrap(); + workspaces.capacity = 1; + let mut state = OperationState::default(); + let binding = Binding { cwd: "/remote".into(), profile: None }; + let (events, _receiver) = tokio::sync::mpsc::channel(16); + let client = AcpClient::connect_with_events(&url, &url, PermissionPolicy::Cancel, events).await.unwrap(); + { + let operation = workspaces.create(&client, &mut state, binding.clone(), None, "backend-default-unknown"); + tokio::pin!(operation); + tokio::select! { + result = &mut operation => panic!("unexpected creation ACK: {}", result.is_ok()), + _ = observed => {}, + } + } + assert!(state.recovery.is_empty()); + { + let store = workspaces.store.lock().unwrap(); + assert!(store.bindings.is_empty()); + assert_eq!(store.reserved, 0); + } + drop(client); + let (events, _receiver) = tokio::sync::mpsc::channel(16); + let client = AcpClient::connect_with_events(&url, &url, PermissionPolicy::Cancel, events).await.unwrap(); + workspaces.create(&client, &mut state, binding, Some("previous-valid".into()), "backend-default-unknown").await.unwrap(); + assert_eq!(state.recovery["sessionId"], "acknowledged"); + assert_eq!(state.recovery["sessionCreated"], true); + assert_eq!(workspaces.store.lock().unwrap().reserved, 0); + // A subsequent invalid ACK must not overwrite any prior valid checkpoint. + workspaces.capacity = 2; + let prior = state.recovery.clone(); + let error = workspaces.create(&client, &mut state, + Binding { cwd: "/fake-new".into(), profile: None }, + Some("fake-previous".into()), "backend-default-unknown").await.err().unwrap(); + let failed = state.finish(crate::output::failure(error)); + for (key, value) in prior { + assert_eq!(failed["recovery"][&key], value); + } + assert_eq!(failed["recovery"]["previousSessionId"], "previous-valid"); + assert_ne!(failed["recovery"]["previousSessionId"], "fake-previous"); + assert_eq!(failed["recovery"]["cwd"], "/remote"); + assert_eq!(failed["recovery"]["workspaceConflict"]["acknowledgedSessionId"], "acknowledged"); + assert_eq!(workspaces.store.lock().unwrap().reserved, 0); + drop(client); + peer.await.unwrap(); + }).await.expect("cancellation and capacity reuse must complete without waiting for an ACK"); + } + + #[test] + fn reservations_bound_capacity_and_release_on_drop() { + let mut workspaces = Workspaces::new().unwrap(); + workspaces.capacity = 1; + let reservation = workspaces.reserve().unwrap(); + assert!(workspaces.reserve().is_err()); + drop(reservation); + let reservation = workspaces.reserve().unwrap(); + workspaces + .insert( + "s", + Binding { + cwd: "/remote".into(), + profile: None, + }, + Some(&reservation), + ) + .unwrap(); + drop(reservation); + assert!(workspaces.reserve().is_err()); + assert!( + workspaces + .insert( + "s", + Binding { + cwd: "/wrong".into(), + profile: None + }, + None + ) + .is_err() + ); + } +} diff --git a/tests/bridge.rs b/tests/bridge.rs index 0cd74d7..a73d61e 100644 --- a/tests/bridge.rs +++ b/tests/bridge.rs @@ -1,4 +1,5 @@ use std::{ + collections::VecDeque, path::PathBuf, process::Stdio, sync::{ @@ -16,6 +17,7 @@ use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines}, net::{TcpListener, TcpStream}, process::{Child, ChildStdin, ChildStdout}, + sync::oneshot, task::{JoinHandle, JoinSet}, }; use tokio_tungstenite::{WebSocketStream, accept_async, tungstenite::Message}; @@ -25,6 +27,21 @@ struct MockAcp { requests: Arc>>, active: Arc, task: JoinHandle<()>, + workspace: Arc>, +} + +#[derive(Default)] +struct MockWorkspace { + created: Vec, + listing: Option>, + reject_creation: bool, + forced_id: Option, + creation_gates: VecDeque, +} + +struct CreationGate { + observed: oneshot::Sender<()>, + release: oneshot::Receiver<()>, } #[derive(Clone, Copy, Default)] @@ -74,6 +91,8 @@ impl MockAcp { let requests = Arc::new(Mutex::new(Vec::new())); let active = Arc::new(AtomicUsize::new(0)); let recorded = requests.clone(); + let workspace = Arc::new(Mutex::new(MockWorkspace::default())); + let workspaces = workspace.clone(); let connections = active.clone(); let task = tokio::spawn(async move { let mut tasks = JoinSet::new(); @@ -84,12 +103,13 @@ impl MockAcp { let recorded = recorded.clone(); let active = connections.clone(); let payload = extension_payload.clone(); + let workspace = workspaces.clone(); tasks.spawn(async move { active.fetch_add(1, Ordering::SeqCst); let _guard = ConnectionGuard(active); tokio::time::sleep(delay).await; let socket = accept_async(stream).await.unwrap(); - serve_acp(socket, recorded, behavior, listing_pages, payload).await; + serve_acp(socket, recorded, behavior, listing_pages, payload, workspace).await; }); } Some(result) = tasks.join_next(), if !tasks.is_empty() => { result.unwrap(); } @@ -101,6 +121,7 @@ impl MockAcp { requests, active, task, + workspace, } } @@ -144,6 +165,7 @@ async fn serve_acp( behavior: SubmissionBehavior, listing_pages: Option, extension_payload: Option, + workspace: Arc>, ) { let mut submitted = false; while let Some(Ok(message)) = socket.next().await { @@ -177,6 +199,38 @@ async fn serve_acp( .await; continue; } + if method == "session/new" && workspace.lock().unwrap().reject_creation { + send(&mut socket, json!({"jsonrpc": "2.0", "id": id, "error": {"code": -32000, "message": "creation rejected"}})).await; + continue; + } + if method == "session/list" { + let listing = workspace.lock().unwrap().listing.clone(); + if let Some(pages) = listing { + assert!( + params.get("cwd").is_none(), + "workspace discovery must be unfiltered" + ); + assert!( + params.get("_meta").is_none(), + "workspace discovery must include all lifecycles" + ); + let page = params["cursor"] + .as_str() + .and_then(|cursor| cursor.strip_prefix("page-")) + .and_then(|n| n.parse::().ok()) + .unwrap_or(1) + - 1; + let reply = &pages[page.min(pages.len() - 1)]; + let mut response = json!({"jsonrpc": "2.0", "id": id}); + response[if reply.get("error").is_some() { + "error" + } else { + "result" + }] = reply.get("error").unwrap_or(reply).clone(); + send(&mut socket, response).await; + continue; + } + } let result = match method { "initialize" => { json!({"protocolVersion": 1, "agentInfo": {"name": "fixture", "version": "1"}, "agentCapabilities": {"loadSession": true, "sessionCapabilities": {"list": {}, "resume": {}, "close": {}}}}) @@ -185,7 +239,20 @@ async fn serve_acp( json!({"features": {"session_lifecycle": true, "session_pins": true}, "methods": ["querymt/session/getLifecycle", "querymt/session/setLifecycle", "querymt/session/setPinned"]}) } "_querymt/profiles" => json!({"profiles": [{"id": "fixture-profile"}]}), - "session/new" => json!({"sessionId": "fixture-session"}), + "session/new" => { + let mut workspace = workspace.lock().unwrap(); + let number = workspace.created.len() + 1; + let session_id = workspace.forced_id.clone().unwrap_or_else(|| { + if number == 1 { + "fixture-session".to_owned() + } else { + format!("fixture-session-{number}") + } + }); + workspace.created.push(json!({"sessionId": session_id, "cwd": params["cwd"], "_meta": params["_meta"]})); + json!({"sessionId": session_id}) + } + "session/close" => json!({}), "session/resume" | "session/load" => { json!({"configOptions": [{"id": "model", "name": "Model", "type": "select", "currentValue": "fixture-model", "options": [{"value": "fixture-model", "name": "Fixture"}]}]}) } @@ -210,7 +277,8 @@ async fn serve_acp( } else if params["cursor"] == "page-2" { json!({"sessions": [{"sessionId": "second-session", "cwd": "/workspace", "title": "Second"}]}) } else { - json!({"sessions": [{"sessionId": "fixture-session", "cwd": "/workspace", "title": "Fixture"}], "nextCursor": "page-2"}) + let created = workspace.lock().unwrap().created.clone(); + json!({"sessions": if created.is_empty() { vec![json!({"sessionId": "fixture-session", "cwd": "/workspace", "title": "Fixture"})] } else { created }, "nextCursor": "page-2"}) } } "_querymt/session/runtimeState" => { @@ -247,7 +315,7 @@ async fn serve_acp( continue; } let permission = acp::RequestPermissionRequest::new( - "fixture-session", + params["sessionId"].as_str().unwrap().to_owned(), acp::ToolCallUpdate::new("tool-1", acp::ToolCallUpdateFields::new()), vec![acp::PermissionOption::new( "allow", @@ -267,7 +335,7 @@ async fn serve_acp( } else { format!("reply:{text}") }; - send(&mut socket, json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": "fixture-session", "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": reply}}}})).await; + send(&mut socket, json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": params["sessionId"], "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": reply}}}})).await; json!({"stopReason": if text == "cancelled" { "cancelled" } else { "end_turn" }}) } _ => { @@ -275,6 +343,13 @@ async fn serve_acp( continue; } }; + if method == "session/new" { + let gate = workspace.lock().unwrap().creation_gates.pop_front(); + if let Some(gate) = gate { + gate.observed.send(()).unwrap(); + gate.release.await.unwrap(); + } + } send( &mut socket, json!({"jsonrpc": "2.0", "id": id, "result": result}), @@ -427,11 +502,797 @@ impl Drop for Mcp { } } +#[tokio::test] +async fn workspace_is_sticky_across_calls_and_switches_without_touching_old_sessions() { + for http in [false, true] { + let backend = MockAcp::start().await; + let mut mcp = Mcp::start(&backend.url, http).await; + let missing = mcp.tool("workspace", json!({})).await; + assert_eq!(missing["isError"], true); + assert!(backend.recorded().is_empty()); + let created = mcp.tool("workspace", json!({"cwd": "/remote/not-on-qmcp/project/./sub/..", "profile": "fixture-profile"})).await; + assert_eq!(created["isError"], false, "{created}"); + let first = created["structuredContent"]["sessionId"] + .as_str() + .unwrap() + .to_owned(); + assert_eq!(created["structuredContent"]["created"], true); + assert_eq!(created["structuredContent"]["switched"], false); + assert_eq!( + created["structuredContent"]["cwd"], + "/remote/not-on-qmcp/project" + ); + assert!( + !backend + .recorded() + .iter() + .any(|r| r["method"] == "session/prompt") + ); + for cwd in [ + None, + Some("."), + Some("sub/.."), + Some("/remote/not-on-qmcp/project"), + ] { + let mut args = json!({"session_id": first, "profile": "fixture-profile"}); + if let Some(cwd) = cwd { + args["cwd"] = json!(cwd); + } + let selected = mcp.tool("workspace", args).await; + assert_eq!(selected["isError"], false, "{selected}"); + assert_eq!(selected["structuredContent"]["sessionId"], first); + assert_eq!(selected["structuredContent"]["created"], false); + assert_eq!(selected["structuredContent"]["switched"], false); + } + let mismatch = mcp + .tool( + "workspace", + json!({"session_id": first, "profile": "other"}), + ) + .await; + assert_eq!(mismatch["isError"], true); + assert!( + mismatch["structuredContent"]["error"] + .as_str() + .unwrap() + .contains("profile changes") + ); + let prompted = mcp + .tool("prompt", json!({"session_id": first, "text": "continue"})) + .await; + assert_eq!(prompted["isError"], false, "{prompted}"); + assert_eq!(prompted["structuredContent"]["sessionId"], first); + assert_eq!( + prompted["structuredContent"]["cwd"], + "/remote/not-on-qmcp/project" + ); + for name in ["status", "inspect", "follow"] { + let result = mcp.tool(name, json!({"session_id": first})).await; + assert_eq!(result["isError"], false, "{name}: {result}"); + assert_eq!( + result["structuredContent"]["cwd"], + "/remote/not-on-qmcp/project" + ); + let before = backend.recorded().len(); + let rejected = mcp + .tool(name, json!({"session_id": first, "cwd": "/wrong"})) + .await; + assert_eq!(rejected["isError"], true, "{name}: {rejected}"); + assert!( + rejected["structuredContent"]["error"] + .as_str() + .unwrap() + .contains("use workspace") + ); + assert!(!backend.recorded()[before..].iter().any(|r| matches!( + r["method"].as_str(), + Some("session/load" | "session/resume" | "session/new") + ))); + } + let switched = mcp + .tool("workspace", json!({"session_id": first, "cwd": "../other"})) + .await; + assert_eq!(switched["isError"], false, "{switched}"); + let second = switched["structuredContent"]["sessionId"] + .as_str() + .unwrap() + .to_owned(); + assert_ne!(first, second); + assert_eq!(switched["structuredContent"]["previousSessionId"], first); + assert_eq!( + switched["structuredContent"]["cwd"], + "/remote/not-on-qmcp/other" + ); + assert_eq!(switched["structuredContent"]["created"], true); + assert_eq!(switched["structuredContent"]["switched"], true); + assert_eq!(switched["structuredContent"]["profileSource"], "inherited"); + let switched_prompt = mcp + .tool( + "prompt", + json!({"session_id": second, "cwd": "../third", "text": "switch"}), + ) + .await; + assert_eq!(switched_prompt["isError"], false, "{switched_prompt}"); + let third = switched_prompt["structuredContent"]["sessionId"] + .as_str() + .unwrap() + .to_owned(); + assert_ne!(third, second); + assert_eq!( + switched_prompt["structuredContent"]["previousSessionId"], + second + ); + assert_eq!(switched_prompt["structuredContent"]["created"], true); + assert_eq!( + switched_prompt["structuredContent"]["cwd"], + "/remote/not-on-qmcp/third" + ); + for (id, cwd) in [ + (&first, "/remote/not-on-qmcp/project"), + (&second, "/remote/not-on-qmcp/other"), + (&third, "/remote/not-on-qmcp/third"), + ] { + let status = mcp.tool("status", json!({"session_id": id})).await; + assert_eq!(status["isError"], false); + assert_eq!(status["structuredContent"]["cwd"], cwd); + } + let requests = backend.recorded(); + let creations: Vec<_> = requests + .iter() + .filter(|r| r["method"] == "session/new") + .collect(); + assert_eq!(creations.len(), 3); + for request in creations { + assert_eq!( + request["params"]["_meta"]["querymt"]["profile_id"], + "fixture-profile" + ); + } + assert!(!requests.iter().any(|r| r["method"] == "session/list")); + assert_no_implicit_cancel(&backend); + } +} + +#[tokio::test] +async fn workspace_metadata_recovery_is_unfiltered_paginated_and_never_trusts_caller_cwd() { + let backend = MockAcp::start().await; + backend.workspace.lock().unwrap().listing = Some(vec![ + json!({"sessions": [{"sessionId": "unrelated", "cwd": "/unrelated"}], "nextCursor": "page-2"}), + json!({"sessions": [{"sessionId": "old", "cwd": "/remote/original/./project", "_meta": {"querymt": {"profile_id": "listed-profile"}}}]}), + ]); + // Separate qmcp instances simulate losing all process-local bindings. + for http in [false, true] { + let mut mcp = Mcp::start(&backend.url, http).await; + let rejected = mcp + .tool( + "status", + json!({"session_id": "old", "cwd": "/requested/not-authoritative"}), + ) + .await; + assert_eq!(rejected["isError"], true, "{rejected}"); + let selected = mcp.tool("workspace", json!({"session_id": "old"})).await; + assert_eq!(selected["isError"], false, "{selected}"); + assert_eq!( + selected["structuredContent"]["cwd"], + "/remote/original/project" + ); + assert_eq!(selected["structuredContent"]["profile"], "listed-profile"); + let switched = mcp + .tool( + "prompt", + json!({"session_id": "old", "cwd": "../next", "text": "new workspace"}), + ) + .await; + assert_eq!(switched["isError"], false, "{switched}"); + assert_eq!(switched["structuredContent"]["previousSessionId"], "old"); + assert_eq!( + switched["structuredContent"]["cwd"], + "/remote/original/next" + ); + assert_eq!(switched["structuredContent"]["profileSource"], "inherited"); + let old = mcp.tool("status", json!({"session_id": "old"})).await; + assert_eq!(old["structuredContent"]["cwd"], "/remote/original/project"); + } + let requests = backend.recorded(); + let listing: Vec<_> = requests + .iter() + .filter(|r| r["method"] == "session/list") + .collect(); + assert_eq!(listing.len(), 4); + for page in listing { + assert!(page["params"].get("cwd").is_none()); + assert!(page["params"].get("_meta").is_none()); + } + for request in requests.iter().filter(|r| { + matches!( + r["method"].as_str(), + Some("session/load" | "session/resume") + ) + }) { + assert_eq!(request["params"]["sessionId"], "old"); + assert_eq!(request["params"]["cwd"], "/remote/original/project"); + } + assert_no_implicit_cancel(&backend); +} + +#[tokio::test] +async fn workspace_discovery_fails_closed_for_incomplete_or_unavailable_metadata() { + let cases = [ + (vec![json!({"sessions": []})], "not found"), + ( + vec![json!({"sessions": [], "nextCursor": null})], + "not found", + ), + ( + vec![json!({"sessions": [], "nextCursor": 17})], + "metadata unavailable", + ), + ( + vec![json!({"sessions": [], "nextCursor": true})], + "metadata unavailable", + ), + ( + vec![json!({"sessions": [], "nextCursor": {}})], + "metadata unavailable", + ), + ( + vec![json!({"sessions": [], "nextCursor": []})], + "metadata unavailable", + ), + ( + vec![json!({"sessions": [], "nextCursor": ""})], + "repeated a cursor", + ), + ( + vec![ + json!({"sessions": [], "nextCursor": "page-2"}), + json!({"sessions": [], "nextCursor": "page-2"}), + ], + "repeated a cursor", + ), + ( + vec![json!({"sessions": [{"sessionId": "unknown", "cwd": ""}]})], + "missing or not absolute", + ), + ( + vec![json!({"sessions": [{"sessionId": "unknown", "cwd": "relative"}]})], + "missing or not absolute", + ), + ( + vec![json!({"sessions": [{"sessionId": "unknown"}]})], + "metadata unavailable", + ), + ( + vec![json!({"error": {"code": -32601, "message": "list unsupported"}})], + "metadata unavailable", + ), + ( + (1..=20) + .map(|page| json!({"sessions": [], "nextCursor": format!("page-{}", page + 1)})) + .collect(), + "page cap reached", + ), + ]; + for (pages, expected) in cases { + for name in ["workspace", "prompt", "status", "inspect", "follow"] { + let backend = MockAcp::start().await; + backend.workspace.lock().unwrap().listing = Some(pages.clone()); + let mut mcp = Mcp::start(&backend.url, true).await; + let mut args = json!({"session_id": "unknown", "cwd": "/caller/cannot/prove/this"}); + if name == "prompt" { + args["text"] = json!("must not send"); + } + let result = mcp.tool(name, args).await; + assert_eq!(result["isError"], true, "{name}: {result}"); + assert!( + result["structuredContent"]["error"] + .as_str() + .unwrap() + .contains(expected), + "{expected}: {result}" + ); + if expected == "metadata unavailable" { + assert!( + !result["structuredContent"]["error"] + .as_str() + .unwrap() + .contains("not found") + ); + } + assert!(!backend.recorded().iter().any(|r| matches!( + r["method"].as_str(), + Some("session/new" | "session/load" | "session/resume" | "session/prompt") + ))); + assert_no_implicit_cancel(&backend); + } + } +} + +#[tokio::test] +async fn workspace_profile_unknown_is_explicit_and_paths_are_remote_lexical() { + let backend = MockAcp::start().await; + let mut mcp = Mcp::start(&backend.url, true).await; + for cwd in [ + "~", + "~/project", + "~user/project", + "", + "C:", + "C:/project", + "/bad\npath", + ] { + let result = mcp.tool("workspace", json!({"cwd": cwd})).await; + assert_eq!(result["isError"], true, "{cwd:?}: {result}"); + } + assert!( + !backend + .recorded() + .iter() + .any(|r| r["method"] == "session/new") + ); + let selected = mcp + .tool("workspace", json!({"session_id": "fixture-session"})) + .await; + assert_eq!(selected["isError"], false, "{selected}"); + assert_eq!(selected["structuredContent"]["profileKnown"], false); + let rejected = mcp + .tool( + "workspace", + json!({"session_id": "fixture-session", "profile": "cannot-assume"}), + ) + .await; + assert_eq!(rejected["isError"], true); + // Give session/new a genuinely fresh ID in this fixture, whose default list + // already contains fixture-session (normal new-session tests start empty). + backend + .workspace + .lock() + .unwrap() + .created + .push(json!({"sessionId": "fixture-session", "cwd": "/workspace"})); + let switched = mcp + .tool( + "workspace", + json!({"session_id": "fixture-session", "cwd": "/remote/nonexistent"}), + ) + .await; + assert_eq!(switched["isError"], false, "{switched}"); + assert_eq!( + switched["structuredContent"]["profileSource"], + "backend-default-unknown" + ); + assert_eq!(switched["structuredContent"]["profileKnown"], false); + let known = mcp.tool("workspace", json!({"session_id": "fixture-session-2", "cwd": "/other", "profile": "explicit-profile"})).await; + assert_eq!(known["isError"], false, "{known}"); + assert_eq!(known["structuredContent"]["profile"], "explicit-profile"); + assert_eq!(known["structuredContent"]["profileSource"], "explicit"); + let requests = backend.recorded(); + let new: Vec<_> = requests + .iter() + .filter(|r| r["method"] == "session/new") + .collect(); + assert!(new[0]["params"].get("_meta").is_none()); + assert_eq!( + new[1]["params"]["_meta"]["querymt"]["profile_id"], + "explicit-profile" + ); + assert_no_implicit_cancel(&backend); +} + +#[tokio::test] +async fn workspace_batches_and_close_keep_bindings_and_new_commands_register_immediately() { + for http in [false, true] { + for name in ["new", "prompt"] { + let backend = MockAcp::start().await; + let mut mcp = Mcp::start(&backend.url, http).await; + let mut args = json!({"cwd": "/remote/partial", "mode": "invalid"}); + if name == "prompt" { + args["new"] = json!(true); + args["text"] = json!("not sent"); + } + let failed = mcp.tool(name, args).await; + assert_eq!(failed["isError"], true, "{failed}"); + assert_eq!( + failed["structuredContent"]["recovery"]["sessionCreated"], + true + ); + let status = mcp + .tool("status", json!({"session_id": "fixture-session"})) + .await; + assert_eq!(status["isError"], false, "{status}"); + assert_eq!(status["structuredContent"]["cwd"], "/remote/partial"); + let closed = mcp + .tool("close", json!({"session_id": "fixture-session"})) + .await; + assert_eq!(closed["isError"], false, "{closed}"); + let batch = mcp.tool("exec", json!({"calls": [ + {"name": "workspace", "arguments": {"session_id": "fixture-session"}}, + {"name": "status", "arguments": {"session_id": "fixture-session"}}, + {"name": "prompt", "arguments": {"session_id": "fixture-session", "cwd": "../switched", "text": "batch"}}, + {"name": "status", "arguments": {"session_id": "fixture-session-2"}}, + {"name": "inspect", "arguments": {"session_id": "fixture-session"}}, + ]})).await; + assert_eq!(batch["isError"], false, "{batch}"); + let steps = batch["structuredContent"]["results"].as_array().unwrap(); + assert_eq!(steps[0]["result"]["cwd"], "/remote/partial"); + assert_eq!(steps[1]["result"]["cwd"], "/remote/partial"); + assert_eq!(steps[2]["result"]["previousSessionId"], "fixture-session"); + assert_eq!(steps[3]["result"]["cwd"], "/remote/switched"); + assert_eq!(steps[4]["result"]["cwd"], "/remote/partial"); + assert!( + !backend + .recorded() + .iter() + .any(|r| r["method"] == "session/list") + ); + } + } +} + +#[tokio::test] +async fn workspace_rejected_creation_and_failed_switch_do_not_rebind_old_id() { + let backend = MockAcp::start().await; + let mut mcp = Mcp::start(&backend.url, true).await; + backend.workspace.lock().unwrap().reject_creation = true; + let rejected = mcp + .tool("workspace", json!({"cwd": "/remote/rejected"})) + .await; + assert_eq!(rejected["isError"], true, "{rejected}"); + assert!(rejected["structuredContent"].get("recovery").is_none()); + backend.workspace.lock().unwrap().reject_creation = false; + let created = mcp.tool("new", json!({"cwd": "/remote/old"})).await; + assert_eq!(created["isError"], false, "{created}"); + backend.workspace.lock().unwrap().reject_creation = true; + let rejected = mcp + .tool( + "workspace", + json!({"session_id": "fixture-session", "cwd": "../rejected"}), + ) + .await; + assert_eq!(rejected["isError"], true); + backend.workspace.lock().unwrap().reject_creation = false; + let failed = mcp.tool("prompt", json!({"session_id": "fixture-session", "cwd": "../new", "text": "not sent", "mode": "invalid"})).await; + assert_eq!(failed["isError"], true, "{failed}"); + let recovery = &failed["structuredContent"]["recovery"]; + assert_eq!(recovery["previousSessionId"], "fixture-session"); + assert_eq!(recovery["sessionId"], "fixture-session-2"); + assert_eq!(recovery["sessionCreated"], true); + assert_eq!(recovery["switched"], true); + for (id, cwd) in [ + ("fixture-session", "/remote/old"), + ("fixture-session-2", "/remote/new"), + ] { + let status = mcp.tool("status", json!({"session_id": id})).await; + assert_eq!(status["isError"], false, "{status}"); + assert_eq!(status["structuredContent"]["cwd"], cwd); + } + assert_no_implicit_cancel(&backend); +} + +#[tokio::test] +async fn concurrent_unknown_workspace_switches_never_bind_caller_paths_to_the_old_id() { + let backend = MockAcp::start().await; + backend.workspace.lock().unwrap().listing = Some(vec![ + json!({"sessions": [{"sessionId": "old", "cwd": "/authoritative/old"}]}), + ]); + let mut mcp = Mcp::start(&backend.url, true).await; + let Mcp::Http { client, url, .. } = &mcp else { + unreachable!() + }; + let request = |cwd| { + client.post(url).header("accept", "application/json, text/event-stream") + .header("mcp-protocol-version", "2025-11-25") + .json(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "workspace", "arguments": {"session_id": "old", "cwd": cwd}}})) + }; + let (first, second) = + tokio::join!(request("/remote/one").send(), request("/remote/two").send()); + let first: Value = first.unwrap().json().await.unwrap(); + let second: Value = second.unwrap().json().await.unwrap(); + let first = &first["result"]["structuredContent"]; + let second = &second["result"]["structuredContent"]; + assert_eq!(first["ok"], true, "{first}"); + assert_eq!(second["ok"], true, "{second}"); + assert_ne!(first["sessionId"], second["sessionId"]); + assert_eq!(first["previousSessionId"], "old"); + assert_eq!(second["previousSessionId"], "old"); + for (id, cwd) in [ + (first["sessionId"].as_str().unwrap(), "/remote/one"), + (second["sessionId"].as_str().unwrap(), "/remote/two"), + ("old", "/authoritative/old"), + ] { + let status = mcp.tool("status", json!({"session_id": id})).await; + assert_eq!(status["isError"], false, "{status}"); + assert_eq!(status["structuredContent"]["cwd"], cwd); + } + assert_no_implicit_cancel(&backend); +} + +fn gated_creation(backend: &MockAcp) -> (oneshot::Receiver<()>, oneshot::Sender<()>) { + let (observed, ready) = oneshot::channel(); + let (release, wait) = oneshot::channel(); + backend + .workspace + .lock() + .unwrap() + .creation_gates + .push_back(CreationGate { + observed, + release: wait, + }); + (ready, release) +} + +fn concurrent_tool(mcp: &Mcp, name: &str, arguments: Value) -> JoinHandle { + let Mcp::Http { client, url, .. } = mcp else { + unreachable!() + }; + let request = client.post(url).header("accept", "application/json, text/event-stream") + .header("mcp-protocol-version", "2025-11-25") + .json(&json!({"jsonrpc": "2.0", "id": 91, "method": "tools/call", "params": {"name": name, "arguments": arguments}})); + tokio::spawn(async move { + let response: Value = request.send().await.unwrap().json().await.unwrap(); + response["result"].clone() + }) +} + +fn assert_creation_conflict(result: &Value, acknowledged_id: &str) { + assert_eq!(result["isError"], true, "{result}"); + let content = &result["structuredContent"]; + let recovery = &content["recovery"]; + assert_eq!( + recovery["workspaceConflict"]["acknowledgedSessionId"], acknowledged_id, + "{result}" + ); + assert_eq!(recovery["workspaceConflict"]["selectionCommitted"], false); + for key in [ + "sessionId", + "cwd", + "created", + "sessionCreated", + "switched", + "previousSessionId", + ] { + assert!( + recovery.get(key).is_none(), + "invalid selection in recovery: {result}" + ); + assert!( + content.get(key).is_none(), + "invalid usable selection: {result}" + ); + } +} + +#[tokio::test] +async fn workspace_metadata_before_creation_ack_reconciles_or_reports_a_conflict() { + tokio::time::timeout(Duration::from_secs(20), async { + for (metadata_cwd, metadata_profile, initial_profile, success) in [ + ("/remote/new", None, Some("inherited-profile"), true), + ( + "/remote/new", + Some("inherited-profile"), + Some("inherited-profile"), + true, + ), + ("/remote/new", Some("authoritative-profile"), None, true), + ( + "/remote/contradiction", + None, + Some("inherited-profile"), + false, + ), + ( + "/remote/new", + Some("conflicting-profile"), + Some("inherited-profile"), + false, + ), + ] { + let backend = MockAcp::start().await; + let mut mcp = Mcp::start(&backend.url, true).await; + let old = mcp + .tool( + "workspace", + json!({"cwd": "/remote/old", "profile": initial_profile}), + ) + .await; + assert_eq!(old["isError"], false, "{old}"); + let (ready, release) = gated_creation(&backend); + let pending = concurrent_tool( + &mcp, + "prompt", + json!({"session_id": "fixture-session", "cwd": "../new", "text": "switch prompt"}), + ); + ready.await.unwrap(); + // session/new has materialized B, but its ACK cannot reach qmcp yet. + let mut summary = json!({"sessionId": "fixture-session-2", "cwd": metadata_cwd}); + if let Some(profile) = metadata_profile { + summary["_meta"] = json!({"querymt": {"profile_id": profile}}); + } + backend.workspace.lock().unwrap().listing = Some(vec![json!({"sessions": [summary]})]); + let discovered = mcp + .tool("workspace", json!({"session_id": "fixture-session-2"})) + .await; + assert_eq!(discovered["isError"], false, "{discovered}"); + assert_eq!(discovered["structuredContent"]["created"], false); + let status = mcp + .tool("status", json!({"session_id": "fixture-session-2"})) + .await; + assert_eq!(status["structuredContent"]["cwd"], metadata_cwd); + assert!( + !backend + .recorded() + .iter() + .any(|r| r["method"] == "session/prompt") + ); + release.send(()).unwrap(); + let result = pending.await.unwrap(); + if success { + assert_eq!(result["isError"], false, "{result}"); + let content = &result["structuredContent"]; + assert_eq!(content["sessionId"], "fixture-session-2"); + assert_eq!(content["previousSessionId"], "fixture-session"); + assert_eq!(content["created"], true); + assert_eq!(content["switched"], true); + let expected_profile = initial_profile.or(metadata_profile).unwrap(); + assert_eq!(content["profile"], expected_profile); + assert_eq!( + content["profileSource"], + if initial_profile.is_some() { + "inherited" + } else { + "authoritative-metadata" + } + ); + let remembered = mcp + .tool("workspace", json!({"session_id": "fixture-session-2"})) + .await; + assert_eq!(remembered["structuredContent"]["profile"], expected_profile); + let prompt = mcp + .tool( + "prompt", + json!({"session_id": "fixture-session-2", "text": "actual next prompt"}), + ) + .await; + assert_eq!(prompt["isError"], false, "{prompt}"); + let requests = backend.recorded(); + let prompts: Vec<_> = requests + .iter() + .filter(|r| r["method"] == "session/prompt") + .collect(); + assert_eq!(prompts.len(), 2); + assert!( + prompts + .iter() + .all(|r| r["params"]["sessionId"] == "fixture-session-2") + ); + } else { + assert_creation_conflict(&result, "fixture-session-2"); + assert!( + !backend + .recorded() + .iter() + .any(|r| r["method"] == "session/prompt") + ); + let remembered = mcp + .tool("workspace", json!({"session_id": "fixture-session-2"})) + .await; + assert_eq!(remembered["structuredContent"]["cwd"], metadata_cwd); + assert_eq!( + remembered["structuredContent"]["profile"], + json!(metadata_profile) + ); + } + let old = mcp + .tool("workspace", json!({"session_id": "fixture-session"})) + .await; + assert_eq!(old["structuredContent"]["cwd"], "/remote/old"); + assert_eq!(old["structuredContent"]["profile"], json!(initial_profile)); + assert_no_implicit_cancel(&backend); + } + }) + .await + .expect("barrier-controlled metadata/ACK test must complete"); +} + +#[tokio::test] +async fn workspace_prebound_duplicate_ack_never_publishes_an_invalid_selection() { + for from_metadata in [false, true] { + for bound_cwd in ["/remote/new", "/remote/different"] { + let backend = MockAcp::start().await; + let mut mcp = Mcp::start(&backend.url, true).await; + let old = mcp.tool("workspace", json!({"cwd": "/remote/old"})).await; + assert_eq!(old["isError"], false); + let duplicate_id = if from_metadata { + backend.workspace.lock().unwrap().listing = Some(vec![ + json!({"sessions": [{"sessionId": "prebound", "cwd": bound_cwd}]}), + ]); + "prebound" + } else { + let prebound = mcp.tool("workspace", json!({"cwd": bound_cwd})).await; + assert_eq!(prebound["isError"], false); + "fixture-session-2" + }; + let known = mcp + .tool("workspace", json!({"session_id": duplicate_id})) + .await; + assert_eq!(known["isError"], false); + backend.workspace.lock().unwrap().forced_id = Some(duplicate_id.into()); + for name in ["workspace", "prompt"] { + let mut args = json!({"session_id": "fixture-session", "cwd": "/remote/new"}); + if name == "prompt" { + args["text"] = json!("must not be sent"); + } + let failed = mcp.tool(name, args).await; + assert_creation_conflict(&failed, duplicate_id); + } + // Also cover the backend returning A itself for an A -> new switch. + backend.workspace.lock().unwrap().forced_id = Some("fixture-session".into()); + let failed = mcp.tool("prompt", json!({"session_id": "fixture-session", "cwd": "/remote/new", "text": "must not be sent"})).await; + assert_creation_conflict(&failed, "fixture-session"); + for (id, cwd) in [ + ("fixture-session", "/remote/old"), + (duplicate_id, bound_cwd), + ] { + let known = mcp.tool("workspace", json!({"session_id": id})).await; + assert_eq!(known["structuredContent"]["cwd"], cwd); + } + assert!( + !backend + .recorded() + .iter() + .any(|r| r["method"] == "session/prompt") + ); + assert_no_implicit_cancel(&backend); + } + } +} + +#[tokio::test] +async fn workspace_two_pending_creation_acks_can_claim_a_metadata_id_only_once() { + tokio::time::timeout(Duration::from_secs(10), async { + let backend = MockAcp::start().await; + backend.workspace.lock().unwrap().forced_id = Some("collision".into()); + let mut mcp = Mcp::start(&backend.url, true).await; + let (first_ready, first_release) = gated_creation(&backend); + let (second_ready, second_release) = gated_creation(&backend); + let first = concurrent_tool(&mcp, "workspace", json!({"cwd": "/remote/same"})); + first_ready.await.unwrap(); + let second = concurrent_tool(&mcp, "workspace", json!({"cwd": "/remote/same"})); + second_ready.await.unwrap(); + let metadata = mcp + .tool("workspace", json!({"session_id": "collision"})) + .await; + assert_eq!(metadata["isError"], false, "{metadata}"); + first_release.send(()).unwrap(); + second_release.send(()).unwrap(); + let results = [first.await.unwrap(), second.await.unwrap()]; + assert_eq!( + results.iter().filter(|r| r["isError"] == false).count(), + 1, + "{results:?}" + ); + let failed = results.iter().find(|r| r["isError"] == true).unwrap(); + assert_creation_conflict(failed, "collision"); + assert_eq!( + backend + .recorded() + .iter() + .filter(|r| r["method"] == "session/new") + .count(), + 2 + ); + assert_no_implicit_cancel(&backend); + }) + .await + .expect("both barrier-controlled ACKs must complete"); +} + async fn exercise_bridge(http: bool) { let backend = MockAcp::start().await; let mut mcp = Mcp::start(&backend.url, http).await; let catalog = mcp.rpc("tools/list", json!({})).await; - assert_eq!(catalog["tools"].as_array().unwrap().len(), 25); + assert_eq!(catalog["tools"].as_array().unwrap().len(), 26); assert!( backend.recorded().is_empty(), "discovery must not connect to ACP" @@ -593,7 +1454,7 @@ async fn failures_are_tool_errors_and_timeouts_do_not_cancel_remote_work() { } #[tokio::test] -async fn long_prompt_does_not_block_concurrent_runtime_requests() { +async fn long_prompt_does_not_block_concurrent_status_runtime_steer_or_cancel() { let backend = MockAcp::start().await; let mut mcp = Mcp::start(&backend.url, true).await; let Mcp::Http { client, url, .. } = &mcp else { @@ -625,6 +1486,25 @@ async fn long_prompt_does_not_block_concurrent_runtime_requests() { .await .unwrap(); assert_eq!(runtime["isError"], false); + for (name, args) in [ + ("status", json!({"session_id": "fixture-session"})), + ( + "steer", + json!({"session_id": "fixture-session", "run_id": "fixture-run", "text": "steer while prompt waits"}), + ), + ("cancel", json!({"session_id": "fixture-session"})), + ] { + let result = tokio::time::timeout(Duration::from_millis(500), mcp.tool(name, args)) + .await + .expect("workspace locks must not block control commands"); + assert_eq!(result["isError"], false, "{name}: {result}"); + } + assert!( + !backend + .recorded() + .iter() + .any(|r| r["method"] == "session/list") + ); let finished = waiting.await.unwrap(); assert_eq!(finished["result"]["structuredContent"]["code"], 4); backend.wait_disconnected().await; From a0dcc0fb23d60c18b621f2c59b47d84e68a40cbc Mon Sep 17 00:00:00 2001 From: Sommelier Date: Sun, 25 Oct 2026 18:30:46 +0200 Subject: [PATCH 5/5] refactor: trim OAuth and workspace integration --- AGENTS.md | 76 +- README.md | 96 +- docs/how-to-connect.md | 348 ++---- src/commands.rs | 15 +- src/local_oauth.rs | 298 +---- src/local_oauth/tests.rs | 2258 +++++++++----------------------------- src/workspace.rs | 61 +- tests/bridge.rs | 549 ++++----- 8 files changed, 913 insertions(+), 2788 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8bff2c8..451fe1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,39 +2,69 @@ ## Working Rules -- Keep changes focused and keep code, comments, documentation, and diagnostics in English. -- Preserve staged, unstaged, and untracked work. Do not stage, unstage, commit, or push unless the user explicitly requests it. -- Keep this file limited to durable, repository-wide guidance. -- Never access the user's session database or stop/restart existing QueryMT services. Use mock ACP endpoints or isolated instances with fresh test databases; stop only processes created for the current test. +- Keep changes minimal and focused; avoid drive-by refactors and unrelated cleanup. +- Preserve staged, unstaged and untracked work. Do not stage, unstage, commit or + push unless the user explicitly requests that action. Editing an already-staged + file does not authorize updating the index. +- Keep this file limited to durable, repository-wide guidance, not task-specific + corrections. Keep code, comments, documentation and diagnostics in English. +- Never access the user's session database or stop/restart existing QueryMT + services. Use mock ACP endpoints or isolated instances with fresh test databases; + stop only processes created for the current test. ## Repository Overview -- The project, crate, and binary are named `qmcp` (lowercase). -- `src/main.rs` configures the server; `server.rs` exposes MCP tools and `commands.rs` implements ACP operations using qmacp. -- `transport.rs` handles stdio/Streamable HTTP; `oauth.rs` handles external-provider introspection; `local_oauth.rs` isolates embedded owner login, consent, PKCE, and volatile token issuance; `output.rs` assembles tool results, bounded events, and recovery information. -- `tests/bridge.rs` exercises the MCP-to-ACP bridge with mocks. `README.md` and `docs/how-to-connect.md` cover user setup; avoid duplicating their content. +- The project, crate and binary are named `qmcp` (lowercase). +- `src/main.rs` configures the server; `server.rs` exposes MCP tools and + `commands.rs` implements their ACP operations using the qmacp client library. +- `transport.rs` handles stdio/Streamable HTTP; `oauth.rs` handles provider OAuth; + `output.rs` assembles tool results, bounded events and recovery information. +- `tests/bridge.rs` exercises the MCP-to-ACP bridge with mocks. `README.md` and + `docs/how-to-connect.md` cover user setup; avoid duplicating their content. ## Development Environment -- Detect Nix first. When available, use the project devShell (`nix develop` or `nix develop -c `); an active project direnv shell is sufficient. -- Without Nix, use the supported native toolchain and dependencies. Check `flake.nix` and `rust-toolchain.toml`; diagnose setup failures rather than silently switching environments or toolchains. +- Detect Nix first. When available, use the project devShell (`nix develop` or + `nix develop -c `); an active project direnv shell is sufficient. +- Without Nix, use the supported native toolchain and dependencies. Check + `flake.nix` and `rust-toolchain.toml`; diagnose setup failures rather than + silently switching environments or toolchains. - Run validation from the repository root inside the selected environment. ## Validation -- After Rust edits, run `cargo check -p qmcp --tests`. Run formatting checks after the check passes. -- Use focused red/green tests where applicable. Each added case should protect a distinct required behavior or real regression; prefer existing tests and fixtures. -- Do not test deleted functionality, mirror implementation constants/logic, or multiply equivalent input combinations. Keep scaffolding proportional. -- Consult `.github/workflows/ci.yml` for project CI. Before finishing, run formatting checks, `cargo check --locked --all-targets`, `cargo test --locked --all-targets`, and `cargo clippy --locked --all-targets -- -D warnings` when applicable. -- Tests must not connect to the user's running server. Loading/resuming can restore runtime state; do not treat those operations as harmless history reads. -- Report checks actually run, failures, and remaining gaps. Documentation-only edits do not require a Rust build. +- After Rust edits, run `cargo check -p qmcp` (include `--tests` for test edits). + Run `cargo fmt` only after that check passes. +- Use focused red/green tests where applicable. Each added case must protect a + distinct required behavior or real regression; prefer existing tests/fixtures. +- Do not test deleted functionality, mirror implementation constants/logic, or + multiply equivalent input combinations. Keep scaffolding proportional; moving + excess into another file is not simplification. +- Start with affected checks, then expand to consumers/features as needed. Consult + `.github/workflows/ci.yml` for final CI commands: + ```sh + cargo check -p qmcp --tests + cargo test -p qmcp --test bridge + cargo fmt --all -- --check + cargo clippy --all-targets --all-features -- -D warnings + ``` +- Tests must not connect to the user's running server. Load/resume can restore + runtime state; do not treat them as harmless history reads. +- Report checks actually run, failures and remaining gaps. Documentation-only + edits do not require a Rust build. ## Protocol and Code Conventions -- Support only stdio and Streamable HTTP. Keep stdout exclusively for MCP JSON-RPC in stdio mode; use stderr for diagnostics and never log credentials. -- Require static Bearer, embedded single-owner OAuth, or external-provider OAuth authentication for non-loopback HTTP binds. Preserve Host/Origin validation. -- Keep external-provider introspection in `oauth.rs`; keep embedded owner login, consent, PKCE, and volatile token issuance in `local_oauth.rs`. Never silently authorize or accept human-chosen owner passwords. -- Embedded OAuth is single-owner; keep its secret file protected, state bounded and volatile, consent explicit, and token material hashed at rest. Do not introduce persistence or multi-owner behavior without authorization. -- Timeout, disconnect, and MCP cancellation must not implicitly close sessions or cancel remote work. Retain submission/recovery information on failure. -- Preserve qmacp's MIT attribution when reusing its ACP implementation. Keep transport/client fixes in qmacp and MCP adaptation in qmcp; do not expand work into QueryMT or `querymt-desktop` without authorization. -- Follow surrounding code, rustfmt, and existing error conventions. Flag protocol/API changes and affected consumers. +- Support only stdio and Streamable HTTP. Keep stdout exclusively for MCP JSON-RPC + in stdio mode; use stderr for diagnostics and never log credentials. +- Require static Bearer or external-provider OAuth authentication for non-loopback + HTTP binds; preserve Host/Origin validation. +- Keep OAuth minimal: resource discovery and token introspection only. Login, + PKCE, consent and token issuance belong to the provider. +- Timeout, disconnect and MCP cancellation must not implicitly close sessions or + cancel remote work. Retain submission/recovery information on failure. +- Follow surrounding code, rustfmt and existing error conventions. +- Do not add speculative compatibility layers. Flag API/protocol changes and + affected consumers; ask before making compatibility decisions. +- Keep transport/client fixes in qmacp and MCP adaptation in qmcp. Do not expand + work into QueryMT or `querymt-desktop` without authorization. diff --git a/README.md b/README.md index 5e7ae50..1ff27ce 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,9 @@ cargo install --locked --path . ## Connect -For a single-owner ChatGPT connection on macOS, qmcp includes OAuth login, -consent, public-client registration and PKCE. No external provider or Docker -service is required. Use an HTTPS reverse proxy and a generated owner credential -in a private `0600` file: +For ChatGPT, qmcp provides embedded single-owner OAuth with explicit consent, +PKCE, dynamic client registration, and rotating refresh tokens. Run it on the +Mac behind an HTTPS reverse proxy; no external login service is needed: ```sh qmcp --transport http --permission cancel \ @@ -31,21 +30,10 @@ qmcp --transport http --permission cancel \ --oauth-owner-secret-file /absolute/private/path/to/owner-secret ``` -These are example values, not a deployment configuration. See -[How to connect](docs/how-to-connect.md#embedded-oauth-on-macos-chatgpt) for secret -generation, proxy setup and ChatGPT sign-in. Embedded OAuth is single-owner and -volatile: restarting invalidates tokens **and registered client IDs**, so you may -need to re-add the server in ChatGPT. Only unapproved registrations expire after -one hour; approved registrations survive until restart. Discovery advertises -PKCE authorization-code and rotating refresh-token grants. Access tokens last -30 minutes (capped at family expiry); clients registered for refresh receive -one-use refresh tokens with an absolute 24-hour family lifetime from the initial -code exchange, never extended by rotation. Replay revokes the family's access -and refresh tokens. Restart invalidates refresh state too; see the guide for -capacity limits and serialized-refresh requirements. - -Local stdio, static Bearer over HTTPS, private tunnels, and optional external -OAuth introspection remain available in the [connection guide](docs/how-to-connect.md). +See the [connection guide](docs/how-to-connect.md) for credential generation, +proxy configuration, stdio, static Bearer, tunnels, or external OAuth. +OAuth state is volatile: restart invalidates tokens and registered client IDs. +Approval grants all backend tools, not multi-user isolation. ## Tools @@ -65,66 +53,22 @@ Start with `caps`, `profiles`, and `models` to discover available capabilities. | `cancel`, `close` | Request turn cancellation or explicitly close a session | | `exec` | Run a batch sequentially on one ACP connection | -Select a workspace once, without sending a prompt, then reuse its returned ID: +Choose a workspace once without prompting, then reuse the returned ID: ```text workspace({"cwd":"/srv/projects/app","profile":"my-profile"}) - -> {"sessionId":"A","cwd":"/srv/projects/app","created":true,"switched":false,...} -prompt({"session_id":"A","text":"Explain this project"}) -status({"session_id":"A"}) -workspace({"session_id":"A","cwd":"../other"}) - -> {"sessionId":"B","previousSessionId":"A","cwd":"/srv/projects/other","created":true,"switched":true,...} -prompt({"session_id":"B","text":"Work in the other project"}) +prompt({"session_id":"","text":"Explain this project"}) +workspace({"session_id":"","cwd":"../other"}) ``` -`profile` is optional; discover actual IDs with `profiles`. A different directory -creates a **new QueryMT session**, not a rebind of the old one. Always use the -returned **new** `sessionId` as `session_id` in subsequent calls. The old ID keeps -its workspace and work; switching does not close, cancel, or alias it. There is -no global active chat workspace: subsequent calls still need an explicit ID. - -`new` and `prompt` with `new: true` also remember cwd. Continuing `prompt` can -omit cwd; an explicit different cwd transparently creates a new session and -returns `previousSessionId`. `status`, `inspect`, and `follow` use remembered cwd -when omitted and reject a different cwd, directing you to `workspace` instead. -`profiles`, `models`, lifecycle, and pin tools return provider data under -`result`, separate from bridge status and event metadata. - -### Workspace Rules - -- Paths refer to the **QueryMT host**, not necessarily the qmcp host. Paths are - normalized lexically (`.` and `..`), without filesystem checks, symlink - resolution, or local canonicalization; the backend owns path validation. - This is not a sandbox or directory allowlist. -- `workspace` without `session_id` requires an explicit initial cwd. Relative - initial/new paths use qmcp's once-captured startup cwd; omitted cwd for `new` - or `prompt(new=true)` preserves that startup-directory default. Prefer absolute - backend paths, especially remotely. Relative paths with a selected ID use that - session's remembered cwd. Only POSIX paths are supported; empty/control-character, - Windows drive/UNC, and `~` paths are rejected. Home expansion is never guessed. -- Profiles explicitly selected at creation, or reported in authoritative list - metadata, can be inherited on switches. Otherwise a new session uses the backend - default, reported as `profileKnown:false` and `profileSource:"backend-default-unknown"`. - Same-workspace profile changes are rejected (including explicit profiles when - the old profile is unknown); create a workspace without an ID to choose one. -- Bindings are process-local, shared across server clones and HTTP requests, and - immutable per ID. `close` retains the binding for later resume. The store holds - at most 4096 IDs, with no eviction; capacity errors occur before session creation. - No workspace lock is held across network requests or long prompts. -- After restart, or for an unknown ID, qmcp first discovers its authoritative cwd - through unfiltered, paginated ACP `session/list`, without loading history. - Discovery scans at most 20 pages and rejects repeated cursors. Missing sessions, - unsupported/unavailable metadata, absent/non-absolute cwd, or incomplete scans - fail closed, even if a caller supplies cwd. No guessed cwd or implicit creation. - Metadata must accurately describe the backend's session; external backend changes - and symlink equivalence cannot be detected from a cached lexical binding. -- Acknowledged new IDs and cwd are recorded before configuration/status/prompt - steps. Later failure or cancellation includes recovery details. If a remote - creation is never acknowledged, qmcp cannot know its ID; inspect backend metadata - before retrying. Timeouts/disconnects never implicitly cancel remote work. - -See [the connection guide](docs/how-to-connect.md#select-a-session-workspace) -for the complete selection workflow. +A directory switch creates a **new session**: use its returned `sessionId`. +The old ID remains unchanged; nothing implicitly closes or cancels its work. +`new` and `prompt(new=true)` also bind cwd; continuing `prompt` can omit cwd. +`status`, `inspect`, and `follow` reject directory changes. Unknown IDs require +backend `session/list` metadata; missing or incomplete metadata fails closed. +Paths are lexical POSIX paths on the QueryMT host, not a sandbox. Initial relative +paths use qmcp's startup cwd; later ones use the session cwd. `~` is rejected; +supply the backend's absolute home path. See [workspace details](docs/how-to-connect.md#select-a-session-workspace). ## Important Behavior @@ -146,6 +90,10 @@ for the complete selection workflow. - `find` scans at most 20 pages. Check `truncated` and `scanLimitReached` before treating its results as exhaustive. +`inspect` still uses ACP `session/load`, which may restore runtime state; it is +not a guaranteed read-only history API. A backend replay failure is a tool error, +not empty history. No history endpoint or backend fix is supplied here. + ## Development ```sh diff --git a/docs/how-to-connect.md b/docs/how-to-connect.md index 4f66551..3165af6 100644 --- a/docs/how-to-connect.md +++ b/docs/how-to-connect.md @@ -27,63 +27,27 @@ non-loopback ACP requires `--allow-insecure`. qmcp does not launch QueryMT. ## Select a Session Workspace -After connecting using any transport, use `workspace` to choose a directory on -**the QueryMT host** without sending a prompt. There is no chat-global active cwd; -the returned `sessionId` is the explicit handle for later calls. - -```text -workspace({"cwd":"/srv/projects/app"}) - -> {"sessionId":"A","cwd":"/srv/projects/app","created":true,"switched":false,...} -prompt({"session_id":"A","text":"Describe the project"}) -status({"session_id":"A"}) -inspect({"session_id":"A"}) -workspace({"session_id":"A","cwd":"../other"}) - -> {"sessionId":"B","previousSessionId":"A","cwd":"/srv/projects/other","created":true,"switched":true,...} -prompt({"session_id":"B","text":"Use this project now"}) -``` - -Always keep and use the returned **new ID** after a switch. QueryMT does not -reliably rebind a materialized session, so changing directories creates a new -backend session. The old session stays unchanged, with no implicit close, cancel, -or ID alias. Omitted cwd or an equivalent normalized path selects the same ID -with `created:false, switched:false`. Continuing `prompt` with a different cwd -has the same new-session behavior and returns `previousSessionId` too. -Read-only `status`, `inspect`, and `follow` remember cwd when omitted and reject -explicit different cwd, suggesting `workspace`; they never create a session. - -`workspace` without an ID requires cwd. `new` and `prompt(new=true)` also pin cwd -immediately on acknowledgement, even when later configuration fails. They retain -qmcp's startup-directory default if cwd is omitted. Initial/new relative paths -resolve from the once-captured qmcp startup cwd; relative paths with an existing -ID resolve from that session's cwd. Prefer absolute paths for remote QueryMT. -Normalization is lexical POSIX `.`/`..` processing, not a local filesystem check -or symlink resolution. `~` is rejected: supply an absolute backend home path -instead. Empty/control-character and Windows drive/UNC paths are rejected too. -The backend validates paths; workspace selection is not a sandbox or allowlist. - -Optionally supply `profile` on creation (discover IDs with `profiles`). Switches -inherit a known profile selected at creation or reported by backend list metadata. -If unknown, the backend default is used and the result explicitly reports -`profileKnown:false`, `profileSource:"backend-default-unknown"`. A same-workspace -explicit different/unknown profile is rejected; profile changing is not part of -workspace selection. Start without an ID to select a new profile independently. - -Bindings live in qmcp memory (4096 IDs, no eviction), shared by all server clones, -HTTP requests, and sequential `exec` steps. Closing a session retains its binding -for resume. Unknown IDs and IDs after qmcp restart require authoritative cwd from -unfiltered paginated `session/list`, at most 20 pages with repeated-cursor detection. -No history is loaded before cwd is known. Unsupported/missing cwd metadata, -not-found sessions, and incomplete scans produce distinct fail-closed errors, -not guessed paths or implicit sessions, even with explicit caller cwd. Accurate -backend metadata is required; cached lexical bindings cannot detect external -workspace changes or symlink equivalence. No locks span network operations, so -long prompts do not block session-ID-only runtime/steer/cancel calls. - -Check `recovery` on failures/timeouts before retrying; acknowledged new IDs remain -usable without cwd. A creation whose acknowledgement never arrives may still -exist remotely but has no locally known ID. Cancellation/disconnect does not -implicitly cancel or close remote work. This feature does not alter or deploy -the running QueryMT service or change global backend configuration. +Call `workspace({"cwd":"/srv/projects/app","profile":""})` +without sending a prompt. Reuse its returned `sessionId` in later calls; omit cwd +until you explicitly change it. There is no global active workspace. +`workspace({"session_id":"A","cwd":"../other"})` creates session B, leaving A +unchanged. Use B's returned ID. Continuing `prompt` with a different cwd behaves +likewise; `status`, `inspect`, and `follow` reject a different cwd. + +Paths refer to the QueryMT host and normalize `.`/`..` lexically, without local +filesystem or symlink checks. Initial relative paths use qmcp's startup cwd; +subsequent paths use the bound session cwd. Supply absolute remote paths; +`~`, Windows paths, empty paths, and control characters are rejected. +Known profiles are inherited on switches; unknown ones use the backend default +and report `profileKnown:false`. Same-directory profile changes are rejected. + +Bindings are immutable per ID, process-local, shared across HTTP calls, retained +on close, and capped at 4096 without eviction. Unknown IDs (including after +restart) require unfiltered paginated `session/list` metadata. Missing cwd, +repeated cursors, unavailable metadata, or the 20-page scan limit fail closed; +caller cwd cannot override them. Check `recovery` before retrying failed creation: +an acknowledged ID is retained, but an unacknowledged creation may still exist +remotely. Timeouts never implicitly cancel remote work. ## Local stdio @@ -155,17 +119,12 @@ to keep qmcp private. Your account/workspace must allow tunnels and custom MCP s ## Embedded OAuth on macOS (ChatGPT) -qmcp includes a single-owner OAuth authorization server. No Docker, external -identity provider, or separate login service is needed. Start QueryMT locally, -then put qmcp behind a trusted HTTPS reverse proxy. qmcp does not terminate TLS. -The resource must be a canonical HTTPS URL ending in exactly `/mcp`, without -userinfo, query, or fragment. Its origin is the issuer (no trailing slash). - -### Generate the owner credential +qmcp runs the single-owner authorization server on the Mac. TLS terminates at +your trusted HTTPS proxy; the canonical public resource must end in `/mcp` with +no userinfo, query, or fragment. Its origin is the issuer. -Generate a new secret, not a human-chosen password. This example creates a -private file without printing its contents, and refuses to overwrite an existing -file. Run it only when preparing a deployment, not as part of a source review: +Generate a credential only when preparing deployment (these commands do not +print it and refuse to overwrite an existing file): ```sh umask 077 @@ -175,21 +134,13 @@ chmod 700 "$HOME/.config/qmcp" chmod 600 "$HOME/.config/qmcp/owner-secret" ``` -Keep the generated value in a trusted password manager for browser login. Never -put it in command arguments, URLs, environment variables, logs, source control, -or a PR. qmcp never creates or prints an owner credential. It accepts 32..64 -random bytes encoded as 64..128 lowercase hex characters, optionally followed -by a newline. Length/encoding and obvious-weakness checks cannot prove entropy; -**only use a cryptographically generated secret**, not a phrase or password. -The absolute file path must name a regular file owned by qmcp's user, mode -`0600`, with no final-component symlink or hard links. Keep its parent directory -private and free of untrusted symlinks/ACLs. Secret-file protection currently -requires Unix (including macOS). +Use only 32..64 cryptographically random bytes encoded as lowercase hex, never +a password. Keep the value in a trusted password manager for browser login, not +URLs, arguments, environment, logs, or source control. The absolute secret path +must be an owned Unix regular file, mode `0600`, without symlinks or hard links; +protect its parent directory too. qmcp neither generates nor prints the secret. -### Run and connect - -Leave `QMCP_BEARER_TOKEN` and `QMCP_OAUTH_CLIENT_SECRET` unset, then run after -reviewing your deployment configuration: +Leave `QMCP_BEARER_TOKEN` and `QMCP_OAUTH_CLIENT_SECRET` unset, then run: ```sh qmcp --transport http --permission cancel \ @@ -197,149 +148,38 @@ qmcp --transport http --permission cancel \ --oauth-owner-secret-file "$HOME/.config/qmcp/owner-secret" ``` -The default bind is `127.0.0.1:3031`. For a proxy on another host, see the -[Nginx/VPN example](#separate-nginx-server-to-a-mac-over-vpn). Embedded OAuth -accepts only the canonical public resource Host, which is also added to MCP's -Host allowlist; additional host aliases do not broaden that outer guard. -Local OAuth, external OAuth (`--oauth-config`), and static Bearer are mutually -exclusive. HTTP authentication options are rejected in stdio mode; an owner -secret flag without a local resource is rejected too. - -Forward **all** these paths through the HTTPS proxy, preserving Host, -Authorization, Origin, and query strings: - -- `/mcp` -- `/.well-known/oauth-authorization-server` -- `/.well-known/oauth-protected-resource` and its `/mcp` variant -- `/oauth/register`, `/oauth/authorize`, `/oauth/token` - -In ChatGPT, add a custom MCP server with `https://mcp.example.com/mcp` and select -**OAuth**. Use dynamic client registration (DCR); do not enter the owner secret -as a client ID/secret. During sign-in, verify the public domain, the unverified -client name, and its exact callback URL. Enter the generated owner secret on -qmcp's **Owner login + authorize** page and explicitly choose **Log in and -Approve**, or choose **Deny**. Then explicitly click **Continue to ChatGPT** on -the completion page (also after denial). This extra click returns the code or -error, state, and issuer to the registered callback; there is no automatic -cross-origin redirect after form submission. Registration alone grants no -access. Workspace settings must allow custom MCP servers. The flow is covered -by local tests; live ChatGPT interoperability still needs deployment validation. - -### Private OAuth diagnostics - -For a reviewed deployment, opt in with -`RUST_LOG='warn,qmcp::oauth_diagnostics=info'` in the qmcp process environment. -Keep stderr private. This target logs fixed route/method labels, HTTP status, -allowlisted OAuth error/rejection reasons, and normalized DCR authentication and -recognized grant flags only. Unknown routes become `/other`; no header values, -URLs, queries, bodies, client IDs, remote IPs, or credentials are logged. -Unknown registration metadata remains ignored. Do not enable broader -`rmcp`/`reqwest` debug or trace logs to diagnose app creation. - -These diagnostics identify which requests reach qmcp and how it responds; they -do not establish the cause of a generic ChatGPT rejection without a retry. -Enabling them in a new process requires a reviewed restart, which discards all -volatile OAuth state; re-registration may be needed. - -### Security and operational limits - -- Discovery advertises authorization-code and refresh-token grants for public - DCR clients (`token_endpoint_auth_method: none`), with PKCE `S256` required - for the initial code exchange. Registration requires `authorization_code`; - `refresh_token` is optional. The grant set is order independent, with no - duplicates or unsupported grants; omission defaults to authorization-code - only. The response echoes the normalized registered set. Only clients that - register the refresh grant receive refresh tokens after explicit owner approval - and a valid PKCE exchange. No implicit grant, CIMD URL fetching, or client - secret authentication is supported. Registration never fetches a supplied URL. -- The exact registered redirect, public resource, client, and `qmcp:access` - scope are bound to authorization and code exchange. HTTPS callbacks are - required except literal loopback/localhost HTTP callbacks. Redirects with - fragments, credentials, or reserved OAuth response query fields are rejected. - Unknown descriptive registration metadata (including `client_uri` and - `logo_uri`) and authorization/token extension parameters are ignored, never - fetched or rendered. Known security parameters remain strict, including - duplicate rejection; unsupported grants and authentication/PKCE methods are - rejected. Credential-bearing authorization query parameters are rejected. - Up to eight redirects and a 128-byte client name fit in an 8 KiB body. -- Each five-minute browser transaction requires a random CSRF token and a - Secure, HttpOnly, SameSite=Lax, path-scoped cookie. Form POSTs require the - exact issuer Origin. MCP and all other routes reject Origin; never strip it - at the proxy to bypass validation. Login and completion HTML use - `Referrer-Policy: same-origin` so real form submissions retain the exact - Origin; `Origin: null` is never accepted. Invalid cookies, CSRF, or Origin - do not consume owner attempts or destroy a valid transaction. Each validated - transaction has one owner attempt, with eight attempts per client per minute. - There is no remembered login or silent approval. -- Codes expire after 90 seconds and are consumed atomically, including on a - failed binding/PKCE check. Transient capacity failures leave a valid code usable - until its original expiry. Access tokens last 30 minutes, capped at their - refresh family's deadline when applicable. Only SHA-256 digests of owner - credentials, cookies, CSRF tokens, codes, access tokens and refresh tokens are - retained in server state. Tokens are opaque, generated from 32 cryptographically - random bytes. Responses use no-store and restrictive - CSP, framing and referrer policies. Completion pages contain a normal escaped - callback link with `rel="noreferrer noopener"`, no scripts, forms, or automatic - navigation. Codes in that private HTML must not be logged or cached. -- Refresh families have an **absolute 24-hour lifetime from the initial code - exchange**; rotation never extends that deadline. Refresh uses `/oauth/token` - with `grant_type=refresh_token`, the public `client_id`, and `refresh_token`. - It does not require browser cookies, another owner login, or client secrets: - possession of the refresh token is the authorization. The client, original - canonical resource and `qmcp:access` scope remain bound. An omitted `resource` - reuses the original resource (RFC 8707); if supplied, it must match exactly. - Scope must be omitted or exactly `qmcp:access`, never expanded. Host, no-Origin, - duplicate-security-parameter and no-POST-query guards still apply. -- Every successful refresh atomically consumes its token and issues new access - and refresh tokens. Consumed digests remain until family expiry, including - after revocation. Reusing a consumed token with valid client/resource/scope - bindings returns `400 invalid_grant` and revokes **all access and refresh tokens - in that family**, not other families (even for the same client). Wrong bindings - or malformed requests do not consume a valid token or revoke a family. Clients - must serialize refreshes: concurrent exchanges issue at most one response, - and the other request is a replay that revokes even the winner's tokens. A lost - successful response followed by retry also requires a new owner authorization. - There is no refresh reuse grace period, external-provider delegation or silent - recovery after replay, absolute expiry, or the chain limit. -- All state is **volatile**. Restart invalidates grants, tokens, transactions, - and DCR client IDs; ChatGPT may require removing and re-adding the server, - not just signing in again. Valid owner approval retains that client identity - until process restart; only unapproved registrations expire after one hour. - There is no database, registration management API, per-client revocation, or - persistence. -- Capacity is 128 clients (at most 96 unapproved), 128 browser transactions, - 128 codes and 256 access tokens. Approved identities are never evicted by spam. - Each client can hold at most eight transactions, eight codes and 16 access - tokens. Refresh state is capped at 128 families globally, eight per client, - 8,192 total refresh digests, and 128 digests per family (the original plus at - most 127 rotations). Revoked families still occupy their slots until expiry. - Live access tokens are retained until expiry or family revocation; frequent - refreshes can hit the access-token limit before the chain limit. Capacity - exhaustion returns `503 temporarily_unavailable` without consuming a valid - code/current refresh token; rate exhaustion returns `429 slow_down`. No replay - evidence is evicted to make room. At the chain limit a new owner authorization - is needed. All expired state is pruned on use. Per-minute limits are 30 - authorizations and 60 known-code/validly-bound-refresh exchanges per client; - unknown credentials cannot spend a named client's exchange budget. Registration has no authenticated client identity: - each validated callback origin gets 16 registrations/minute, with at most - 256 fixed-memory origin buckets. Aggregate ceilings are 1,024 registration - requests and 4,096 authorization/token requests per endpoint per minute, - not a tiny shared owner-login lockout. - AS bodies have an 8 KiB limit, 16 concurrent readers and a ten-second read - timeout. Limits never trust `X-Forwarded-For` or other client-IP/proxy headers. - Residual resource DoS remains possible: many clients/callback origins, or - requests naming a public client ID, can exhaust table/CPU/network capacity - or that client's authorization window. Configure deployment-specific - connection/concurrency, request-rate, body-size, and upload-time limits at - the trusted proxy (and its trusted source-IP configuration). Application - limits are not a substitute for those controls. No automatic eviction of - live approved clients or grants is attempted. -- This is a **single-owner backend**, not multi-user isolation. Approval grants - all tools on the connected QueryMT instance. `--permission cancel` controls - agent tool approval, not OAuth scope. Protect the owner secret accordingly. - For rotation or global revocation, replace the protected file and perform a - reviewed restart. Keep TLS termination, proxy logs (no query/body/header or - token-response logging for OAuth), firewall and the private upstream secure. +The default listener is `127.0.0.1:3031`. For a separate proxy, use the +[Nginx/VPN template](#separate-nginx-server-to-a-mac-over-vpn). Forward `/mcp`, +`/.well-known/oauth-authorization-server`, both protected-resource discovery +paths, and `/oauth/register`, `/oauth/authorize`, `/oauth/token`. Preserve Host, +Authorization, Origin, and queries. The public resource Host is automatically +allowed; local/external OAuth and static Bearer modes are mutually exclusive. + +In ChatGPT, add the public `/mcp` URL, choose **OAuth**, and use DCR. Do not put +the owner credential in client ID/secret fields. During sign-in verify the domain, +unverified client name, and exact callback. Enter the credential on qmcp's owner +page and explicitly approve or deny, then click **Continue to ChatGPT**. +Registration alone grants nothing. Custom MCP servers must be enabled in your +workspace; live sign-in still needs deployment validation. + +Security/lifetime constraints: + +- Public clients use authorization-code + PKCE `S256`; DCR may additionally request + `refresh_token`. Exact client, redirect, resource, and scope are bound to grants. + Metadata URLs are never fetched. Login requires an issuer-Origin form POST, + a browser cookie, CSRF token, and explicit owner consent. Other routes reject Origin. +- Approval grants all tools on this backend (`qmcp:access`), not per-user isolation. + `--permission cancel` controls agent approvals, not MCP authentication. +- Access lasts up to 30 minutes; rotating one-use refresh tokens have an absolute + 24-hour family deadline. Replay revokes the entire family. Serialize refreshes: + concurrent refreshes or retrying a lost successful response can revoke the winner. +- Credentials are stored as digests; state is bounded and volatile. Restart invalidates client IDs and tokens; + ChatGPT may require removing/re-adding the server. Approved clients survive until + restart; unapproved registrations expire after one hour. No persistence is supplied. +- Capacity/rate errors do not evict replay evidence or consume valid credentials. + A family allows at most 127 rotations; exhaustion requires owner authorization + again. Keep proxy connection/body/rate limits and private logs: application bounds + do not prevent all resource exhaustion. Never log OAuth queries, bodies, or tokens. ## External OAuth Provider (Optional) @@ -387,37 +227,17 @@ configuration and has not been verified by the project's mock-provider tests. ## Separate Nginx server to a Mac over VPN -Topology: ChatGPT -> Nginx (public HTTPS) -> Mac (private/VPN HTTP) -> -local QueryMT. -This is a deployment template, not a claim of a verified live connection. -Use embedded OAuth on the Mac; an external provider is optional. TLS and -proxying alone do not enable ChatGPT authentication; a static bearer is not a -substitute. If you choose external OAuth instead, an arbitrary OIDC provider -may lack the introspection endpoint or claims described in the optional section. - -Replace every `mcp.example.com` with your public domain and every `10.0.0.42` -with the Mac's actual private/VPN IP; both are placeholders. Point public DNS to -Nginx, not the Mac, and use a valid TLS certificate for that domain on Nginx. - -On the Mac, keep QueryMT on `ws://127.0.0.1:3030/acp/ws` as described above. -Generate the protected owner secret as described above. Leave -`QMCP_BEARER_TOKEN` and `QMCP_OAUTH_CLIENT_SECRET` unset. After reviewing the -configuration, run on the Mac (all addresses and paths here are examples): +Topology: ChatGPT -> public HTTPS Nginx -> encrypted VPN -> qmcp on Mac:3031 +-> local QueryMT. Replace the example domain, VPN address, and certificate paths. +Keep QueryMT's ACP port private. On the Mac, use the protected owner file above: ```sh -qmcp --transport http --bind 10.0.0.42:3031 \ - --allowed-host mcp.example.com --permission cancel \ +qmcp --transport http --bind 10.0.0.42:3031 --permission cancel \ --oauth-local-resource https://mcp.example.com/mcp \ --oauth-owner-secret-file /absolute/private/path/to/owner-secret ``` -`--bind` takes an IP:port socket address, not a URL or hostname. This binds only -the Mac's VPN/private interface; OAuth is required for this non-loopback bind. -Use the installed qmcp binary's absolute path if it is not on `PATH`. - -On the separate Nginx host, adapt this HTTPS server template; certificate paths -are placeholders too. Keep the upstream without a URI suffix so MCP, discovery, -and all embedded authorization-server endpoints and queries reach qmcp unchanged: +On the separate proxy host (not deployment instructions to execute during review): ```nginx server { @@ -425,45 +245,29 @@ server { server_name mcp.example.com; ssl_certificate /path/to/fullchain.pem; ssl_certificate_key /path/to/privkey.pem; - location / { proxy_pass http://10.0.0.42:3031; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header Authorization $http_authorization; proxy_set_header Origin $http_origin; - access_log off; # OAuth queries contain authorization codes/state. proxy_set_header Connection ""; + access_log off; # OAuth queries contain codes/state. proxy_buffering off; proxy_request_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } - } ``` -The Host must match `--allowed-host`; do not replace it with the upstream IP. -Forward Authorization unchanged; allow token-free discovery and preserve -qmcp's `WWW-Authenticate` responses. Do not strip Origin to bypass qmcp's -checks. -Buffering/cache are disabled for Streamable HTTP; the example timeouts are -inactivity limits, not a guarantee of task duration. Align any other proxy or -load-balancer timeouts with long-lived requests. - -Allow inbound TCP 3031 on the Mac only from the Nginx host's VPN source IP, -over the VPN interface; deny other sources. Do not publish/port-forward 3031 or -expose QueryMT's ACP port. Plain HTTP here assumes the proxy-to-Mac hop uses the -encrypted VPN, not an untrusted LAN. Verify Nginx can route to that Mac address; -keep the VPN connected, the Mac awake/reachable, and QueryMT and qmcp running. -Validate the adapted Nginx configuration with `nginx -t` before deploying it. -After deployment, the AS metadata and both resource-discovery paths should -return metadata without a token, and `/mcp` should reject missing/invalid tokens. -Only then follow the embedded OAuth ChatGPT setup above using -`https://mcp.example.com/mcp`, not the private IP. Deployment and real sign-in -verification are separate from source/build review; do not restart a live -service merely to run tests. +The upstream has no URI suffix, so discovery and authorization paths reach qmcp +unchanged. Never strip Origin to bypass validation. Firewall Mac port 3031 to the +proxy's VPN source only; do not expose it or ACP publicly. Plain HTTP assumes the +encrypted VPN hop, not an untrusted LAN. Keep the Mac awake and VPN reachable. +Validate adapted Nginx configuration before deployment. Source tests do not prove +real ChatGPT sign-in and must not restart installed services. ## Remote Access Checklist diff --git a/src/commands.rs b/src/commands.rs index ffda179..e221574 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1066,7 +1066,13 @@ async fn inspect( let (loaded, load_value) = client .load_session(session_id.clone(), cwd.clone()) .await - .map_err(CliError::from_request)?; + .map_err(|error| { + if error.to_string().contains("Loaded session has no runtime ref for ACP replay") { + CliError::rpc("QueryMT backend cannot replay this session's history: Loaded session has no runtime ref for ACP replay. History is unavailable, not empty; qmcp cannot repair the owning runtime. No fallback was attempted.") + } else { + CliError::from_request(error) + } + })?; if full { return Ok(json!({ "sessionId": session_id, @@ -1375,8 +1381,8 @@ async fn follow( ) .await?; let mut text = client.take_assistant_text().await; - if text.is_empty() - && let Ok(snapshot) = inspect( + if text.is_empty() { + let snapshot = inspect( client, session_id.clone(), cwd.clone(), @@ -1384,8 +1390,7 @@ async fn follow( Some(1), false, ) - .await - { + .await?; text = session::last_assistant_text(&snapshot).unwrap_or_default(); } Ok(json!({ diff --git a/src/local_oauth.rs b/src/local_oauth.rs index 6e3f8f8..407ac95 100644 --- a/src/local_oauth.rs +++ b/src/local_oauth.rs @@ -310,9 +310,7 @@ impl LocalOAuth { } pub(crate) fn guard(&self, router: Router) -> Router { - router - .layer(middleware::from_fn_with_state(self.clone(), guard)) - .layer(middleware::from_fn(diagnostics)) + router.layer(middleware::from_fn_with_state(self.clone(), guard)) } fn accepts(&self, credential: &str) -> bool { @@ -329,132 +327,6 @@ fn digest(value: &str) -> Digest32 { Sha256::digest(value.as_bytes()).into() } -// Only fixed classifications cross into diagnostics; never request data or errors. -#[derive(Clone, Copy)] -struct DiagnosticError(&'static str); - -fn diagnostic_error(value: &str) -> DiagnosticError { - DiagnosticError(match value { - "access_denied" => "access_denied", - "invalid_request" => "invalid_request", - "invalid_token" => "invalid_token", - "invalid_client" => "invalid_client", - "invalid_client_metadata" => "invalid_client_metadata", - "invalid_grant" => "invalid_grant", - "invalid_target" => "invalid_target", - "invalid_scope" => "invalid_scope", - "unsupported_response_type" => "unsupported_response_type", - "unsupported_grant_type" => "unsupported_grant_type", - "slow_down" => "slow_down", - "temporarily_unavailable" => "temporarily_unavailable", - _ => "other", - }) -} - -#[derive(Clone, Copy)] -enum DiagnosticReason { - HostRejected, - OriginRejected, - PostQuery, - UriTooLong, - InvalidJson, - InvalidRedirect, - InvalidClientName, - UnsupportedAuthMethod, - UnsupportedGrant, - UnsupportedResponse, - UnsupportedScope, - RegistrationRate, - OriginRate, - OriginCapacity, - ClientCapacity, - BodyRejected, - RandomUnavailable, - Registered, -} - -impl DiagnosticReason { - fn label(self) -> &'static str { - match self { - Self::HostRejected => "host_rejected", - Self::OriginRejected => "origin_rejected", - Self::PostQuery => "post_query", - Self::UriTooLong => "uri_too_long", - Self::InvalidJson => "invalid_json", - Self::InvalidRedirect => "invalid_redirect", - Self::InvalidClientName => "invalid_client_name", - Self::UnsupportedAuthMethod => "unsupported_auth_method", - Self::UnsupportedGrant => "unsupported_grant", - Self::UnsupportedResponse => "unsupported_response", - Self::UnsupportedScope => "unsupported_scope", - Self::RegistrationRate => "registration_rate", - Self::OriginRate => "origin_rate", - Self::OriginCapacity => "origin_capacity", - Self::ClientCapacity => "client_capacity", - Self::BodyRejected => "body_rejected", - Self::RandomUnavailable => "random_unavailable", - Self::Registered => "registered", - } - } - - fn attach(self, mut response: Response) -> Response { - response.extensions_mut().insert(self); - response - } -} - -fn diagnostic_route(path: &str) -> &'static str { - match path { - "/mcp" => "/mcp", - "/.well-known/oauth-protected-resource" => "/.well-known/oauth-protected-resource", - "/.well-known/oauth-protected-resource/mcp" => "/.well-known/oauth-protected-resource/mcp", - "/.well-known/oauth-authorization-server" => "/.well-known/oauth-authorization-server", - "/oauth/register" => "/oauth/register", - "/oauth/authorize" => "/oauth/authorize", - "/oauth/token" => "/oauth/token", - _ => "/other", - } -} - -fn diagnostic_method(method: &Method) -> &'static str { - match *method { - Method::GET => "GET", - Method::HEAD => "HEAD", - Method::POST => "POST", - Method::PUT => "PUT", - Method::PATCH => "PATCH", - Method::DELETE => "DELETE", - Method::OPTIONS => "OPTIONS", - Method::CONNECT => "CONNECT", - Method::TRACE => "TRACE", - _ => "OTHER", - } -} - -async fn diagnostics(request: Request, next: Next) -> Response { - let route = diagnostic_route(request.uri().path()); - let method = diagnostic_method(request.method()); - let response = next.run(request).await; - let error = response.extensions().get::(); - let reason = response.extensions().get::(); - let registration = response.extensions().get::(); - tracing::info!( - target: "qmcp::oauth_diagnostics", - route, - method, - status = response.status().as_u16(), - oauth_error = error.map_or("none", |e| e.0), - reason = reason.map_or("none", |r| r.label()), - auth_method = registration.map(|r| r.auth_method), - grant_types_present = registration.map(|r| r.grant_types_present), - grant_authcode = registration.map(|r| r.grant_authcode), - grant_refresh = registration.map(|r| r.grant_refresh), - grant_other = registration.map(|r| r.grant_other), - "local OAuth request" - ); - response -} - struct Failure(StatusCode, &'static str); impl IntoResponse for Failure { @@ -490,9 +362,7 @@ fn secure(mut response: Response) -> Response { } fn error(status: StatusCode, name: &'static str) -> Response { - let mut response = secure((status, Json(json!({"error": name}))).into_response()); - response.extensions_mut().insert(diagnostic_error(name)); - response + secure((status, Json(json!({"error": name}))).into_response()) } fn one_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { @@ -513,20 +383,13 @@ async fn guard(State(auth): State, request: Request, next: Next) -> !request.headers().contains_key("origin") }; if !host_ok || !origin_ok { - let reason = if !host_ok { - DiagnosticReason::HostRejected - } else { - DiagnosticReason::OriginRejected - }; - return reason.attach(error(StatusCode::FORBIDDEN, "access_denied")); + return error(StatusCode::FORBIDDEN, "access_denied"); } if request.method() == Method::POST && request.uri().query().is_some() { - return DiagnosticReason::PostQuery - .attach(error(StatusCode::BAD_REQUEST, "invalid_request")); + return error(StatusCode::BAD_REQUEST, "invalid_request"); } if request.uri().to_string().len() > MAX_BODY { - return DiagnosticReason::UriTooLong - .attach(error(StatusCode::URI_TOO_LONG, "invalid_request")); + return error(StatusCode::URI_TOO_LONG, "invalid_request"); } let mut response = secure(next.run(request).await); if response @@ -701,42 +564,6 @@ struct Registration { scope: Option, } -#[derive(Clone, Copy)] -struct RegistrationDiagnostic { - auth_method: &'static str, - grant_types_present: bool, - grant_authcode: bool, - grant_refresh: bool, - grant_other: bool, -} - -fn registration_diagnostic(registration: &Registration) -> RegistrationDiagnostic { - let grants = registration.grant_types.as_deref().unwrap_or_default(); - RegistrationDiagnostic { - auth_method: match registration.token_endpoint_auth_method.as_deref() { - None => "omitted", - Some("none") => "none", - Some("client_secret_basic") => "client_secret_basic", - Some("client_secret_post") => "client_secret_post", - Some(_) => "other", - }, - grant_types_present: registration.grant_types.is_some(), - grant_authcode: grants.iter().any(|g| g == "authorization_code"), - grant_refresh: grants.iter().any(|g| g == "refresh_token"), - grant_other: grants - .iter() - .any(|g| g != "authorization_code" && g != "refresh_token"), - } -} - -impl RegistrationDiagnostic { - fn attach(self, response: Response, reason: DiagnosticReason) -> Response { - let mut response = reason.attach(response); - response.extensions_mut().insert(self); - response - } -} - fn valid_redirect(value: &str) -> bool { let Ok(url) = Url::parse(value) else { return false; @@ -769,73 +596,56 @@ async fn register(State(auth): State, request: Request) -> Response let mut store = auth.0.store.lock().unwrap(); store.prune(now); if !store.registration_rate.allow(now, REGISTRATION_RATE) { - return DiagnosticReason::RegistrationRate - .attach(error(StatusCode::TOO_MANY_REQUESTS, "slow_down")); + return error(StatusCode::TOO_MANY_REQUESTS, "slow_down"); } } let bytes = match body(&auth, request, "application/json").await { - Ok(b) => b, - Err(e) => return DiagnosticReason::BodyRejected.attach(e.into_response()), + Ok(bytes) => bytes, + Err(error) => return error.into_response(), }; let registration: Registration = match serde_json::from_slice(&bytes) { - Ok(r) => r, - Err(_) => { - return DiagnosticReason::InvalidJson - .attach(error(StatusCode::BAD_REQUEST, "invalid_client_metadata")); - } + Ok(registration) => registration, + Err(_) => return error(StatusCode::BAD_REQUEST, "invalid_client_metadata"), }; - let diagnostic = registration_diagnostic(®istration); - let rejection = if registration.redirect_uris.is_empty() + if registration.redirect_uris.is_empty() || registration.redirect_uris.len() > 8 || registration .redirect_uris .iter() - .any(|u| !valid_redirect(u)) - { - Some(DiagnosticReason::InvalidRedirect) - } else if registration - .client_name - .as_ref() - .is_some_and(|n| n.len() > 128 || n.chars().any(char::is_control)) - { - Some(DiagnosticReason::InvalidClientName) - } else if registration - .token_endpoint_auth_method - .as_deref() - .is_some_and(|m| m != "none") - { - Some(DiagnosticReason::UnsupportedAuthMethod) - } else if registration.grant_types.as_ref().is_some_and(|g| { - !g.iter().any(|v| v == "authorization_code") - || g.iter() - .any(|v| v != "authorization_code" && v != "refresh_token") - || g.iter().collect::>().len() != g.len() - }) { - Some(DiagnosticReason::UnsupportedGrant) - } else if registration - .response_types - .as_ref() - .is_some_and(|r| r != &["code"]) + .any(|uri| !valid_redirect(uri)) + || registration + .client_name + .as_ref() + .is_some_and(|name| name.len() > 128 || name.chars().any(char::is_control)) + || registration + .token_endpoint_auth_method + .as_deref() + .is_some_and(|method| method != "none") + || registration.grant_types.as_ref().is_some_and(|grants| { + !grants.iter().any(|grant| grant == "authorization_code") + || grants + .iter() + .any(|grant| grant != "authorization_code" && grant != "refresh_token") + || grants.iter().collect::>().len() != grants.len() + }) + || registration + .response_types + .as_ref() + .is_some_and(|types| types != &["code"]) + || registration + .scope + .as_deref() + .is_some_and(|scope| scope != SCOPE) { - Some(DiagnosticReason::UnsupportedResponse) - } else if registration.scope.as_deref().is_some_and(|s| s != SCOPE) { - Some(DiagnosticReason::UnsupportedScope) - } else { - None - }; - if let Some(reason) = rejection { - return diagnostic.attach( - error(StatusCode::BAD_REQUEST, "invalid_client_metadata"), - reason, - ); + return error(StatusCode::BAD_REQUEST, "invalid_client_metadata"); } let id = match random() { Ok(id) => id, - Err(e) => return diagnostic.attach(e.into_response(), DiagnosticReason::RandomUnavailable), + Err(error) => return error.into_response(), }; let mut store = auth.0.store.lock().unwrap(); - // No client identity exists yet: bound registrations by validated callback - // origin as well as aggregate work. Never fetch descriptive metadata URLs. + // Bound registrations by validated callback origin and aggregate work. + // Descriptive metadata URLs are never fetched. let origins: HashSet<_> = registration .redirect_uris .iter() @@ -846,10 +656,7 @@ async fn register(State(auth): State, request: Request) -> Response .filter(|origin| !store.registration_origins.contains_key(*origin)) .count(); if store.registration_origins.len() + new_origins > MAX_REGISTRATION_ORIGINS { - return diagnostic.attach( - error(StatusCode::TOO_MANY_REQUESTS, "slow_down"), - DiagnosticReason::OriginCapacity, - ); + return error(StatusCode::TOO_MANY_REQUESTS, "slow_down"); } for origin in origins { if !store @@ -858,10 +665,7 @@ async fn register(State(auth): State, request: Request) -> Response .or_default() .allow(now, 16) { - return diagnostic.attach( - error(StatusCode::TOO_MANY_REQUESTS, "slow_down"), - DiagnosticReason::OriginRate, - ); + return error(StatusCode::TOO_MANY_REQUESTS, "slow_down"); } } if store.clients.len() >= MAX_CLIENTS @@ -872,12 +676,12 @@ async fn register(State(auth): State, request: Request) -> Response .count() >= MAX_UNAPPROVED_CLIENTS { - return diagnostic.attach( - error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"), - DiagnosticReason::ClientCapacity, - ); + return error(StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable"); } - let refresh_allowed = diagnostic.grant_refresh; + let refresh_allowed = registration + .grant_types + .as_ref() + .is_some_and(|grants| grants.iter().any(|grant| grant == "refresh_token")); let grants = if refresh_allowed { vec!["authorization_code", "refresh_token"] } else { @@ -908,7 +712,7 @@ async fn register(State(auth): State, request: Request) -> Response exchange_rate: Rate::default(), }, ); - diagnostic.attach(response, DiagnosticReason::Registered) + response } fn completion( @@ -930,14 +734,10 @@ fn completion( } // An ordinary user-clicked link is not a form-action redirect. Keep the // callback/code private and do not navigate externally without another click. - let mut response = Html(format!( + Html(format!( "Complete qmcp authorization

Complete authorization

Continue to the callback you reviewed, including after denial.

Continue to ChatGPT", escape(url.as_str()) - )).into_response(); - if key == "error" { - response.extensions_mut().insert(diagnostic_error(value)); - } - response + )).into_response() } fn escape(text: &str) -> String { diff --git a/src/local_oauth/tests.rs b/src/local_oauth/tests.rs index ca192bc..8a0b26a 100644 --- a/src/local_oauth/tests.rs +++ b/src/local_oauth/tests.rs @@ -305,423 +305,43 @@ impl Harness { } } -#[derive(Clone, Default)] -struct DiagnosticWriter(Arc>>); - -impl std::io::Write for DiagnosticWriter { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} - -fn capture_diagnostics(filter: &str, future: impl std::future::Future) -> String { - let writer = DiagnosticWriter::default(); - let output = writer.clone(); - let subscriber = tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::new(filter)) - .without_time() - .with_ansi(false) - .with_writer(move || writer.clone()) - .finish(); - // A current-thread runtime keeps every request under this scoped subscriber. - tracing::subscriber::with_default(subscriber, || { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(future); - }); - String::from_utf8(output.0.lock().unwrap().clone()).unwrap() -} - -#[test] -fn diagnostic_classifiers_only_return_fixed_labels() { - for (input, expected) in [ - (None, "omitted"), - (Some("none"), "none"), - (Some("client_secret_basic"), "client_secret_basic"), - (Some("client_secret_post"), "client_secret_post"), - (Some("malicious\nsecret"), "other"), - (Some("NONE"), "other"), - ] { - let registration = Registration { - redirect_uris: vec![CALLBACK.into()], - client_name: None, - token_endpoint_auth_method: input.map(str::to_owned), - grant_types: Some(vec![ - "authorization_code".into(), - "refresh_token".into(), - "malicious\nsecret".into(), - ]), - response_types: None, - scope: None, - }; - let safe = registration_diagnostic(®istration); - assert_eq!(safe.auth_method, expected); - assert!( - safe.grant_types_present - && safe.grant_authcode - && safe.grant_refresh - && safe.grant_other - ); - } - assert_eq!(diagnostic_route("/oauth/register/malicious"), "/other"); +#[tokio::test] +async fn token_protects_real_mcp_transport_without_origin_stripping() { + let h = Harness::new(); + let unauthenticated = h.request("POST", "/mcp", String::new(), &[]).await; + assert_eq!(unauthenticated.status, StatusCode::UNAUTHORIZED); assert_eq!( - diagnostic_method(&Method::from_bytes(b"MALICIOUS").unwrap()), - "OTHER" + unauthenticated.headers[header::WWW_AUTHENTICATE], + format!( + "Bearer resource_metadata=\"{ISSUER}/.well-known/oauth-protected-resource/mcp\", scope=\"{SCOPE}\"" + ) ); - assert_eq!(diagnostic_error("malicious\nsecret").0, "other"); -} - -#[test] -fn diagnostics_capture_safe_routes_errors_and_dcr_rejections_only() { - let mut secrets = Vec::new(); - let output = capture_diagnostics("warn,qmcp::oauth_diagnostics=info", async { - let h = Harness::new(); - let r = h - .request( - "GET", - "/canary-path?secret=canary-querysecret", - String::new(), - &[], - ) - .await; - assert_eq!(r.status, StatusCode::NOT_FOUND); - let r = h - .request("CANARYMETHOD", "/canary-path", String::new(), &[]) - .await; - assert_eq!(r.status, StatusCode::NOT_FOUND); - let r = h - .request( - "GET", - "/mcp?secret=canary-querysecret", - String::new(), - &[ - ("authorization", "Bearer canary-token"), - ("cookie", "canary-cookie"), - ], - ) - .await; - assert_eq!(r.status, StatusCode::UNAUTHORIZED); - for (header, value, reason) in [ - ("host", "canary-host", "host_rejected"), - ("origin", "https://canary-origin.example", "origin_rejected"), - ] { - let r = h - .request( - "GET", - "/canary-path?secret=canary-querysecret", - String::new(), - &[(header, value)], - ) - .await; - assert_eq!(r.status, StatusCode::FORBIDDEN, "{reason}"); - } - let r = h - .request( - "POST", - "/oauth/register?secret=canary-querysecret", - "canary-body".into(), - &[], - ) - .await; - assert_eq!(r.status, StatusCode::BAD_REQUEST); - let r = h - .request( - "POST", - "/oauth/register", - "{\"canary-unknownmetadatakey-malicious\":\"canary-body\", broken".into(), - &[("content-type", "application/json")], - ) - .await; - assert_eq!(r.json(), json!({"error": "invalid_client_metadata"})); - let invalid = [ - ( - json!({"redirect_uris": ["https://canary-redirect.example/cb#canary-code"]}), - "invalid_redirect", - ), - ( - json!({"redirect_uris": [CALLBACK], "client_name": "canary-name\n"}), - "invalid_client_name", - ), - ( - json!({"redirect_uris": [CALLBACK], "token_endpoint_auth_method": "client_secret_basic"}), - "unsupported_auth_method", - ), - ( - json!({"redirect_uris": [CALLBACK], "token_endpoint_auth_method": "client_secret_post"}), - "unsupported_auth_method", - ), - ( - json!({"redirect_uris": [CALLBACK], "token_endpoint_auth_method": "canary-authmethod"}), - "unsupported_auth_method", - ), - ( - json!({"redirect_uris": [CALLBACK], "grant_types": ["authorization_code", "refresh_token", "canary-grant"]}), - "unsupported_grant", - ), - ( - json!({"redirect_uris": [CALLBACK], "response_types": ["canary-response"]}), - "unsupported_response", - ), - ( - json!({"redirect_uris": [CALLBACK], "scope": "canary-scope"}), - "unsupported_scope", - ), - ]; - for (metadata, _) in invalid { - let r = h.register(metadata).await; - assert_eq!(r.status, StatusCode::BAD_REQUEST); - assert_eq!(r.json(), json!({"error": "invalid_client_metadata"})); - assert!(h.auth.0.store.lock().unwrap().clients.is_empty()); - assert!( - !r.headers - .keys() - .any(|key| key.as_str().contains("diagnostic")) - ); - } - for path in [ - "/.well-known/oauth-protected-resource", - "/.well-known/oauth-protected-resource/mcp", - "/.well-known/oauth-authorization-server", - ] { - assert_eq!( - h.request("GET", path, String::new(), &[]).await.status, - StatusCode::OK - ); - } - let r = h - .register(json!({ - "redirect_uris": [CALLBACK], "token_endpoint_auth_method": "none", - "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scope": SCOPE, - "canary-unknownmetadatakey-malicious": {"nested": "canary-body"}, - "logo_uri": "https://canary-logo.example/icon" - })) - .await; - assert_eq!(r.status, StatusCode::CREATED); - assert_eq!(r.json()["token_endpoint_auth_method"], "none"); - let client = r.json()["client_id"].as_str().unwrap().to_owned(); - let page = h - .authorize(&client, &[("future_extension", "canary-querysecret")]) - .await; - let (cookie, csrf) = Harness::browser(&page); - let r = h - .consent(&cookie, &csrf, "canary-owner", "approve", Some(ISSUER)) - .await; - assert_eq!(r.parameter("error"), "access_denied"); - let code = h.code(&client).await; - let r = h - .exchange( - &client, - &code, - &[("future_extension", "canary-clientsecret")], - ) - .await; - assert_eq!(r.status, StatusCode::OK); - let token = r.json()["access_token"].as_str().unwrap().to_owned(); - // Check generated credentials too, not just recognizable input canaries. - let refresh = r.json()["refresh_token"].as_str().unwrap().to_owned(); - let rotated = h - .refresh( - &client, - &refresh, - &[("future_extension", "canary-refresh-secret")], - ) - .await; - assert_eq!(rotated.status, StatusCode::OK); - secrets.push(rotated.json()["refresh_token"].as_str().unwrap().to_owned()); - secrets.push(rotated.json()["access_token"].as_str().unwrap().to_owned()); - assert_eq!( - h.refresh(&client, &refresh, &[]).await.json()["error"], - "invalid_grant" - ); - assert_eq!( - h.refresh(&client, "canary-refresh-secret", &[]) - .await - .status, - StatusCode::BAD_REQUEST - ); - secrets.extend([client, code, token, refresh, cookie, csrf]); - let r = h - .exchange( - "canary-clientid", - "canary-code", - &[("client_secret", "canary-clientsecret")], - ) - .await; - assert_eq!(r.status, StatusCode::BAD_REQUEST); - assert_eq!( - h.register(json!({"redirect_uris": [CALLBACK]})) - .await - .status, - StatusCode::CREATED - ); - }); - assert!(!output.is_empty()); - for line in output.lines() { - assert!(line.contains("qmcp::oauth_diagnostics")); - assert!(!line.to_ascii_lowercase().contains("canary"), "{line}"); - assert!(!line.contains(SECRET), "{line}"); - } - for secret in secrets { - assert!(!secret.is_empty()); - assert!(!output.contains(&secret)); - } - for expected in [ - "route=\"/other\" method=\"GET\" status=404", - "route=\"/other\" method=\"OTHER\" status=404", - "route=\"/mcp\" method=\"GET\" status=401 oauth_error=\"invalid_token\"", - "route=\"/oauth/register\" method=\"POST\" status=400", - "status=201 oauth_error=\"none\" reason=\"registered\"", - "reason=\"host_rejected\"", - "reason=\"origin_rejected\"", - "reason=\"post_query\"", - "reason=\"invalid_json\"", - "reason=\"invalid_redirect\"", - "reason=\"invalid_client_name\"", - "reason=\"unsupported_auth_method\"", - "reason=\"unsupported_grant\"", - "reason=\"unsupported_response\"", - "reason=\"unsupported_scope\"", - "auth_method=\"none\"", - "auth_method=\"omitted\"", - "auth_method=\"other\"", - "auth_method=\"client_secret_basic\"", - "auth_method=\"client_secret_post\"", - "grant_authcode=true grant_refresh=true grant_other=true", - "status=201 oauth_error=\"none\" reason=\"registered\" auth_method=\"none\" grant_types_present=true grant_authcode=true grant_refresh=true grant_other=false", - "route=\"/oauth/authorize\" method=\"POST\" status=200 oauth_error=\"access_denied\"", - "route=\"/oauth/token\" method=\"POST\" status=400 oauth_error=\"invalid_grant\"", - "route=\"/.well-known/oauth-protected-resource\"", - "route=\"/.well-known/oauth-protected-resource/mcp\"", - "route=\"/.well-known/oauth-authorization-server\"", - ] { - assert!(output.contains(expected), "missing {expected}: {output}"); - } - let disabled = capture_diagnostics("warn", async { - let h = Harness::new(); - assert_eq!( - h.register(json!({"redirect_uris": [CALLBACK]})) - .await - .status, - StatusCode::CREATED - ); - }); - assert!(disabled.is_empty(), "{disabled}"); -} - -#[test] -fn diagnostics_preserve_dcr_body_rate_and_capacity_failures() { - let output = capture_diagnostics("warn,qmcp::oauth_diagnostics=info", async { - let h = Harness::new(); - for (body, headers, status) in [ - ( - "canary-body".into(), - vec![], - StatusCode::UNSUPPORTED_MEDIA_TYPE, - ), - ( - "x".repeat(MAX_BODY + 1), - vec![("content-type", "application/json")], - StatusCode::PAYLOAD_TOO_LARGE, - ), - ] { - assert_eq!( - h.request("POST", "/oauth/register", body, &headers) - .await - .status, - status - ); - } - let permits = h.auth.0.body_slots.acquire_many(16).await.unwrap(); - assert_eq!( - h.register(json!({"redirect_uris": [CALLBACK]})) - .await - .status, - StatusCode::TOO_MANY_REQUESTS - ); - drop(permits); - h.auth.0.store.lock().unwrap().registration_rate = Rate { - start: Some(Instant::now()), - used: REGISTRATION_RATE, - }; - assert_eq!( - h.register(json!({"redirect_uris": [CALLBACK]})) - .await - .status, - StatusCode::TOO_MANY_REQUESTS - ); - - let h = Harness::new(); - for _ in 0..16 { - h.client().await; - } - assert_eq!( - h.register(json!({"redirect_uris": [CALLBACK]})) - .await - .status, - StatusCode::TOO_MANY_REQUESTS - ); - { - let mut store = h.auth.0.store.lock().unwrap(); - for i in store.registration_origins.len()..MAX_REGISTRATION_ORIGINS { - store.registration_origins.insert( - format!("https://origin-{i}.example"), - Rate { - start: Some(Instant::now()), - used: 1, - }, - ); - } - } - assert_eq!( - h.register(json!({"redirect_uris": ["https://new-origin.example/cb"]})) - .await - .status, - StatusCode::TOO_MANY_REQUESTS - ); - { - let mut store = h.auth.0.store.lock().unwrap(); - store.registration_origins.clear(); - for i in store.clients.len()..MAX_UNAPPROVED_CLIENTS { - store.clients.insert( - format!("canary-clientid-{i}"), - Client { - redirects: vec![CALLBACK.into()], - name: "canary-clientname".into(), - expires: Instant::now() + CLIENT_TTL, - approved: false, - refresh_allowed: false, - authorization_rate: Rate::default(), - login_rate: Rate::default(), - exchange_rate: Rate::default(), - }, - ); - } + let client = h.client().await; + let tokens = h.initial_tokens(&client).await; + let access = tokens["access_token"].as_str().unwrap(); + let bearer = format!("Bearer {access}"); + let forbidden = h + .request( + "POST", + "/mcp", + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}).to_string(), + &[ + ("authorization", &bearer), + ("origin", ISSUER), + ("content-type", "application/json"), + ("accept", "application/json, text/event-stream"), + ], + ) + .await; + assert_eq!(forbidden.status, StatusCode::FORBIDDEN); + assert_eq!(h.mcp_status(access).await, StatusCode::OK); + for reply in [unauthenticated, forbidden] { + let output = format!("{:?}{}", reply.headers, reply.text); + for credential in [access, SECRET, VERIFIER] { + assert!(!output.contains(credential)); } - let r = h.register(json!({"redirect_uris": [CALLBACK]})).await; - assert_eq!(r.status, StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(r.json(), json!({"error": "temporarily_unavailable"})); - }); - for expected in [ - "reason=\"body_rejected\"", - "reason=\"registration_rate\"", - "reason=\"origin_rate\"", - "reason=\"origin_capacity\"", - "reason=\"client_capacity\"", - "status=415", - "status=413", - "status=429", - "status=503", - ] { - assert!(output.contains(expected), "missing {expected}: {output}"); } - assert!(!output.contains("canary")); } #[tokio::test] @@ -1041,155 +661,6 @@ async fn code_exchange_pkce_bindings_and_replay() { assert!(!h.auth.accepts("not-a-token")); } -#[tokio::test] -async fn token_protects_real_mcp_transport_without_origin_stripping() { - let h = Harness::new(); - let client = h.client().await; - let code = h.code(&client).await; - let token = h.exchange(&client, &code, &[]).await.json()["access_token"] - .as_str() - .unwrap() - .to_owned(); - let initialize = json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}).to_string(); - for authorization in [None, Some("Bearer bad")] { - let mut headers = vec![ - ("content-type", "application/json"), - ("accept", "application/json, text/event-stream"), - ]; - if let Some(value) = authorization { - headers.push(("authorization", value)); - } - let r = h - .request("POST", "/mcp", initialize.clone(), &headers) - .await; - assert_eq!(r.status, StatusCode::UNAUTHORIZED); - assert!( - r.headers[header::WWW_AUTHENTICATE] - .to_str() - .unwrap() - .contains("resource_metadata") - ); - } - let bearer = format!("Bearer {token}"); - let headers = [ - ("content-type", "application/json"), - ("accept", "application/json, text/event-stream"), - ("authorization", bearer.as_str()), - ]; - let r = h - .request("POST", "/mcp", initialize.clone(), &headers) - .await; - assert_eq!(r.status, StatusCode::OK, "{}", r.text); - assert!(r.json()["result"]["serverInfo"].is_object()); - let mut browser_headers = headers.to_vec(); - browser_headers.push(("origin", ISSUER)); - assert_eq!( - h.request("POST", "/mcp", initialize, &browser_headers) - .await - .status, - StatusCode::FORBIDDEN - ); - h.auth - .0 - .store - .lock() - .unwrap() - .tokens - .get_mut(&digest(&token)) - .unwrap() - .expires = Instant::now() - Duration::from_secs(1); - assert!(!h.auth.accepts(&token)); -} - -#[tokio::test] -async fn expiry_capacity_pruning_and_global_rate_bounds() { - let h = Harness::new(); - let client = h.client().await; - let page = h.authorize(&client, &[]).await; - let (cookie, csrf) = Harness::browser(&page); - for transaction in h.auth.0.store.lock().unwrap().transactions.values_mut() { - transaction.expires = Instant::now() - Duration::from_secs(1); - } - assert_eq!( - h.consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) - .await - .status, - StatusCode::BAD_REQUEST - ); - let code = h.code(&client).await; - h.auth - .0 - .store - .lock() - .unwrap() - .codes - .get_mut(&digest(&code)) - .unwrap() - .expires = Instant::now() - Duration::from_secs(1); - assert_eq!( - h.exchange(&client, &code, &[]).await.status, - StatusCode::BAD_REQUEST - ); - { - let mut store = h.auth.0.store.lock().unwrap(); - for i in 0..MAX_CLIENTS { - store.clients.insert( - format!("filler-{i}"), - Client { - name: String::new(), - redirects: vec![CALLBACK.into()], - expires: Instant::now() + CLIENT_TTL, - approved: false, - refresh_allowed: false, - authorization_rate: Rate::default(), - login_rate: Rate::default(), - exchange_rate: Rate::default(), - }, - ); - } - } - assert_eq!( - h.register(json!({"redirect_uris": [CALLBACK]})) - .await - .status, - StatusCode::SERVICE_UNAVAILABLE - ); - for client in h.auth.0.store.lock().unwrap().clients.values_mut() { - client.approved = false; - client.expires = Instant::now() - Duration::from_secs(1); - } - assert_eq!( - h.register(json!({"redirect_uris": [CALLBACK]})) - .await - .status, - StatusCode::CREATED - ); - assert_eq!(h.auth.0.store.lock().unwrap().clients.len(), 1); - let mut rate = Rate::default(); - let now = Instant::now(); - for _ in 0..8 { - assert!(rate.allow(now, 8)); - } - assert!(!rate.allow(now, 8)); - assert!(rate.allow(now + Duration::from_secs(60), 8)); - let h = Harness::new(); - let client = h.client().await; - for _ in 0..8 { - let page = h.authorize(&client, &[]).await; - let (cookie, csrf) = Harness::browser(&page); - h.consent(&cookie, &csrf, "wrong", "approve", Some(ISSUER)) - .await; - } - let page = h.authorize(&client, &[]).await; - let (cookie, csrf) = Harness::browser(&page); - assert_eq!( - h.consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) - .await - .status, - StatusCode::TOO_MANY_REQUESTS - ); -} - fn grant(client: &str) -> Grant { Grant { client: client.into(), @@ -1281,540 +752,290 @@ async fn every_state_table_is_capped_and_expired_entries_release_slots() { } #[tokio::test] -async fn body_concurrency_duplicates_cookies_and_rate_limits_fail_closed() { +async fn chatgpt_dcr_grant_sets_are_normalized_and_metadata_urls_are_not_fetched() { let h = Harness::new(); - let client = h.client().await; - let permits = h.auth.0.body_slots.acquire_many(16).await.unwrap(); - assert_eq!( - h.register(json!({"redirect_uris": [CALLBACK]})) - .await - .status, - StatusCode::TOO_MANY_REQUESTS - ); - drop(permits); - assert!(fields(b"state=one&state=two").is_err()); - for invalid in [ - b"state=%".as_slice(), - b"state=%GG", - b"state=%FF", - b"state=\xff", + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let metadata_url = format!("http://{}/metadata", listener.local_addr().unwrap()); + for grants in [ + json!(["authorization_code", "refresh_token"]), + json!(["refresh_token", "authorization_code"]), ] { - assert!(fields(invalid).is_err()); - } - assert_eq!( - param(&fields(b"state=%EF%BF%BD").ok().unwrap(), "state"), - "\u{fffd}" - ); - assert!(fields(&vec![b'x'; MAX_BODY + 1]).is_err()); - let page = h.authorize(&client, &[]).await; - let (cookie, csrf) = Harness::browser(&page); - assert_eq!( - h.consent("", &csrf, SECRET, "approve", Some(ISSUER)) - .await - .status, - StatusCode::BAD_REQUEST - ); - let duplicate = format!("{cookie}; {cookie}"); - assert_eq!( - h.consent(&duplicate, &csrf, SECRET, "approve", Some(ISSUER)) - .await - .status, - StatusCode::BAD_REQUEST - ); - let response = h - .request( - "POST", - "/oauth/authorize", - encoded(&[ - ("csrf", &csrf), - ("decision", "approve"), - ("owner_secret", SECRET), - ]), - &[ - ("content-type", "application/x-www-form-urlencoded"), - ("cookie", &cookie), - ("origin", ISSUER), - ("origin", ISSUER), - ], - ) - .await; - assert_eq!(response.status, StatusCode::FORBIDDEN); - h.auth.0.store.lock().unwrap().authorization_rate.used = AGGREGATE_RATE; - assert_eq!( - h.authorize(&client, &[]).await.status, - StatusCode::TOO_MANY_REQUESTS - ); - { - let mut store = h.auth.0.store.lock().unwrap(); - store.exchange_rate.start = Some(Instant::now()); - store.exchange_rate.used = AGGREGATE_RATE; - } - assert_eq!( - h.exchange(&client, "unknown", &[]).await.status, - StatusCode::TOO_MANY_REQUESTS - ); -} - -#[tokio::test] -async fn browser_pages_have_exact_policy_and_private_explicit_completion_links() { - let h = Harness::new(); - let client = h.client().await; - for decision in ["approve", "deny"] { - let page = h.authorize(&client, &[]).await; - assert_eq!(page.headers["referrer-policy"], "same-origin"); - assert_eq!( - page.headers["content-security-policy"], - "default-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" - ); - assert!( - page.text - .contains("method=\"post\" action=\"/oauth/authorize\"") - ); - let (cookie, csrf) = Harness::browser(&page); - let result = h - .consent(&cookie, &csrf, SECRET, decision, Some(ISSUER)) + let reply = h + .register(json!({ + "redirect_uris": [CALLBACK], "client_name": "ChatGPT", + "token_endpoint_auth_method": "none", "grant_types": grants, + "response_types": ["code"], "scope": SCOPE, + "client_uri": metadata_url, "logo_uri": metadata_url, + "application_type": "web", "unknown_safe": {"nested": true} + })) .await; - assert_eq!(result.headers["referrer-policy"], "same-origin"); - assert_eq!(result.headers[header::CACHE_CONTROL], "no-store"); - assert_eq!(result.headers["pragma"], "no-cache"); + assert_eq!(reply.status, StatusCode::CREATED); assert_eq!( - result.headers["content-security-policy"], - page.headers["content-security-policy"] - ); - assert!(!result.headers.contains_key(header::LOCATION)); - assert!( - result - .text - .contains("rel=\"noreferrer noopener\">Continue to ChatGPT") + reply.json()["grant_types"], + json!(["authorization_code", "refresh_token"]) ); - assert!(result.text.contains("&iss=")); - for forbidden in [ - "untrusted", - ] { - assert!(!result.text.contains(forbidden), "{forbidden}"); - } - assert_eq!(result.parameter("state"), STATE); - assert_eq!(result.parameter("iss"), ISSUER); - assert_eq!(result.parameter("existing"), "1"); - if decision == "approve" { - assert_eq!( - h.exchange(&client, &result.parameter("code"), &[]) - .await - .status, - StatusCode::OK - ); - } else { - assert_eq!(result.parameter("error"), "access_denied"); - assert!( - !result - .location() - .query_pairs() - .any(|(key, _)| key == "code") - ); - } + assert_eq!(reply.json()["token_endpoint_auth_method"], "none"); + assert!(!reply.text.contains("metadata")); + let client = reply.json()["client_id"].as_str().unwrap().to_owned(); + assert!(h.auth.0.store.lock().unwrap().clients[&client].refresh_allowed); } - let metadata = h - .request( - "GET", - "/.well-known/oauth-authorization-server", - String::new(), - &[], - ) - .await; - assert_eq!(metadata.headers["referrer-policy"], "no-referrer"); + for metadata in [ + json!({"redirect_uris": [CALLBACK]}), + json!({"redirect_uris": [CALLBACK], "grant_types": ["authorization_code"]}), + ] { + let reply = h.register(metadata).await; + assert_eq!(reply.status, StatusCode::CREATED); + assert_eq!(reply.json()["grant_types"], json!(["authorization_code"])); + let client = reply.json()["client_id"].as_str().unwrap().to_owned(); + assert!(!h.auth.0.store.lock().unwrap().clients[&client].refresh_allowed); + let tokens = h.initial_tokens(&client).await; + assert!(tokens.get("refresh_token").is_none()); + } + assert!( + tokio::time::timeout(Duration::from_millis(100), listener.accept()) + .await + .is_err() + ); } #[tokio::test] -async fn approved_clients_survive_one_hour_and_spam_cannot_evict_them() { +async fn refresh_issuance_requires_registered_permission_owner_consent_and_pkce() { let h = Harness::new(); - let response = h.register(json!({"redirect_uris": [CALLBACK]})).await; - assert!(response.json().get("client_id_expires_in").is_none()); - let approved = response.json()["client_id"].as_str().unwrap().to_owned(); - h.code(&approved).await; - let pending = h.client().await; - { - let mut store = h.auth.0.store.lock().unwrap(); - assert!(store.clients[&approved].approved); - assert!(!store.clients[&pending].approved); - // Advance pruning past the original registration deadline, without sleeping. - store.prune(Instant::now() + CLIENT_TTL + Duration::from_secs(1)); - assert!(store.clients.contains_key(&approved)); - assert!(!store.clients.contains_key(&pending)); - } - assert_eq!(h.authorize(&approved, &[]).await.status, StatusCode::OK); - for i in 0..MAX_UNAPPROVED_CLIENTS { - let mut store = h.auth.0.store.lock().unwrap(); - store.clients.insert( - format!("spam-{i}"), - Client { - redirects: vec![CALLBACK.into()], - name: String::new(), - expires: Instant::now() + CLIENT_TTL, - approved: false, - refresh_allowed: false, - authorization_rate: Rate::default(), - login_rate: Rate::default(), - exchange_rate: Rate::default(), - }, - ); - } + let client = h.refresh_client().await; + assert!(h.auth.0.store.lock().unwrap().refresh.is_empty()); + let page = h.authorize(&client, &[]).await; + let (cookie, csrf) = Harness::browser(&page); + let denied = h + .consent(&cookie, &csrf, SECRET, "deny", Some(ISSUER)) + .await; + assert_eq!(denied.parameter("error"), "access_denied"); + assert!(h.auth.0.store.lock().unwrap().refresh.is_empty()); + let code = h.code(&client).await; + let bad = h + .exchange(&client, &code, &[("code_verifier", "too-short")]) + .await; + assert_eq!(bad.json()["error"], "invalid_grant"); + assert!(h.auth.0.store.lock().unwrap().refresh.is_empty()); + let tokens = h.initial_tokens(&client).await; + assert_eq!(tokens["expires_in"], 1800); + let refresh = tokens["refresh_token"].as_str().unwrap(); + assert_eq!(URL_SAFE_NO_PAD.decode(refresh).unwrap().len(), 32); + assert!(!h.auth.accepts(refresh)); + let no_refresh_client = h.client().await; assert_eq!( - h.register(json!({"redirect_uris": [CALLBACK]})) - .await - .status, - StatusCode::SERVICE_UNAVAILABLE + h.refresh(&no_refresh_client, refresh, &[]).await.json()["error"], + "invalid_grant" ); - assert!( - h.auth - .0 - .store - .lock() - .unwrap() - .clients - .contains_key(&approved) + let no_refresh = h.initial_tokens(&no_refresh_client).await; + assert!(no_refresh.get("refresh_token").is_none()); + assert_eq!( + h.refresh( + &no_refresh_client, + no_refresh["access_token"].as_str().unwrap(), + &[] + ) + .await + .json()["error"], + "invalid_grant" ); - assert_eq!(h.authorize(&approved, &[]).await.status, StatusCode::OK); - { - let mut store = h.auth.0.store.lock().unwrap(); - store.prune(Instant::now() + CLIENT_TTL + Duration::from_secs(1)); - assert_eq!(store.clients.len(), 1); - } assert_eq!( - h.register(json!({"redirect_uris": [CALLBACK]})) - .await - .status, - StatusCode::CREATED + h.refresh(&client, refresh, &[]).await.status, + StatusCode::OK ); } #[tokio::test] -async fn invalid_consent_does_not_consume_owner_attempts_or_transaction() { +async fn refresh_rotation_replay_revokes_only_its_family_including_real_mcp_access() { let h = Harness::new(); - let client = h.client().await; - let page = h.authorize(&client, &[]).await; - let (cookie, csrf) = Harness::browser(&page); - for _ in 0..32 { + let client = h.refresh_client().await; + let other_client = h.refresh_client().await; + let original = h.initial_tokens(&client).await; + let independent = h.initial_tokens(&client).await; + let other = h.initial_tokens(&other_client).await; + let old = original["refresh_token"].as_str().unwrap(); + let family = digest(old); + let deadline = h.auth.0.store.lock().unwrap().families[&family].expires; + let rotated = h.refresh(&client, old, &[]).await; + assert_eq!(rotated.status, StatusCode::OK); + let rotated = rotated.json(); + let current = rotated["refresh_token"].as_str().unwrap(); + assert!(old != current); + assert!(original["access_token"] != rotated["access_token"]); + assert_eq!(rotated["expires_in"], 1800); + { + let store = h.auth.0.store.lock().unwrap(); + assert_eq!(store.families[&family].expires, deadline); + assert!(store.refresh[&digest(old)].consumed); + assert!(!store.refresh[&digest(current)].consumed); + assert_eq!(store.families[&family].records, 2); + } + for tokens in [&original, &rotated, &independent, &other] { assert_eq!( - h.consent("", &csrf, SECRET, "approve", Some(ISSUER)) - .await - .status, - StatusCode::BAD_REQUEST + h.mcp_status(tokens["access_token"].as_str().unwrap()).await, + StatusCode::OK ); + } + let replay = h.refresh(&client, old, &[]).await; + assert_eq!(replay.status, StatusCode::BAD_REQUEST); + assert_eq!(replay.json(), json!({"error": "invalid_grant"})); + for tokens in [&original, &rotated] { assert_eq!( - h.consent(&cookie, "wrong", SECRET, "approve", Some(ISSUER)) - .await - .status, - StatusCode::FORBIDDEN + h.mcp_status(tokens["access_token"].as_str().unwrap()).await, + StatusCode::UNAUTHORIZED + ); + } + assert_eq!( + h.refresh(&client, current, &[]).await.json()["error"], + "invalid_grant" + ); + for (id, tokens) in [(&client, &independent), (&other_client, &other)] { + assert_eq!( + h.mcp_status(tokens["access_token"].as_str().unwrap()).await, + StatusCode::OK ); assert_eq!( - h.consent(&cookie, &csrf, SECRET, "approve", Some("null")) + h.refresh(id, tokens["refresh_token"].as_str().unwrap(), &[]) .await .status, - StatusCode::FORBIDDEN + StatusCode::OK ); } - { - let store = h.auth.0.store.lock().unwrap(); - assert_eq!(store.clients[&client].login_rate.used, 0); - assert_eq!(store.transactions.len(), 1); - } - let result = h - .consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) - .await; - assert!(!result.parameter("code").is_empty()); - assert_eq!( - h.auth.0.store.lock().unwrap().clients[&client] - .login_rate - .used, - 1 - ); + let mut store = h.auth.0.store.lock().unwrap(); + store.prune(Instant::now() + TOKEN_TTL + Duration::from_secs(1)); + assert!(store.families[&family].revoked); + assert!(store.refresh.contains_key(&digest(old))); + assert!(store.refresh.contains_key(&digest(current))); + store.prune(deadline); + assert!(!store.families.contains_key(&family)); + assert!(!store.refresh.contains_key(&digest(old))); } -#[tokio::test] -async fn attacker_transactions_and_token_requests_do_not_lock_other_clients() { - let h = Harness::new(); - let legitimate = h.client().await; - let attacker = h.client().await; - for _ in 0..8 { - let page = h.authorize(&attacker, &[]).await; - let (cookie, csrf) = Harness::browser(&page); - let result = h - .consent(&cookie, &csrf, "wrong", "approve", Some(ISSUER)) - .await; - assert_eq!(result.parameter("error"), "access_denied"); - } - let page = h.authorize(&attacker, &[]).await; - let (cookie, csrf) = Harness::browser(&page); - assert_eq!( - h.consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) - .await - .status, - StatusCode::TOO_MANY_REQUESTS - ); - for _ in 0..MAX_CLIENT_TRANSACTIONS - 1 { - Harness::browser(&h.authorize(&attacker, &[]).await); - } - assert_eq!( - h.authorize(&attacker, &[]).await.parameter("error"), - "temporarily_unavailable" - ); - // Unknown codes, even naming the victim, cannot spend its exchange budget. - for _ in 0..160 { - assert_eq!( - h.exchange(&legitimate, "unknown", &[]).await.status, - StatusCode::BAD_REQUEST - ); +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn concurrent_refresh_exchanges_issue_once_then_replay_revokes_the_winner() { + let h = Arc::new(Harness::new()); + let client = h.refresh_client().await; + let tokens = h.initial_tokens(&client).await; + let raw = tokens["refresh_token"].as_str().unwrap().to_owned(); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let mut jobs = Vec::new(); + for _ in 0..2 { + let (h, client, raw, barrier) = (h.clone(), client.clone(), raw.clone(), barrier.clone()); + jobs.push(tokio::spawn(async move { + barrier.wait().await; + h.refresh(&client, &raw, &[]).await + })); } - let code = h.code(&legitimate).await; - assert_eq!( - h.exchange(&legitimate, &code, &[]).await.status, - StatusCode::OK - ); + let a = jobs.remove(0).await.unwrap(); + let b = jobs.remove(0).await.unwrap(); assert_eq!( - h.auth.0.store.lock().unwrap().clients[&legitimate] - .exchange_rate - .used, + [a.status, b.status] + .iter() + .filter(|s| **s == StatusCode::OK) + .count(), 1 ); - // Saturating an attacker's known-client bucket cannot block another client. - h.auth - .0 - .store - .lock() - .unwrap() - .clients - .get_mut(&attacker) - .unwrap() - .exchange_rate = Rate { - start: Some(Instant::now()), - used: 60, + let (winner, replay) = if a.status == StatusCode::OK { + (a, b) + } else { + (b, a) }; - let code = h.code(&legitimate).await; - assert_eq!( - h.exchange(&legitimate, &code, &[]).await.status, - StatusCode::OK - ); -} - -#[tokio::test] -async fn unknown_oauth_extensions_are_ignored_without_fetching_or_rendering_urls() { - let h = Harness::new(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let untrusted = format!( - "http://{}/", - listener.local_addr().unwrap() - ); - let r = h - .register(json!({ - "redirect_uris": [CALLBACK], "client_uri": untrusted, "logo_uri": untrusted, - "policy_uri": untrusted, "tos_uri": untrusted, "extension": {"nested": [1, 2]} - })) - .await; - assert_eq!(r.status, StatusCode::CREATED); - assert!(!r.text.contains("not-rendered")); - let client = r.json()["client_id"].as_str().unwrap().to_owned(); - let page = h - .authorize(&client, &[("future_extension", &untrusted)]) - .await; - assert_eq!(page.status, StatusCode::OK); - assert!(!page.text.contains("not-rendered")); - let (cookie, csrf) = Harness::browser(&page); - let result = h - .consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) - .await; - let token = h - .exchange( - &client, - &result.parameter("code"), - &[("future_extension", &untrusted)], - ) - .await; - assert_eq!(token.status, StatusCode::OK); - assert!(!token.text.contains("not-rendered")); - assert!( - tokio::time::timeout(Duration::from_millis(100), listener.accept()) - .await - .is_err(), - "metadata URL was fetched" - ); - let duplicate = format!( - "{{\"redirect_uris\":[\"{CALLBACK}\"],\"token_endpoint_auth_method\":\"none\",\"token_endpoint_auth_method\":\"none\"}}" - ); - assert_eq!( - h.request( - "POST", - "/oauth/register", - duplicate, - &[("content-type", "application/json")] - ) - .await - .status, - StatusCode::BAD_REQUEST - ); - assert!(fields(b"future_extension=one&future_extension=two").is_ok()); - for key in [ - "client_id", - "resource", - "code", - "code_verifier", - "grant_type", - "code_challenge_method", - "client_assertion", - "client_assertion_type", - ] { - assert!(fields(format!("{key}=one&{key}=two").as_bytes()).is_err()); - } - let code = h.code(&client).await; + assert_eq!(replay.status, StatusCode::BAD_REQUEST); + assert_eq!(replay.json()["error"], "invalid_grant"); + let winner = winner.json(); assert_eq!( - h.exchange(&client, &code, &[("grant_type", "refresh_token")]) + h.refresh(&client, winner["refresh_token"].as_str().unwrap(), &[]) .await .json()["error"], "invalid_grant" ); assert_eq!( - h.exchange(&client, &code, &[("grant_type", "client_credentials")]) - .await - .json()["error"], - "unsupported_grant_type" - ); - // Dispatching another grant must not consume an authorization code. - assert_eq!(h.exchange(&client, &code, &[]).await.status, StatusCode::OK); - assert_eq!( - h.authorize(&client, &[("response_mode", "fragment")]) - .await - .status, - StatusCode::BAD_REQUEST + h.mcp_status(winner["access_token"].as_str().unwrap()).await, + StatusCode::UNAUTHORIZED ); + let store = h.auth.0.store.lock().unwrap(); + assert_eq!(store.families.len(), 1); + assert_eq!(store.refresh.len(), 2); + assert!(store.tokens.is_empty()); } -#[tokio::test] -async fn client_code_token_and_registration_origin_limits_are_isolated_and_bounded() { - let h = Harness::new(); - let attacker = h.client().await; - let legitimate = h.client().await; - { - let mut store = h.auth.0.store.lock().unwrap(); - for i in 0..MAX_CLIENT_CODES { - store.codes.insert( - digest(&format!("code-{i}")), - Code { - grant: grant(&attacker), - expires: Instant::now() + CODE_TTL, - }, - ); - } +#[test] +fn resource_and_secret_validation_do_not_echo_credentials() { + for resource in [ + "http://mcp.example.com/mcp", + "https://u:p@mcp.example.com/mcp", + "https://mcp.example.com/", + "https://mcp.example.com/mcp?x=1", + "https://mcp.example.com/mcp#x", + "https://MCP.example.com/mcp", + ] { + assert!(LocalOAuth::new(resource, SECRET).is_err(), "{resource}"); } - let page = h.authorize(&attacker, &[]).await; - let (cookie, csrf) = Harness::browser(&page); - assert_eq!( - h.consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) - .await - .parameter("error"), - "temporarily_unavailable" - ); - assert_eq!(h.auth.0.store.lock().unwrap().codes.len(), MAX_CLIENT_CODES); - let code = h.code(&legitimate).await; - assert_eq!( - h.exchange(&legitimate, &code, &[]).await.status, - StatusCode::OK - ); - { - let mut store = h.auth.0.store.lock().unwrap(); - store.codes.clear(); - for i in 0..MAX_CLIENT_TOKENS { - store.tokens.insert( - digest(&format!("token-{i}")), - Token { - family: None, - client: attacker.clone(), - resource: RESOURCE.into(), - scope: SCOPE.into(), - expires: Instant::now() + TOKEN_TTL, - }, - ); - } + for secret in [ + "password".into(), + "a".repeat(64), + "01".repeat(32), + SECRET.to_uppercase(), + ] { + let e = LocalOAuth::new(RESOURCE, &secret) + .err() + .unwrap() + .to_string(); + assert!(!e.contains(&secret)); } - let code = h.code(&attacker).await; - assert_eq!( - h.exchange(&attacker, &code, &[]).await.status, - StatusCode::SERVICE_UNAVAILABLE - ); - let code = h.code(&legitimate).await; - assert_eq!( - h.exchange(&legitimate, &code, &[]).await.status, - StatusCode::OK - ); +} - let h = Harness::new(); - for _ in 0..16 { - h.client().await; - } - assert_eq!( - h.register(json!({"redirect_uris": [CALLBACK]})) - .await - .status, - StatusCode::TOO_MANY_REQUESTS - ); - assert_eq!( - h.register(json!({"redirect_uris": ["https://unrelated.example/callback"]})) - .await - .status, - StatusCode::CREATED - ); - { - let mut store = h.auth.0.store.lock().unwrap(); - for i in store.registration_origins.len()..MAX_REGISTRATION_ORIGINS { - store.registration_origins.insert( - format!("https://origin-{i}.example"), - Rate { - start: Some(Instant::now()), - used: 1, - }, - ); - } +#[cfg(unix)] +#[test] +fn secret_file_requires_absolute_owned_0600_regular_file_without_links() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir = std::env::temp_dir().join(format!("qmcp-oauth-fixture-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = dir.join("fixture"); + std::fs::write(&path, format!("{SECRET}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(LocalOAuth::from_secret_file(RESOURCE, &path).is_ok()); + assert!(LocalOAuth::from_secret_file(RESOURCE, Path::new("relative")).is_err()); + for mode in [0o644, 0o660, 0o400, 0o1600] { + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + assert!(LocalOAuth::from_secret_file(RESOURCE, &path).is_err()); } - assert_eq!( - h.register(json!({"redirect_uris": ["https://new-origin.example/callback"]})) - .await - .status, - StatusCode::TOO_MANY_REQUESTS - ); - assert_eq!( - h.auth.0.store.lock().unwrap().registration_origins.len(), - MAX_REGISTRATION_ORIGINS - ); - h.auth - .0 - .store - .lock() - .unwrap() - .prune(Instant::now() + Duration::from_secs(61)); - assert!( - h.auth - .0 - .store - .lock() - .unwrap() - .registration_origins - .is_empty() - ); - assert_eq!( - h.register(json!({"redirect_uris": ["https://new-origin.example/callback"]})) - .await - .status, - StatusCode::CREATED - ); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + let link = dir.join("symlink"); + symlink(&path, &link).unwrap(); + assert!(LocalOAuth::from_secret_file(RESOURCE, &link).is_err()); + let hardlink = dir.join("hardlink"); + std::fs::hard_link(&path, &hardlink).unwrap(); + assert!(LocalOAuth::from_secret_file(RESOURCE, &hardlink).is_err()); + assert!(LocalOAuth::from_secret_file(RESOURCE, &dir).is_err()); + std::fs::remove_dir_all(&dir).unwrap(); } #[tokio::test] -async fn recognized_authentication_fields_and_null_duplicates_stay_strict() { +async fn request_parsing_and_browser_policy_fail_closed() { let h = Harness::new(); + let client = h.client().await; + for invalid in [ + b"state=one&state=two".as_slice(), + b"state=%", + b"state=%GG", + b"state=%FF", + b"state=\xff", + ] { + assert!(fields(invalid).is_err()); + } + for key in [ + "client_id", + "resource", + "code", + "code_verifier", + "grant_type", + "client_assertion", + ] { + assert!(fields(format!("{key}=one&{key}=two").as_bytes()).is_err()); + } let duplicate = format!( - "{{\"redirect_uris\":[\"{CALLBACK}\"],\"token_endpoint_auth_method\":null,\"token_endpoint_auth_method\":\"none\"}}" + r#"{{"redirect_uris":["{CALLBACK}"],"token_endpoint_auth_method":null,"token_endpoint_auth_method":"none"}}"# ); assert_eq!( h.request( @@ -1827,308 +1048,184 @@ async fn recognized_authentication_fields_and_null_duplicates_stay_strict() { .status, StatusCode::BAD_REQUEST ); - let client = h.client().await; - for key in ["client_secret", "client_assertion", "client_assertion_type"] { - assert_eq!( - h.authorize(&client, &[(key, "not-supported")]).await.status, - StatusCode::BAD_REQUEST - ); - let code = h.code(&client).await; + let permits = h.auth.0.body_slots.acquire_many(16).await.unwrap(); + assert_eq!( + h.register(json!({"redirect_uris":[CALLBACK]})).await.status, + StatusCode::TOO_MANY_REQUESTS + ); + drop(permits); + let page = h.authorize(&client, &[]).await; + assert_eq!(page.headers["referrer-policy"], "same-origin"); + assert_eq!( + page.headers["content-security-policy"], + "default-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'" + ); + let (cookie, csrf) = Harness::browser(&page); + for bad_cookie in [String::new(), format!("{cookie}; {cookie}")] { assert_eq!( - h.exchange(&client, &code, &[(key, "not-supported")]) + h.consent(&bad_cookie, &csrf, SECRET, "approve", Some(ISSUER)) .await .status, StatusCode::BAD_REQUEST ); } -} - -#[tokio::test] -async fn chatgpt_dcr_grant_sets_are_normalized_and_metadata_urls_are_not_fetched() { - let h = Harness::new(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let metadata_url = format!("http://{}/metadata", listener.local_addr().unwrap()); - for grants in [ - json!(["authorization_code", "refresh_token"]), - json!(["refresh_token", "authorization_code"]), - ] { - let reply = h - .register(json!({ - "redirect_uris": [CALLBACK], "client_name": "ChatGPT", - "token_endpoint_auth_method": "none", "grant_types": grants, - "response_types": ["code"], "scope": SCOPE, - "client_uri": metadata_url, "logo_uri": metadata_url, - "application_type": "web", "unknown_safe": {"nested": true} - })) - .await; - assert_eq!(reply.status, StatusCode::CREATED); - assert_eq!( - reply.json()["grant_types"], - json!(["authorization_code", "refresh_token"]) - ); - assert_eq!(reply.json()["token_endpoint_auth_method"], "none"); - assert!(!reply.text.contains("metadata")); - let client = reply.json()["client_id"].as_str().unwrap().to_owned(); - assert!(h.auth.0.store.lock().unwrap().clients[&client].refresh_allowed); - } - for metadata in [ - json!({"redirect_uris": [CALLBACK]}), - json!({"redirect_uris": [CALLBACK], "grant_types": ["authorization_code"]}), - ] { - let reply = h.register(metadata).await; - assert_eq!(reply.status, StatusCode::CREATED); - assert_eq!(reply.json()["grant_types"], json!(["authorization_code"])); - let client = reply.json()["client_id"].as_str().unwrap().to_owned(); - assert!(!h.auth.0.store.lock().unwrap().clients[&client].refresh_allowed); - let tokens = h.initial_tokens(&client).await; - assert!(tokens.get("refresh_token").is_none()); - } + assert_eq!( + h.auth.0.store.lock().unwrap().clients[&client] + .login_rate + .used, + 0 + ); + let result = h + .consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) + .await; assert!( - tokio::time::timeout(Duration::from_millis(100), listener.accept()) - .await - .is_err() + result + .text + .contains("rel=\"noreferrer noopener\">Continue to ChatGPT") ); + assert!(!result.headers.contains_key(header::LOCATION)); + for forbidden in ["untrusted & client", - "token_endpoint_auth_method": "none", - "refresh_allowed": !refresh_allowed, - "consent_duration": "" - }); - if let Some(grants) = grants { - metadata["grant_types"] = grants; - } - let registration = h.register(metadata).await; - assert_eq!(registration.status, StatusCode::CREATED); - let client = registration.json()["client_id"] - .as_str() - .unwrap() - .to_owned(); + for refresh in [false, true] { + let client = if refresh { + h.refresh_client().await + } else { + h.client().await + }; let page = h .authorize( &client, &[ - ("redirect_uri", &callback), - ( - "refresh_allowed", - if refresh_allowed { "false" } else { "true" }, - ), - ( - "grant_types", - if refresh_allowed { - "authorization_code" - } else { - "refresh_token" - }, - ), - ("consent_duration", ""), + ("refresh_allowed", "true"), + ("consent_duration", ""), ], ) .await; - // Inspect the GET response only: no owner credentials or approval submitted. Harness::browser(&page); - assert!(page.text.contains(&format!( - "Approve grants all QueryMT tools on {RESOURCE} (scope {SCOPE})." - ))); assert!( page.text .contains("Only approve a client and callback you trust.") ); + assert!(page.text.contains(&format!( + "Approve grants all QueryMT tools on {RESOURCE} (scope {SCOPE})." + ))); assert_eq!( - page.text.contains("Access lasts up to 30 minutes."), - !refresh_allowed - ); - assert_eq!( - page.text - .contains("Each access token lasts up to 30 minutes."), - refresh_allowed - ); - assert_eq!( - page.text.contains("Access can be renewed without a new login for up to 24 hours from the initial code exchange; renewal does not extend this deadline."), - refresh_allowed - ); - assert_eq!(page.text.contains("24 hours"), refresh_allowed); - assert_eq!(page.text.contains("renew"), refresh_allowed); - assert!(!page.text.contains("for 30 minutes")); - assert!( - page.text - .contains("<script>untrusted & client</script>") - ); - assert!( page.text - .contains(&format!("Redirect: {}", escape(&callback))) + .contains("24 hours from the initial code exchange"), + refresh ); - for forbidden in ["")); let store = h.auth.0.store.lock().unwrap(); - assert_eq!(store.clients[&client].refresh_allowed, refresh_allowed); assert!(!store.clients[&client].approved); - assert!(store.codes.is_empty()); - assert!(store.tokens.is_empty()); - assert!(store.families.is_empty()); - assert!(store.refresh.is_empty()); + assert!(store.codes.is_empty() && store.tokens.is_empty() && store.refresh.is_empty()); } } #[tokio::test] -async fn refresh_issuance_requires_registered_permission_owner_consent_and_pkce() { - let h = Harness::new(); - let client = h.refresh_client().await; - assert!(h.auth.0.store.lock().unwrap().refresh.is_empty()); - let page = h.authorize(&client, &[]).await; - let (cookie, csrf) = Harness::browser(&page); - let denied = h - .consent(&cookie, &csrf, SECRET, "deny", Some(ISSUER)) - .await; - assert_eq!(denied.parameter("error"), "access_denied"); - assert!(h.auth.0.store.lock().unwrap().refresh.is_empty()); - let code = h.code(&client).await; - let bad = h - .exchange(&client, &code, &[("code_verifier", "too-short")]) - .await; - assert_eq!(bad.json()["error"], "invalid_grant"); - assert!(h.auth.0.store.lock().unwrap().refresh.is_empty()); - let tokens = h.initial_tokens(&client).await; - assert_eq!(tokens["expires_in"], 1800); - let refresh = tokens["refresh_token"].as_str().unwrap(); - assert_eq!(URL_SAFE_NO_PAD.decode(refresh).unwrap().len(), 32); - assert!(!h.auth.accepts(refresh)); - let no_refresh_client = h.client().await; - assert_eq!( - h.refresh(&no_refresh_client, refresh, &[]).await.json()["error"], - "invalid_grant" - ); - let no_refresh = h.initial_tokens(&no_refresh_client).await; - assert!(no_refresh.get("refresh_token").is_none()); - assert_eq!( - h.refresh( - &no_refresh_client, - no_refresh["access_token"].as_str().unwrap(), - &[] - ) - .await - .json()["error"], - "invalid_grant" - ); - assert_eq!( - h.refresh(&client, refresh, &[]).await.status, - StatusCode::OK - ); -} - -#[tokio::test] -async fn refresh_rotation_replay_revokes_only_its_family_including_real_mcp_access() { +async fn approved_clients_survive_pruning_and_owner_attempts_are_client_isolated() { let h = Harness::new(); - let client = h.refresh_client().await; - let other_client = h.refresh_client().await; - let original = h.initial_tokens(&client).await; - let independent = h.initial_tokens(&client).await; - let other = h.initial_tokens(&other_client).await; - let old = original["refresh_token"].as_str().unwrap(); - let family = digest(old); - let deadline = h.auth.0.store.lock().unwrap().families[&family].expires; - let rotated = h.refresh(&client, old, &[]).await; - assert_eq!(rotated.status, StatusCode::OK); - let rotated = rotated.json(); - let current = rotated["refresh_token"].as_str().unwrap(); - assert!(old != current); - assert!(original["access_token"] != rotated["access_token"]); - assert_eq!(rotated["expires_in"], 1800); + let approved = h.client().await; + h.code(&approved).await; + let pending = h.client().await; { - let store = h.auth.0.store.lock().unwrap(); - assert_eq!(store.families[&family].expires, deadline); - assert!(store.refresh[&digest(old)].consumed); - assert!(!store.refresh[&digest(current)].consumed); - assert_eq!(store.families[&family].records, 2); + let mut store = h.auth.0.store.lock().unwrap(); + store.prune(Instant::now() + CLIENT_TTL + Duration::from_secs(1)); + assert!(store.clients.contains_key(&approved)); + assert!(!store.clients.contains_key(&pending)); } - for tokens in [&original, &rotated, &independent, &other] { - assert_eq!( - h.mcp_status(tokens["access_token"].as_str().unwrap()).await, - StatusCode::OK - ); + let attacker = h.client().await; + for _ in 0..8 { + let (cookie, csrf) = Harness::browser(&h.authorize(&attacker, &[]).await); + h.consent(&cookie, &csrf, "wrong", "approve", Some(ISSUER)) + .await; } - let replay = h.refresh(&client, old, &[]).await; - assert_eq!(replay.status, StatusCode::BAD_REQUEST); - assert_eq!(replay.json(), json!({"error": "invalid_grant"})); - for tokens in [&original, &rotated] { + let (cookie, csrf) = Harness::browser(&h.authorize(&attacker, &[]).await); + assert_eq!( + h.consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) + .await + .status, + StatusCode::TOO_MANY_REQUESTS + ); + // Unknown credentials naming the owner cannot spend its exchange budget. + for _ in 0..61 { assert_eq!( - h.mcp_status(tokens["access_token"].as_str().unwrap()).await, - StatusCode::UNAUTHORIZED + h.exchange(&approved, "unknown", &[]).await.status, + StatusCode::BAD_REQUEST ); } + assert!(h.initial_tokens(&approved).await["access_token"].is_string()); +} + +#[tokio::test] +async fn unknown_metadata_is_not_fetched_and_security_extensions_remain_strict() { + let h = Harness::new(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!( + "http://{}/", + listener.local_addr().unwrap() + ); + let reply = h + .register(json!({"redirect_uris":[CALLBACK],"logo_uri":url,"client_uri":url})) + .await; + let client = reply.json()["client_id"].as_str().unwrap().to_owned(); + let page = h.authorize(&client, &[("future_extension", &url)]).await; + assert!(!page.text.contains("ignored")); + let code = h.code(&client).await; assert_eq!( - h.refresh(&client, current, &[]).await.json()["error"], - "invalid_grant" + h.exchange(&client, &code, &[("future_extension", &url)]) + .await + .status, + StatusCode::OK ); - for (id, tokens) in [(&client, &independent), (&other_client, &other)] { + assert!( + tokio::time::timeout(Duration::from_millis(50), listener.accept()) + .await + .is_err() + ); + for key in ["client_secret", "client_assertion", "client_assertion_type"] { assert_eq!( - h.mcp_status(tokens["access_token"].as_str().unwrap()).await, - StatusCode::OK + h.authorize(&client, &[(key, "unsupported")]).await.status, + StatusCode::BAD_REQUEST ); + let code = h.code(&client).await; assert_eq!( - h.refresh(id, tokens["refresh_token"].as_str().unwrap(), &[]) + h.exchange(&client, &code, &[(key, "unsupported")]) .await .status, - StatusCode::OK + StatusCode::BAD_REQUEST ); } - let mut store = h.auth.0.store.lock().unwrap(); - store.prune(Instant::now() + TOKEN_TTL + Duration::from_secs(1)); - assert!(store.families[&family].revoked); - assert!(store.refresh.contains_key(&digest(old))); - assert!(store.refresh.contains_key(&digest(current))); - store.prune(deadline); - assert!(!store.families.contains_key(&family)); - assert!(!store.refresh.contains_key(&digest(old))); } #[tokio::test] -async fn invalid_refresh_bindings_and_malformed_requests_do_not_consume_or_revoke() { +async fn invalid_refresh_bindings_and_malformed_requests_preserve_current_token() { let h = Harness::new(); let client = h.refresh_client().await; let other = h.refresh_client().await; - let original = h.initial_tokens(&client).await; - let raw = original["refresh_token"].as_str().unwrap(); + let tokens = h.initial_tokens(&client).await; + let raw = tokens["refresh_token"].as_str().unwrap(); for (key, value, expected) in [ ("client_id", other.as_str(), "invalid_grant"), - ("client_id", "", "invalid_grant"), ("resource", "https://other.example/mcp", "invalid_target"), - ( - "resource", - "https://mcp.example.com:443/mcp", - "invalid_target", - ), - ("resource", "https://mcp.example.com/mcp/", "invalid_target"), - ("resource", "", "invalid_target"), ("scope", "qmcp:access other", "invalid_scope"), - ("scope", "", "invalid_scope"), - ("scope", "qmcp:access ", "invalid_scope"), - ("refresh_token", "not-an-opaque-token", "invalid_grant"), + ("refresh_token", "unknown", "invalid_grant"), ("client_secret", "", "invalid_grant"), - ("client_assertion", "canary-refresh-secret", "invalid_grant"), ("code", "mixed-grants", "invalid_grant"), - ("owner_secret", "not-required", "invalid_grant"), ] { - let reply = h.refresh(&client, raw, &[(key, value)]).await; - assert_eq!(reply.status, StatusCode::BAD_REQUEST); - assert_eq!(reply.json()["error"], expected, "{key}"); + assert_eq!( + h.refresh(&client, raw, &[(key, value)]).await.json()["error"], + expected + ); let store = h.auth.0.store.lock().unwrap(); - assert!(!store.refresh[&digest(raw)].consumed); - assert!(!store.families[&digest(raw)].revoked); + assert!(!store.refresh[&digest(raw)].consumed && !store.families[&digest(raw)].revoked); } let body = encoded(&[ ("grant_type", "refresh_token"), @@ -2137,11 +1234,7 @@ async fn invalid_refresh_bindings_and_malformed_requests_do_not_consume_or_revok ]); for malformed in [ format!("{body}&refresh_token={raw}"), - format!("{body}&client_id={client}"), - format!("{body}&resource={RESOURCE}&resource={RESOURCE}"), - format!("{body}&scope={SCOPE}&scope={SCOPE}"), format!("{body}&broken=%GG"), - encoded(&[("grant_type", "refresh_token"), ("refresh_token", raw)]), ] { assert_eq!( h.request( @@ -2152,55 +1245,13 @@ async fn invalid_refresh_bindings_and_malformed_requests_do_not_consume_or_revok ) .await .status, - StatusCode::BAD_REQUEST - ); - } - for (path, extra, status) in [ - ("/oauth/token?x=1", None, StatusCode::BAD_REQUEST), - ( - "/oauth/token", - Some(("origin", ISSUER)), - StatusCode::FORBIDDEN, - ), - ( - "/oauth/token", - Some(("origin", "null")), - StatusCode::FORBIDDEN, - ), - ( - "/oauth/token", - Some(("host", "evil.example")), - StatusCode::FORBIDDEN, - ), - ( - "/oauth/token", - Some(("authorization", "Basic canary-refresh-secret")), - StatusCode::BAD_REQUEST, - ), - ] { - let mut headers = vec![("content-type", "application/x-www-form-urlencoded")]; - if let Some(extra) = extra { - headers.push(extra); - } - assert_eq!( - h.request("POST", path, body.clone(), &headers).await.status, - status + StatusCode::BAD_REQUEST ); } - assert!(h.auth.accepts(original["access_token"].as_str().unwrap())); let rotated = h - .refresh( - &client, - raw, - &[ - ("resource", RESOURCE), - ("scope", SCOPE), - ("future_extension", "ignored"), - ], - ) - .await; - assert_eq!(rotated.status, StatusCode::OK); - // Even a consumed digest with wrong bindings must not revoke the family. + .refresh(&client, raw, &[("resource", RESOURCE), ("scope", SCOPE)]) + .await + .json(); for changes in [ vec![("client_id", other.as_str())], vec![("resource", "wrong")], @@ -2213,37 +1264,56 @@ async fn invalid_refresh_bindings_and_malformed_requests_do_not_consume_or_revok assert!(!h.auth.0.store.lock().unwrap().families[&digest(raw)].revoked); } assert_eq!( - h.refresh( - &client, - rotated.json()["refresh_token"].as_str().unwrap(), - &[] - ) - .await - .status, + h.refresh(&client, rotated["refresh_token"].as_str().unwrap(), &[]) + .await + .status, StatusCode::OK ); } #[tokio::test] -async fn refresh_absolute_expiry_pruning_and_restart_are_volatile() { +async fn expiry_and_restart_invalidate_grants_and_mcp_access() { let h = Harness::new(); let client = h.refresh_client().await; - let start = Instant::now(); - let original = h.initial_tokens(&client).await; - let access = original["access_token"].as_str().unwrap(); - let raw = original["refresh_token"].as_str().unwrap(); - { - let mut store = h.auth.0.store.lock().unwrap(); - assert!(store.families[&digest(raw)].expires >= start + FAMILY_TTL); - assert!(store.families[&digest(raw)].expires <= Instant::now() + FAMILY_TTL); - store.tokens.get_mut(&digest(access)).unwrap().expires = - Instant::now() - Duration::from_secs(1); + let (cookie, csrf) = Harness::browser(&h.authorize(&client, &[]).await); + for transaction in h.auth.0.store.lock().unwrap().transactions.values_mut() { + transaction.expires = Instant::now(); } - assert!(!h.auth.accepts(access)); - let rotated = h.refresh(&client, raw, &[]).await; - assert_eq!(rotated.status, StatusCode::OK); - let rotated = rotated.json(); - let current = rotated["refresh_token"].as_str().unwrap(); + assert_eq!( + h.consent(&cookie, &csrf, SECRET, "approve", Some(ISSUER)) + .await + .status, + StatusCode::BAD_REQUEST + ); + let code = h.code(&client).await; + h.auth + .0 + .store + .lock() + .unwrap() + .codes + .get_mut(&digest(&code)) + .unwrap() + .expires = Instant::now(); + assert_eq!( + h.exchange(&client, &code, &[]).await.status, + StatusCode::BAD_REQUEST + ); + let tokens = h.initial_tokens(&client).await; + let access = tokens["access_token"].as_str().unwrap(); + let refresh = tokens["refresh_token"].as_str().unwrap(); + assert_eq!(h.mcp_status("bad").await, StatusCode::UNAUTHORIZED); + assert_eq!(h.mcp_status(access).await, StatusCode::OK); + h.auth + .0 + .store + .lock() + .unwrap() + .tokens + .get_mut(&digest(access)) + .unwrap() + .expires = Instant::now(); + assert_eq!(h.mcp_status(access).await, StatusCode::UNAUTHORIZED); let deadline = Instant::now() + Duration::from_secs(10); h.auth .0 @@ -2251,440 +1321,106 @@ async fn refresh_absolute_expiry_pruning_and_restart_are_volatile() { .lock() .unwrap() .families - .get_mut(&digest(raw)) + .get_mut(&digest(refresh)) .unwrap() .expires = deadline; - let last = h.refresh(&client, current, &[]).await; - assert_eq!(last.status, StatusCode::OK); - assert!(last.json()["expires_in"].as_u64().unwrap() <= 10); + let rotated = h.refresh(&client, refresh, &[]).await.json(); + assert!(rotated["expires_in"].as_u64().unwrap() <= 10); { let mut store = h.auth.0.store.lock().unwrap(); - assert_eq!(store.families[&digest(raw)].expires, deadline); - assert!( - store.tokens[&digest(last.json()["access_token"].as_str().unwrap())].expires - <= deadline - ); + assert!(store.tokens.values().all(|token| token.expires <= deadline)); store.prune(deadline); - assert!(store.families.is_empty()); - assert!(store.refresh.is_empty()); - assert!(store.tokens.is_empty()); - assert!(store.codes.is_empty()); - assert!(store.transactions.is_empty()); + assert!(store.families.is_empty() && store.refresh.is_empty() && store.tokens.is_empty()); } assert_eq!( - h.refresh(&client, current, &[]).await.json()["error"], - "invalid_grant" + h.refresh(&client, refresh, &[]).await.status, + StatusCode::BAD_REQUEST ); - let fresh = h.initial_tokens(&client).await; let restarted = Harness::new(); - assert!( - !restarted - .auth - .accepts(fresh["access_token"].as_str().unwrap()) - ); assert_eq!( restarted - .refresh(&client, fresh["refresh_token"].as_str().unwrap(), &[]) + .refresh(&client, rotated["refresh_token"].as_str().unwrap(), &[]) .await - .json()["error"], - "invalid_grant" + .status, + StatusCode::BAD_REQUEST + ); + assert_eq!( + restarted + .mcp_status(rotated["access_token"].as_str().unwrap()) + .await, + StatusCode::UNAUTHORIZED ); - assert!(restarted.auth.0.store.lock().unwrap().clients.is_empty()); assert_eq!( restarted.authorize(&client, &[]).await.status, StatusCode::BAD_REQUEST ); } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn concurrent_refresh_exchanges_issue_once_then_replay_revokes_the_winner() { - let h = Arc::new(Harness::new()); +#[tokio::test] +async fn refresh_capacity_failures_preserve_credentials_and_replay_evidence() { + let h = Harness::new(); let client = h.refresh_client().await; - let tokens = h.initial_tokens(&client).await; - let raw = tokens["refresh_token"].as_str().unwrap().to_owned(); - let barrier = Arc::new(tokio::sync::Barrier::new(2)); - let mut jobs = Vec::new(); - for _ in 0..2 { - let (h, client, raw, barrier) = (h.clone(), client.clone(), raw.clone(), barrier.clone()); - jobs.push(tokio::spawn(async move { - barrier.wait().await; - h.refresh(&client, &raw, &[]).await - })); + // Exercise the per-client family bound with real grants, not synthetic matrices. + let mut issued = Vec::new(); + for _ in 0..MAX_CLIENT_FAMILIES { + issued.push(h.initial_tokens(&client).await); } - let a = jobs.remove(0).await.unwrap(); - let b = jobs.remove(0).await.unwrap(); + // Start another owner-attempt window without waiting a minute in the test. + h.auth + .0 + .store + .lock() + .unwrap() + .clients + .get_mut(&client) + .unwrap() + .login_rate = Rate::default(); + let code = h.code(&client).await; assert_eq!( - [a.status, b.status] - .iter() - .filter(|s| **s == StatusCode::OK) - .count(), - 1 + h.exchange(&client, &code, &[]).await.status, + StatusCode::SERVICE_UNAVAILABLE ); - let (winner, replay) = if a.status == StatusCode::OK { - (a, b) - } else { - (b, a) - }; - assert_eq!(replay.status, StatusCode::BAD_REQUEST); - assert_eq!(replay.json()["error"], "invalid_grant"); - let winner = winner.json(); - assert_eq!( - h.refresh(&client, winner["refresh_token"].as_str().unwrap(), &[]) - .await - .json()["error"], - "invalid_grant" + assert!( + h.auth + .0 + .store + .lock() + .unwrap() + .codes + .contains_key(&digest(&code)) ); + let old = issued[0]["refresh_token"].as_str().unwrap(); + let current = h.refresh(&client, old, &[]).await.json(); + let raw = current["refresh_token"].as_str().unwrap(); + { + let mut store = h.auth.0.store.lock().unwrap(); + store.clients.get_mut(&client).unwrap().exchange_rate.used = 60; + } assert_eq!( - h.mcp_status(winner["access_token"].as_str().unwrap()).await, - StatusCode::UNAUTHORIZED + h.refresh(&client, raw, &[]).await.status, + StatusCode::TOO_MANY_REQUESTS ); - let store = h.auth.0.store.lock().unwrap(); - assert_eq!(store.families.len(), 1); - assert_eq!(store.refresh.len(), 2); - assert!(store.tokens.is_empty()); -} - -// Synthetic full tables exercise rejection paths without thousands of owner logins. -fn fill_capacity(store: &mut Store, kind: &str, client: &str, now: Instant) { - match kind { - "families" | "client_families" => { - let (limit, owner) = if kind == "families" { - (MAX_FAMILIES, "unrelated") - } else { - (MAX_CLIENT_FAMILIES, client) - }; - while if kind == "families" { - store.families.len() - } else { - store - .families - .values() - .filter(|f| f.client == client) - .count() - } < limit - { - let id = digest(&format!("filler-family-{}", store.families.len())); - store.families.insert( - id, - Family { - client: owner.into(), - resource: RESOURCE.into(), - scope: SCOPE.into(), - expires: now + FAMILY_TTL, - records: 1, - revoked: false, - }, - ); - store.refresh.insert( - id, - RefreshRecord { - family: id, - consumed: false, - }, - ); - } - } - "records" => { - while store.refresh.len() < MAX_REFRESH_RECORDS { - let family_id = digest(&format!("filler-record-family-{}", store.families.len())); - let count = MAX_FAMILY_RECORDS.min(MAX_REFRESH_RECORDS - store.refresh.len()); - store.families.insert( - family_id, - Family { - client: "unrelated".into(), - resource: RESOURCE.into(), - scope: SCOPE.into(), - expires: now + FAMILY_TTL, - records: count, - revoked: false, - }, - ); - for i in 0..count { - store.refresh.insert( - digest(&format!("filler-record-{}-{i}", store.families.len())), - RefreshRecord { - family: family_id, - consumed: i + 1 != count, - }, - ); - } - } - assert!(store.families.len() <= MAX_FAMILIES); - } - "access" | "client_access" => { - let (limit, owner) = if kind == "access" { - (MAX_TOKENS, "unrelated") - } else { - (MAX_CLIENT_TOKENS, client) - }; - while if kind == "access" { - store.tokens.len() - } else { - store.tokens.values().filter(|t| t.client == client).count() - } < limit - { - store.tokens.insert( - digest(&format!("filler-access-{}", store.tokens.len())), - Token { - family: None, - client: owner.into(), - resource: RESOURCE.into(), - scope: SCOPE.into(), - expires: now + TOKEN_TTL, - }, - ); - } - } - _ => panic!("unknown capacity fixture"), - } -} - -fn expire_capacity_fillers(store: &mut Store, original_family: Option) { - let expired = Instant::now() - Duration::from_secs(1); - for (id, family) in &mut store.families { - if Some(*id) != original_family { - family.expires = expired; - } - } - for token in store.tokens.values_mut() { - if token.family.is_none() { - token.expires = expired; - } - } - store.prune(Instant::now()); -} - -#[tokio::test] -async fn initial_refresh_family_capacity_failure_preserves_code_and_pruning_releases_slots() { - for kind in [ - "families", - "client_families", - "records", - "access", - "client_access", - ] { - let h = Harness::new(); - let client = h.refresh_client().await; - let code = h.code(&client).await; - { - let mut store = h.auth.0.store.lock().unwrap(); - fill_capacity(&mut store, kind, &client, Instant::now()); - } - let reply = h.exchange(&client, &code, &[]).await; - assert_eq!(reply.status, StatusCode::SERVICE_UNAVAILABLE, "{kind}"); - assert_eq!(reply.json(), json!({"error": "temporarily_unavailable"})); - { - let mut store = h.auth.0.store.lock().unwrap(); - assert!(store.codes.contains_key(&digest(&code))); - assert!(!store.tokens.values().any(|t| t.family.is_some())); - expire_capacity_fillers(&mut store, None); - assert!(store.families.is_empty()); - assert!(store.refresh.is_empty()); - assert!(store.tokens.is_empty()); - } - let reply = h.exchange(&client, &code, &[]).await; - assert_eq!(reply.status, StatusCode::OK, "{kind}"); - assert!(reply.json()["refresh_token"].is_string()); - let store = h.auth.0.store.lock().unwrap(); - assert_eq!(store.families.len(), 1); - assert_eq!(store.refresh.len(), 1); - assert_eq!(store.tokens.len(), 1); - assert!(store.codes.is_empty()); - } -} - -#[tokio::test] -async fn refresh_capacity_and_rate_failures_preserve_current_token_and_replay_evidence() { - for kind in ["records", "access", "client_access", "rate"] { - let h = Harness::new(); - let client = h.refresh_client().await; - let original = h.initial_tokens(&client).await; - let old = original["refresh_token"].as_str().unwrap(); - let rotated = h.refresh(&client, old, &[]).await.json(); - let current = rotated["refresh_token"].as_str().unwrap(); - { - let mut store = h.auth.0.store.lock().unwrap(); - if kind == "rate" { - store.clients.get_mut(&client).unwrap().exchange_rate.used = 60; - } else { - fill_capacity(&mut store, kind, &client, Instant::now()); - } - } - let reply = h.refresh(&client, current, &[]).await; - assert_eq!( - reply.status, - if kind == "rate" { - StatusCode::TOO_MANY_REQUESTS - } else { - StatusCode::SERVICE_UNAVAILABLE - }, - "{kind}" - ); - { - let mut store = h.auth.0.store.lock().unwrap(); - assert!(!store.refresh[&digest(current)].consumed); - assert!(store.refresh[&digest(old)].consumed); - assert!(!store.families[&digest(old)].revoked); - assert_eq!(store.families[&digest(old)].records, 2); - expire_capacity_fillers(&mut store, Some(digest(old))); - store.clients.get_mut(&client).unwrap().exchange_rate = Rate::default(); - } - assert!(h.auth.accepts(rotated["access_token"].as_str().unwrap())); - assert_eq!( - h.refresh(&client, current, &[]).await.status, - StatusCode::OK - ); - assert_eq!( - h.refresh(&client, old, &[]).await.json()["error"], - "invalid_grant" - ); - assert!(!h.auth.accepts(rotated["access_token"].as_str().unwrap())); - } -} - -#[tokio::test] -async fn refresh_chain_is_bounded_without_evicting_history_or_extending_absolute_lifetime() { - let h = Harness::new(); - let client = h.refresh_client().await; - let original = h.initial_tokens(&client).await; - let old = original["refresh_token"].as_str().unwrap(); - let family_id = digest(old); - let deadline = h.auth.0.store.lock().unwrap().families[&family_id].expires; - let start = Instant::now(); - let mut current = old.to_owned(); - for i in 1..MAX_FAMILY_RECORDS { - // Ten-minute virtual steps exercise real access expiry and fixed rate windows. - let now = start + Duration::from_secs(i as u64 * 600); - let request = fields( - encoded(&[ - ("grant_type", "refresh_token"), - ("client_id", &client), - ("refresh_token", ¤t), - ]) - .as_bytes(), - ) - .ok() - .unwrap(); - let response = { - let mut store = h.auth.0.store.lock().unwrap(); - store.prune(now); - exchange_refresh(&mut store, &request, now) - }; - assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), MAX_BODY).await.unwrap(); - let response: Value = serde_json::from_slice(&body).unwrap(); - current = response["refresh_token"].as_str().unwrap().to_owned(); - } { let mut store = h.auth.0.store.lock().unwrap(); - assert_eq!(store.refresh.len(), MAX_FAMILY_RECORDS); - assert_eq!(store.families[&family_id].records, MAX_FAMILY_RECORDS); - assert_eq!(store.families[&family_id].expires, deadline); - assert!(store.refresh[&digest(old)].consumed); - let now = start + Duration::from_secs(MAX_FAMILY_RECORDS as u64 * 600); - store.prune(now); - let request = - fields(encoded(&[("client_id", &client), ("refresh_token", ¤t)]).as_bytes()) - .ok() - .unwrap(); - assert_eq!( - exchange_refresh(&mut store, &request, now).status(), - StatusCode::SERVICE_UNAVAILABLE - ); - assert!(!store.refresh[&digest(¤t)].consumed); - let replay = fields(encoded(&[("client_id", &client), ("refresh_token", old)]).as_bytes()) - .ok() - .unwrap(); - assert_eq!( - exchange_refresh(&mut store, &replay, now).status(), - StatusCode::BAD_REQUEST - ); - assert!(store.families[&family_id].revoked); - assert!(store.tokens.is_empty()); - assert_eq!(store.refresh.len(), MAX_FAMILY_RECORDS); - store.prune(deadline); - assert!(store.families.is_empty()); - assert!(store.refresh.is_empty()); - } -} - -// Optional browser harness: only a public test credential and a fresh loopback port. -// The driver forwards browser-generated headers; it never reads deployment files. -#[tokio::test] -#[ignore = "driven by target/oauth-review/browser-check.py"] -async fn browser_fixture_server() { - let dir = - std::env::var_os("QMCP_OAUTH_BROWSER_FIXTURE_DIR").expect("explicit fixture directory"); - let dir = std::path::PathBuf::from(dir); - let h = Harness::new(); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - std::fs::write( - dir.join("port"), - listener.local_addr().unwrap().port().to_string(), - ) - .unwrap(); - axum::serve(listener, h.app) - .with_graceful_shutdown(async move { - for _ in 0..1200 { - if dir.join("done").exists() { - return; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await - .unwrap(); -} - -#[test] -fn resource_and_secret_validation_do_not_echo_credentials() { - for resource in [ - "http://mcp.example.com/mcp", - "https://u:p@mcp.example.com/mcp", - "https://mcp.example.com/", - "https://mcp.example.com/mcp?x=1", - "https://mcp.example.com/mcp#x", - "https://MCP.example.com/mcp", - ] { - assert!(LocalOAuth::new(resource, SECRET).is_err(), "{resource}"); - } - for secret in [ - "password".into(), - "a".repeat(64), - "01".repeat(32), - SECRET.to_uppercase(), - ] { - let e = LocalOAuth::new(RESOURCE, &secret) - .err() - .unwrap() - .to_string(); - assert!(!e.contains(&secret)); + store.clients.get_mut(&client).unwrap().exchange_rate = Rate::default(); + store.families.get_mut(&digest(old)).unwrap().records = MAX_FAMILY_RECORDS; } -} - -#[cfg(unix)] -#[test] -fn secret_file_requires_absolute_owned_0600_regular_file_without_links() { - use std::os::unix::fs::{PermissionsExt, symlink}; - let dir = std::env::temp_dir().join(format!("qmcp-oauth-fixture-{}", uuid::Uuid::new_v4())); - std::fs::create_dir(&dir).unwrap(); - std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); - let path = dir.join("fixture"); - std::fs::write(&path, format!("{SECRET}\n")).unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); - assert!(LocalOAuth::from_secret_file(RESOURCE, &path).is_ok()); - assert!(LocalOAuth::from_secret_file(RESOURCE, Path::new("relative")).is_err()); - for mode in [0o644, 0o660, 0o400, 0o1600] { - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); - assert!(LocalOAuth::from_secret_file(RESOURCE, &path).is_err()); + assert_eq!( + h.refresh(&client, raw, &[]).await.status, + StatusCode::SERVICE_UNAVAILABLE + ); + { + let store = h.auth.0.store.lock().unwrap(); + assert!(!store.refresh[&digest(raw)].consumed); + assert!(store.refresh[&digest(old)].consumed); } - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); - let link = dir.join("symlink"); - symlink(&path, &link).unwrap(); - assert!(LocalOAuth::from_secret_file(RESOURCE, &link).is_err()); - let hardlink = dir.join("hardlink"); - std::fs::hard_link(&path, &hardlink).unwrap(); - assert!(LocalOAuth::from_secret_file(RESOURCE, &hardlink).is_err()); - assert!(LocalOAuth::from_secret_file(RESOURCE, &dir).is_err()); - std::fs::remove_dir_all(&dir).unwrap(); + assert_eq!( + h.refresh(&client, old, &[]).await.json()["error"], + "invalid_grant" + ); + assert_eq!( + h.mcp_status(current["access_token"].as_str().unwrap()) + .await, + StatusCode::UNAUTHORIZED + ); } diff --git a/src/workspace.rs b/src/workspace.rs index a8b31e7..12ae971 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -421,7 +421,7 @@ mod tests { } #[test] - fn startup_relative_paths_and_concurrent_initial_bindings_are_stable() { + fn startup_relative_paths_are_stable() { let workspaces = Workspaces::new().unwrap(); let expected = normalize(Path::new("relative/../project"), &workspaces.startup_cwd).unwrap(); @@ -435,31 +435,6 @@ mod tests { workspaces.initial_cwd(None).unwrap(), workspaces.startup_cwd ); - let outcomes = std::thread::scope(|scope| { - let one = scope.spawn(|| { - workspaces.insert( - "same-id", - Binding { - cwd: "/one".into(), - profile: None, - }, - None, - ) - }); - let two = scope.spawn(|| { - workspaces.insert( - "same-id", - Binding { - cwd: "/two".into(), - profile: None, - }, - None, - ) - }); - [one.join().unwrap(), two.join().unwrap()] - }); - assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1); - assert_eq!(workspaces.store.lock().unwrap().bindings.len(), 1); } #[tokio::test] @@ -687,38 +662,4 @@ mod tests { peer.await.unwrap(); }).await.expect("cancellation and capacity reuse must complete without waiting for an ACK"); } - - #[test] - fn reservations_bound_capacity_and_release_on_drop() { - let mut workspaces = Workspaces::new().unwrap(); - workspaces.capacity = 1; - let reservation = workspaces.reserve().unwrap(); - assert!(workspaces.reserve().is_err()); - drop(reservation); - let reservation = workspaces.reserve().unwrap(); - workspaces - .insert( - "s", - Binding { - cwd: "/remote".into(), - profile: None, - }, - Some(&reservation), - ) - .unwrap(); - drop(reservation); - assert!(workspaces.reserve().is_err()); - assert!( - workspaces - .insert( - "s", - Binding { - cwd: "/wrong".into(), - profile: None - }, - None - ) - .is_err() - ); - } } diff --git a/tests/bridge.rs b/tests/bridge.rs index c83c7f7..12d10b9 100644 --- a/tests/bridge.rs +++ b/tests/bridge.rs @@ -35,6 +35,7 @@ struct MockWorkspace { created: Vec, listing: Option>, reject_creation: bool, + replay_error: bool, forced_id: Option, creation_gates: VecDeque, } @@ -185,6 +186,10 @@ async fn serve_acp( continue; } let params = &request["params"]; + if method == "session/load" && workspace.lock().unwrap().replay_error { + send(&mut socket, json!({"jsonrpc":"2.0","id":id,"error":{"code":-32000,"message":"Loaded session has no runtime ref for ACP replay"}})).await; + continue; + } if matches!( method, "_querymt/profiles" @@ -378,6 +383,67 @@ async fn serve_acp( } } +#[tokio::test] +async fn inspect_reports_backend_replay_limitation_without_empty_history_or_fallback() { + for http in [false, true] { + let backend = MockAcp::start().await; + backend.workspace.lock().unwrap().replay_error = true; + let mut mcp = Mcp::start(&backend.url, http).await; + for full in [false, true] { + let result = mcp + .tool( + "inspect", + json!({"session_id":"fixture-session","full":full}), + ) + .await; + assert_eq!(result["isError"], true, "{result}"); + let content = &result["structuredContent"]; + let error = content["error"].as_str().unwrap(); + assert!(error.contains("QueryMT backend cannot replay"), "{result}"); + assert!(error.contains("History is unavailable, not empty")); + assert!(error.contains("Loaded session has no runtime ref for ACP replay")); + assert!(content.get("messages").is_none() && content.get("raw").is_none()); + } + let requests = backend.recorded(); + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "session/load") + .count(), + 2 + ); + assert!(requests.iter().all(|r| matches!( + r["method"].as_str(), + Some("initialize" | "session/list" | "session/load") + ))); + // A failed history fallback after successful resume/poll is not a done/empty answer. + let follow = mcp + .tool( + "follow", + json!({"session_id":"fixture-session","timeout":1,"interval":1}), + ) + .await; + assert_eq!(follow["isError"], true, "{follow}"); + assert!( + follow["structuredContent"]["error"] + .as_str() + .unwrap() + .contains("History is unavailable, not empty") + ); + assert!(follow["structuredContent"].get("text").is_none()); + assert!(backend.recorded().iter().all(|r| matches!( + r["method"].as_str(), + Some( + "initialize" + | "session/list" + | "session/load" + | "session/resume" + | "_querymt/session/runtimeState" + ) + ))); + } +} + enum Mcp { Stdio { child: Box, @@ -527,147 +593,72 @@ async fn workspace_is_sticky_across_calls_and_switches_without_touching_old_sess for http in [false, true] { let backend = MockAcp::start().await; let mut mcp = Mcp::start(&backend.url, http).await; - let missing = mcp.tool("workspace", json!({})).await; - assert_eq!(missing["isError"], true); + assert_eq!(mcp.tool("workspace", json!({})).await["isError"], true); assert!(backend.recorded().is_empty()); - let created = mcp.tool("workspace", json!({"cwd": "/remote/not-on-qmcp/project/./sub/..", "profile": "fixture-profile"})).await; - assert_eq!(created["isError"], false, "{created}"); - let first = created["structuredContent"]["sessionId"] - .as_str() - .unwrap() - .to_owned(); - assert_eq!(created["structuredContent"]["created"], true); - assert_eq!(created["structuredContent"]["switched"], false); - assert_eq!( - created["structuredContent"]["cwd"], - "/remote/not-on-qmcp/project" - ); + let first = mcp + .tool( + "workspace", + json!({"cwd":"/remote/project/./sub/..", "profile":"fixture-profile"}), + ) + .await; + assert_eq!(first["isError"], false, "{first}"); + assert_eq!(first["structuredContent"]["cwd"], "/remote/project"); + assert_eq!(first["structuredContent"]["created"], true); assert!( !backend .recorded() .iter() .any(|r| r["method"] == "session/prompt") ); - for cwd in [ - None, - Some("."), - Some("sub/.."), - Some("/remote/not-on-qmcp/project"), - ] { - let mut args = json!({"session_id": first, "profile": "fixture-profile"}); - if let Some(cwd) = cwd { - args["cwd"] = json!(cwd); - } - let selected = mcp.tool("workspace", args).await; - assert_eq!(selected["isError"], false, "{selected}"); - assert_eq!(selected["structuredContent"]["sessionId"], first); - assert_eq!(selected["structuredContent"]["created"], false); - assert_eq!(selected["structuredContent"]["switched"], false); - } - let mismatch = mcp - .tool( - "workspace", - json!({"session_id": first, "profile": "other"}), - ) + let first = first["structuredContent"]["sessionId"].as_str().unwrap(); + let same = mcp + .tool("workspace", json!({"session_id":first,"cwd":"sub/.."})) .await; - assert_eq!(mismatch["isError"], true); - assert!( - mismatch["structuredContent"]["error"] - .as_str() - .unwrap() - .contains("profile changes") + assert_eq!(same["structuredContent"]["sessionId"], first); + assert_eq!(same["structuredContent"]["created"], false); + assert_eq!( + mcp.tool("workspace", json!({"session_id":first,"profile":"other"})) + .await["isError"], + true ); let prompted = mcp - .tool("prompt", json!({"session_id": first, "text": "continue"})) + .tool("prompt", json!({"session_id":first,"text":"continue"})) .await; - assert_eq!(prompted["isError"], false, "{prompted}"); - assert_eq!(prompted["structuredContent"]["sessionId"], first); - assert_eq!( - prompted["structuredContent"]["cwd"], - "/remote/not-on-qmcp/project" - ); + assert_eq!(prompted["structuredContent"]["cwd"], "/remote/project"); for name in ["status", "inspect", "follow"] { - let result = mcp.tool(name, json!({"session_id": first})).await; - assert_eq!(result["isError"], false, "{name}: {result}"); - assert_eq!( - result["structuredContent"]["cwd"], - "/remote/not-on-qmcp/project" - ); + let result = mcp.tool(name, json!({"session_id":first})).await; + assert_eq!(result["isError"], false, "{result}"); + assert_eq!(result["structuredContent"]["cwd"], "/remote/project"); let before = backend.recorded().len(); - let rejected = mcp - .tool(name, json!({"session_id": first, "cwd": "/wrong"})) - .await; - assert_eq!(rejected["isError"], true, "{name}: {rejected}"); - assert!( - rejected["structuredContent"]["error"] - .as_str() - .unwrap() - .contains("use workspace") + assert_eq!( + mcp.tool(name, json!({"session_id":first,"cwd":"/wrong"})) + .await["isError"], + true ); - assert!(!backend.recorded()[before..].iter().any(|r| matches!( - r["method"].as_str(), - Some("session/load" | "session/resume" | "session/new") - ))); + assert_eq!(backend.recorded().len(), before); } let switched = mcp - .tool("workspace", json!({"session_id": first, "cwd": "../other"})) + .tool("workspace", json!({"session_id":first,"cwd":"../other"})) .await; assert_eq!(switched["isError"], false, "{switched}"); - let second = switched["structuredContent"]["sessionId"] - .as_str() - .unwrap() - .to_owned(); - assert_ne!(first, second); assert_eq!(switched["structuredContent"]["previousSessionId"], first); - assert_eq!( - switched["structuredContent"]["cwd"], - "/remote/not-on-qmcp/other" - ); - assert_eq!(switched["structuredContent"]["created"], true); - assert_eq!(switched["structuredContent"]["switched"], true); + assert_eq!(switched["structuredContent"]["cwd"], "/remote/other"); assert_eq!(switched["structuredContent"]["profileSource"], "inherited"); - let switched_prompt = mcp - .tool( - "prompt", - json!({"session_id": second, "cwd": "../third", "text": "switch"}), - ) - .await; - assert_eq!(switched_prompt["isError"], false, "{switched_prompt}"); - let third = switched_prompt["structuredContent"]["sessionId"] - .as_str() - .unwrap() - .to_owned(); - assert_ne!(third, second); - assert_eq!( - switched_prompt["structuredContent"]["previousSessionId"], - second - ); - assert_eq!(switched_prompt["structuredContent"]["created"], true); - assert_eq!( - switched_prompt["structuredContent"]["cwd"], - "/remote/not-on-qmcp/third" - ); - for (id, cwd) in [ - (&first, "/remote/not-on-qmcp/project"), - (&second, "/remote/not-on-qmcp/other"), - (&third, "/remote/not-on-qmcp/third"), - ] { - let status = mcp.tool("status", json!({"session_id": id})).await; - assert_eq!(status["isError"], false); - assert_eq!(status["structuredContent"]["cwd"], cwd); - } + let second = switched["structuredContent"]["sessionId"].as_str().unwrap(); + assert_ne!(first, second); + let old = mcp.tool("status", json!({"session_id":first})).await; + assert_eq!(old["structuredContent"]["cwd"], "/remote/project"); let requests = backend.recorded(); let creations: Vec<_> = requests .iter() .filter(|r| r["method"] == "session/new") .collect(); - assert_eq!(creations.len(), 3); - for request in creations { - assert_eq!( - request["params"]["_meta"]["querymt"]["profile_id"], - "fixture-profile" - ); - } + assert_eq!(creations.len(), 2); + assert!( + creations + .iter() + .all(|r| r["params"]["_meta"]["querymt"]["profile_id"] == "fixture-profile") + ); assert!(!requests.iter().any(|r| r["method"] == "session/list")); assert_no_implicit_cancel(&backend); } @@ -738,93 +729,83 @@ async fn workspace_metadata_recovery_is_unfiltered_paginated_and_never_trusts_ca #[tokio::test] async fn workspace_discovery_fails_closed_for_incomplete_or_unavailable_metadata() { let cases = [ - (vec![json!({"sessions": []})], "not found"), - ( - vec![json!({"sessions": [], "nextCursor": null})], - "not found", - ), - ( - vec![json!({"sessions": [], "nextCursor": 17})], - "metadata unavailable", - ), - ( - vec![json!({"sessions": [], "nextCursor": true})], - "metadata unavailable", - ), - ( - vec![json!({"sessions": [], "nextCursor": {}})], - "metadata unavailable", - ), + (vec![json!({"sessions":[]})], "not found"), ( - vec![json!({"sessions": [], "nextCursor": []})], + vec![json!({"sessions":[],"nextCursor":17})], "metadata unavailable", ), ( - vec![json!({"sessions": [], "nextCursor": ""})], + vec![json!({"sessions":[],"nextCursor":"page-1"})], "repeated a cursor", ), ( - vec![ - json!({"sessions": [], "nextCursor": "page-2"}), - json!({"sessions": [], "nextCursor": "page-2"}), - ], - "repeated a cursor", - ), - ( - vec![json!({"sessions": [{"sessionId": "unknown", "cwd": ""}]})], - "missing or not absolute", - ), - ( - vec![json!({"sessions": [{"sessionId": "unknown", "cwd": "relative"}]})], + vec![json!({"sessions":[{"sessionId":"unknown","cwd":"relative"}]})], "missing or not absolute", ), ( - vec![json!({"sessions": [{"sessionId": "unknown"}]})], + vec![json!({"sessions":[{"sessionId":"unknown"}]})], "metadata unavailable", ), ( - vec![json!({"error": {"code": -32601, "message": "list unsupported"}})], + vec![json!({"error":{"code":-32601,"message":"list unsupported"}})], "metadata unavailable", ), ( (1..=20) - .map(|page| json!({"sessions": [], "nextCursor": format!("page-{}", page + 1)})) + .map(|page| json!({"sessions":[],"nextCursor":format!("page-{}",page+1)})) .collect(), "page cap reached", ), ]; for (pages, expected) in cases { - for name in ["workspace", "prompt", "status", "inspect", "follow"] { - let backend = MockAcp::start().await; - backend.workspace.lock().unwrap().listing = Some(pages.clone()); - let mut mcp = Mcp::start(&backend.url, true).await; - let mut args = json!({"session_id": "unknown", "cwd": "/caller/cannot/prove/this"}); - if name == "prompt" { - args["text"] = json!("must not send"); - } - let result = mcp.tool(name, args).await; - assert_eq!(result["isError"], true, "{name}: {result}"); - assert!( - result["structuredContent"]["error"] - .as_str() - .unwrap() - .contains(expected), - "{expected}: {result}" - ); - if expected == "metadata unavailable" { - assert!( - !result["structuredContent"]["error"] - .as_str() - .unwrap() - .contains("not found") - ); - } - assert!(!backend.recorded().iter().any(|r| matches!( - r["method"].as_str(), - Some("session/new" | "session/load" | "session/resume" | "session/prompt") - ))); - assert_no_implicit_cancel(&backend); + let backend = MockAcp::start().await; + backend.workspace.lock().unwrap().listing = Some(pages); + let mut mcp = Mcp::start(&backend.url, true).await; + let result = mcp + .tool( + "workspace", + json!({"session_id":"unknown","cwd":"/caller/cannot/prove/this"}), + ) + .await; + assert_eq!(result["isError"], true, "{result}"); + assert!( + result["structuredContent"]["error"] + .as_str() + .unwrap() + .contains(expected), + "{result}" + ); + assert!( + backend + .recorded() + .iter() + .all(|r| matches!(r["method"].as_str(), Some("initialize" | "session/list"))) + ); + } + // Each entry point must recover metadata before doing any session work. + for name in ["prompt", "status", "inspect", "follow"] { + let backend = MockAcp::start().await; + backend.workspace.lock().unwrap().listing = + Some(vec![json!({"sessions":[{"sessionId":"unknown"}]})]); + let mut mcp = Mcp::start(&backend.url, true).await; + let mut args = json!({"session_id":"unknown","cwd":"/caller/path"}); + if name == "prompt" { + args["text"] = json!("must not send"); } + let result = mcp.tool(name, args).await; + assert_eq!(result["isError"], true, "{result}"); + assert!( + result["structuredContent"]["error"] + .as_str() + .unwrap() + .contains("metadata unavailable") + ); + assert!( + backend + .recorded() + .iter() + .all(|r| matches!(r["method"].as_str(), Some("initialize" | "session/list"))) + ); } } @@ -832,48 +813,28 @@ async fn workspace_discovery_fails_closed_for_incomplete_or_unavailable_metadata async fn workspace_profile_unknown_is_explicit_and_paths_are_remote_lexical() { let backend = MockAcp::start().await; let mut mcp = Mcp::start(&backend.url, true).await; - for cwd in [ - "~", - "~/project", - "~user/project", - "", - "C:", - "C:/project", - "/bad\npath", - ] { - let result = mcp.tool("workspace", json!({"cwd": cwd})).await; - assert_eq!(result["isError"], true, "{cwd:?}: {result}"); - } - assert!( - !backend - .recorded() - .iter() - .any(|r| r["method"] == "session/new") - ); let selected = mcp - .tool("workspace", json!({"session_id": "fixture-session"})) + .tool("workspace", json!({"session_id":"fixture-session"})) .await; - assert_eq!(selected["isError"], false, "{selected}"); assert_eq!(selected["structuredContent"]["profileKnown"], false); - let rejected = mcp - .tool( + assert_eq!( + mcp.tool( "workspace", - json!({"session_id": "fixture-session", "profile": "cannot-assume"}), + json!({"session_id":"fixture-session","profile":"cannot-assume"}) ) - .await; - assert_eq!(rejected["isError"], true); - // Give session/new a genuinely fresh ID in this fixture, whose default list - // already contains fixture-session (normal new-session tests start empty). + .await["isError"], + true + ); backend .workspace .lock() .unwrap() .created - .push(json!({"sessionId": "fixture-session", "cwd": "/workspace"})); + .push(json!({"sessionId":"fixture-session","cwd":"/workspace"})); let switched = mcp .tool( "workspace", - json!({"session_id": "fixture-session", "cwd": "/remote/nonexistent"}), + json!({"session_id":"fixture-session","cwd":"/remote/nonexistent"}), ) .await; assert_eq!(switched["isError"], false, "{switched}"); @@ -882,18 +843,21 @@ async fn workspace_profile_unknown_is_explicit_and_paths_are_remote_lexical() { "backend-default-unknown" ); assert_eq!(switched["structuredContent"]["profileKnown"], false); - let known = mcp.tool("workspace", json!({"session_id": "fixture-session-2", "cwd": "/other", "profile": "explicit-profile"})).await; - assert_eq!(known["isError"], false, "{known}"); - assert_eq!(known["structuredContent"]["profile"], "explicit-profile"); - assert_eq!(known["structuredContent"]["profileSource"], "explicit"); + let explicit = mcp + .tool( + "workspace", + json!({"session_id":"fixture-session-2","cwd":"/other","profile":"explicit-profile"}), + ) + .await; + assert_eq!(explicit["structuredContent"]["profile"], "explicit-profile"); let requests = backend.recorded(); - let new: Vec<_> = requests + let creations: Vec<_> = requests .iter() .filter(|r| r["method"] == "session/new") .collect(); - assert!(new[0]["params"].get("_meta").is_none()); + assert!(creations[0]["params"].get("_meta").is_none()); assert_eq!( - new[1]["params"]["_meta"]["querymt"]["profile_id"], + creations[1]["params"]["_meta"]["querymt"]["profile_id"], "explicit-profile" ); assert_no_implicit_cancel(&backend); @@ -1085,60 +1049,30 @@ fn assert_creation_conflict(result: &Value, acknowledged_id: &str) { #[tokio::test] async fn workspace_metadata_before_creation_ack_reconciles_or_reports_a_conflict() { - tokio::time::timeout(Duration::from_secs(20), async { - for (metadata_cwd, metadata_profile, initial_profile, success) in [ - ("/remote/new", None, Some("inherited-profile"), true), - ( - "/remote/new", - Some("inherited-profile"), - Some("inherited-profile"), - true, - ), - ("/remote/new", Some("authoritative-profile"), None, true), - ( - "/remote/contradiction", - None, - Some("inherited-profile"), - false, - ), - ( - "/remote/new", - Some("conflicting-profile"), - Some("inherited-profile"), - false, - ), - ] { + tokio::time::timeout(Duration::from_secs(10), async { + for (metadata_cwd, success) in [("/remote/new", true), ("/contradiction", false)] { let backend = MockAcp::start().await; let mut mcp = Mcp::start(&backend.url, true).await; - let old = mcp - .tool( - "workspace", - json!({"cwd": "/remote/old", "profile": initial_profile}), - ) - .await; - assert_eq!(old["isError"], false, "{old}"); + mcp.tool( + "workspace", + json!({"cwd":"/remote/old","profile":"inherited"}), + ) + .await; let (ready, release) = gated_creation(&backend); let pending = concurrent_tool( &mcp, "prompt", - json!({"session_id": "fixture-session", "cwd": "../new", "text": "switch prompt"}), + json!({"session_id":"fixture-session","cwd":"../new","text":"switch prompt"}), ); ready.await.unwrap(); - // session/new has materialized B, but its ACK cannot reach qmcp yet. - let mut summary = json!({"sessionId": "fixture-session-2", "cwd": metadata_cwd}); - if let Some(profile) = metadata_profile { - summary["_meta"] = json!({"querymt": {"profile_id": profile}}); - } - backend.workspace.lock().unwrap().listing = Some(vec![json!({"sessions": [summary]})]); + // The backend has created B, but another call discovers B before its ACK. + backend.workspace.lock().unwrap().listing = Some(vec![ + json!({"sessions":[{"sessionId":"fixture-session-2","cwd":metadata_cwd}]}), + ]); let discovered = mcp - .tool("workspace", json!({"session_id": "fixture-session-2"})) + .tool("workspace", json!({"session_id":"fixture-session-2"})) .await; - assert_eq!(discovered["isError"], false, "{discovered}"); assert_eq!(discovered["structuredContent"]["created"], false); - let status = mcp - .tool("status", json!({"session_id": "fixture-session-2"})) - .await; - assert_eq!(status["structuredContent"]["cwd"], metadata_cwd); assert!( !backend .recorded() @@ -1149,42 +1083,14 @@ async fn workspace_metadata_before_creation_ack_reconciles_or_reports_a_conflict let result = pending.await.unwrap(); if success { assert_eq!(result["isError"], false, "{result}"); - let content = &result["structuredContent"]; - assert_eq!(content["sessionId"], "fixture-session-2"); - assert_eq!(content["previousSessionId"], "fixture-session"); - assert_eq!(content["created"], true); - assert_eq!(content["switched"], true); - let expected_profile = initial_profile.or(metadata_profile).unwrap(); - assert_eq!(content["profile"], expected_profile); assert_eq!( - content["profileSource"], - if initial_profile.is_some() { - "inherited" - } else { - "authoritative-metadata" - } + result["structuredContent"]["sessionId"], + "fixture-session-2" ); - let remembered = mcp - .tool("workspace", json!({"session_id": "fixture-session-2"})) - .await; - assert_eq!(remembered["structuredContent"]["profile"], expected_profile); - let prompt = mcp - .tool( - "prompt", - json!({"session_id": "fixture-session-2", "text": "actual next prompt"}), - ) - .await; - assert_eq!(prompt["isError"], false, "{prompt}"); - let requests = backend.recorded(); - let prompts: Vec<_> = requests - .iter() - .filter(|r| r["method"] == "session/prompt") - .collect(); - assert_eq!(prompts.len(), 2); - assert!( - prompts - .iter() - .all(|r| r["params"]["sessionId"] == "fixture-session-2") + assert_eq!(result["structuredContent"]["profile"], "inherited"); + assert_eq!( + result["structuredContent"]["previousSessionId"], + "fixture-session" ); } else { assert_creation_conflict(&result, "fixture-session-2"); @@ -1194,25 +1100,20 @@ async fn workspace_metadata_before_creation_ack_reconciles_or_reports_a_conflict .iter() .any(|r| r["method"] == "session/prompt") ); - let remembered = mcp - .tool("workspace", json!({"session_id": "fixture-session-2"})) - .await; - assert_eq!(remembered["structuredContent"]["cwd"], metadata_cwd); - assert_eq!( - remembered["structuredContent"]["profile"], - json!(metadata_profile) - ); } + let remembered = mcp + .tool("workspace", json!({"session_id":"fixture-session-2"})) + .await; + assert_eq!(remembered["structuredContent"]["cwd"], metadata_cwd); let old = mcp - .tool("workspace", json!({"session_id": "fixture-session"})) + .tool("workspace", json!({"session_id":"fixture-session"})) .await; assert_eq!(old["structuredContent"]["cwd"], "/remote/old"); - assert_eq!(old["structuredContent"]["profile"], json!(initial_profile)); assert_no_implicit_cancel(&backend); } }) .await - .expect("barrier-controlled metadata/ACK test must complete"); + .expect("barrier-controlled ACK test must complete"); } #[tokio::test] @@ -1268,46 +1169,6 @@ async fn workspace_prebound_duplicate_ack_never_publishes_an_invalid_selection() } } -#[tokio::test] -async fn workspace_two_pending_creation_acks_can_claim_a_metadata_id_only_once() { - tokio::time::timeout(Duration::from_secs(10), async { - let backend = MockAcp::start().await; - backend.workspace.lock().unwrap().forced_id = Some("collision".into()); - let mut mcp = Mcp::start(&backend.url, true).await; - let (first_ready, first_release) = gated_creation(&backend); - let (second_ready, second_release) = gated_creation(&backend); - let first = concurrent_tool(&mcp, "workspace", json!({"cwd": "/remote/same"})); - first_ready.await.unwrap(); - let second = concurrent_tool(&mcp, "workspace", json!({"cwd": "/remote/same"})); - second_ready.await.unwrap(); - let metadata = mcp - .tool("workspace", json!({"session_id": "collision"})) - .await; - assert_eq!(metadata["isError"], false, "{metadata}"); - first_release.send(()).unwrap(); - second_release.send(()).unwrap(); - let results = [first.await.unwrap(), second.await.unwrap()]; - assert_eq!( - results.iter().filter(|r| r["isError"] == false).count(), - 1, - "{results:?}" - ); - let failed = results.iter().find(|r| r["isError"] == true).unwrap(); - assert_creation_conflict(failed, "collision"); - assert_eq!( - backend - .recorded() - .iter() - .filter(|r| r["method"] == "session/new") - .count(), - 2 - ); - assert_no_implicit_cancel(&backend); - }) - .await - .expect("both barrier-controlled ACKs must complete"); -} - async fn exercise_bridge(http: bool) { let backend = MockAcp::start().await; let mut mcp = Mcp::start(&backend.url, http).await;