From 3078097b60ab78b36d6f9db563759fda21844408 Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Fri, 31 Jul 2026 23:19:28 -0400 Subject: [PATCH 1/8] docs(plan): define stdio MCP proxy implementation --- .../2026-07-31-stdio-mcp-proxy-research.md | 344 ++++++ ...26-07-31-stdio-mcp-proxy-implementation.md | 1091 +++++++++++++++++ 2 files changed, 1435 insertions(+) create mode 100644 docs/reports/2026-07-31-stdio-mcp-proxy-research.md create mode 100644 docs/superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md diff --git a/docs/reports/2026-07-31-stdio-mcp-proxy-research.md b/docs/reports/2026-07-31-stdio-mcp-proxy-research.md new file mode 100644 index 000000000..44a9a959b --- /dev/null +++ b/docs/reports/2026-07-31-stdio-mcp-proxy-research.md @@ -0,0 +1,344 @@ +# Stdio MCP Proxy Research Report + +- **Date:** 2026-07-31 +- **Labby base:** `eff39c79d97c907f6c9956f4711ecab5cd8df62f` +- **Target command:** `labby proxy ` +- **Primary UX:** `labby proxy /path/to/dist.js` + +## Executive finding + +The feature is feasible and fits Labby's product boundary, but it is not raw port forwarding. It is a supervised MCP transport adapter with four independent responsibilities: + +1. Launch and own a stdio MCP child process. +2. Preserve MCP requests, notifications, results, extensions, cancellation, progress, MRTR, tasks, and subscriptions while translating stdio to Streamable HTTP. +3. Protect the HTTP resource with tailnet policy, a static bearer token, or OAuth. +4. Publish the loopback HTTP listener through Tailscale Serve without clobbering existing Serve configuration. + +The normal user flow can remain one command after setup: + +```console +labby proxy /path/to/dist.js +``` + +The implementation must be a dedicated direct-proxy runtime. Reusing the aggregate gateway path would alter names, catalogs, capability reporting, resource URIs, result metadata, and extension behavior, which violates the meaning of proxying one specific server. + +## Research anchors + +### Labby + +- Source tree: current `origin/main` at `eff39c79d97c907f6c9956f4711ecab5cd8df62f`. +- Workspace RMCP pin: `=3.0.0-beta.2` at the research base. +- Active, separate RMCP upgrade worktree: branch `chore/pin-rmcp-3.1.0-20260731`. It already changes the workspace pin and lockfile but was uncommitted when inspected. +- Active, separate MCP capability worktree: branch `audit/mcp-2026-07-28-capabilities`. It contains uncommitted aggregate-gateway work and a capability audit. This proxy plan does not modify or depend on those uncommitted files. + +### RMCP + +- Release: `rmcp-v3.1.0`. +- Source commit inspected: `1f9358eddca42d3a510c70ae6446dd6548c7c856`. +- Release date: 2026-07-31. +- Relevant changes: + - authorization-required error classification; + - strict stateless request metadata validation; + - receive-side request-association enforcement; + - MRTR input-required result decoding fixes; + - modern HTTP metadata enforcement; + - protocol negotiation fixes. + +### MCP specification + +- Source tag inspected: `2026-07-28-RC`. +- Source commit inspected: `9d700ed62dcf86cb77475c9b81930611a9182f46`. +- Status on 2026-07-31: release candidate. The latest stable dated specification remains 2025-11-25. +- The implementation should deliberately target the pinned 2026-07-28 RC source while retaining compatibility negotiation for older servers. The live draft has already evolved beyond the tag, so implementation and conformance must never rely on an unpinned moving page without an explicit drift review. + +### Tailscale + +- Live dookie binary inspected: Tailscale 1.98.10. +- The node was connected and already had unrelated Serve mappings. +- No existing Serve mapping was changed during research. +- A temporary foreground-cleanup experiment did not return a usable tool result envelope. A post-check confirmed that the temporary port was absent and the existing mappings remained. Exact signal and fallback cleanup behavior therefore remains an explicit implementation spike and release gate, not an assumed contract. + +## Protocol findings + +### 1. The 2026 lifecycle is stateless, including stdio + +The 2026-07-28 RC states that an open connection or stdio process is not a session. Each request carries protocol version, client identity, and client capabilities in `_meta`. Unrelated requests may be interleaved on the same stdio process. + +Consequences for the proxy: + +- It must forward each downstream request's metadata, not reuse one synthetic startup identity. +- It must not infer a client session from the HTTP connection, bearer token, Tailscale identity, or child process. +- Persistent task or subscription state must be represented by explicit protocol identifiers. +- Request IDs, progress tokens, and subscription IDs must be correlated independently. + +### 2. Streamable HTTP is one POST per JSON-RPC message + +For 2026-07-28: + +- one MCP endpoint accepts POST; +- every request, notification, or input response is its own POST; +- a request returns JSON or a request-scoped SSE response; +- progress and request-scoped notifications belong only on the originating response stream; +- `subscriptions/listen` owns its own long-lived SSE response; +- closing an SSE response cancels that request; +- protocol-level HTTP sessions and the standalone GET stream are removed; +- Origin validation and loopback binding are normative security requirements. + +The local adapter must bind to `127.0.0.1` and must keep RMCP's Host, Origin, metadata-header, and response-stream enforcement enabled. + +### 3. Modern server-to-client interactions use MRTR + +Under 2026-07-28 Streamable HTTP, sampling, elicitation, and roots are not independent JSON-RPC requests on an SSE stream. They are returned in `InputRequiredResult`, and the client retries with input responses. + +Consequences: + +- For a modern 2026 stdio child, the proxy can preserve MRTR results directly. +- The direct runtime must not auto-consume or rewrite incomplete MRTR results. +- Legacy initialized children still require a client handler for server-to-client requests. Those requests are multiplexed over stdio without a transport stream identifier, so legacy forwarding needs a correctness gate that prevents ambiguous concurrent association. + +### 4. Subscriptions require explicit ID translation + +`subscriptions/listen`: + +- is a long-lived request; +- must send `notifications/subscriptions/acknowledged` first; +- may acknowledge only the supported subset of the requested filter; +- tags every delivered notification with `io.modelcontextprotocol/subscriptionId`; +- permits multiple concurrent subscriptions; +- is cancelled by closing HTTP SSE or sending `notifications/cancelled` on stdio. + +A proxy cannot treat subscriptions as ordinary request-response forwarding. It needs a dedicated bridge that maps downstream listen IDs to upstream listen IDs and rewrites notification metadata. + +### 5. Custom extensions can be preserved by RMCP 3.1 + +RMCP 3.1's `ClientRequest`, `ServerRequest`, and notification unions include `CustomRequest` and `CustomNotification` variants. The raw method and params are preserved. `ServerResult` includes `CustomResult`. + +This makes a typed low-level `Service` bridge viable without dropping unknown extension methods. It is still necessary to add tests that prove unknown request, notification, metadata, and result fields round-trip unchanged. + +## RMCP API findings + +### Low-level service boundary + +`rmcp::service::Service` receives a complete `ClientRequest`, `RequestContext`, complete client notifications, and returns `ServerResult`. This is a better fit than implementing each aggregate gateway handler separately. + +The request context exposes: + +- a cancellation token; +- the downstream request ID; +- the request metadata; +- transport extensions; +- the downstream peer needed to deliver legacy server-to-client requests or notifications. + +### Upstream request metadata + +`PeerRequestOptions::with_meta` lets the caller provide request metadata. RMCP first applies connection defaults, then extends them with explicit metadata, so the direct bridge can override synthetic startup values with the actual downstream request metadata. + +The proxy must still replace collision-prone fields before forwarding: + +- progress token; +- any proxy-owned request-correlation extension; +- subscription ID where applicable. + +All other metadata and unknown extension keys should be preserved. + +### Lifecycle negotiation + +`ClientLifecycleMode::Auto`: + +1. probes with `server/discover`; +2. falls back to legacy initialize only on method-not-found; +3. treats unsupported protocol version as modern negotiation, not a legacy signal. + +This matches the RC's stdio compatibility rules and should replace Labby's current manual reconnect logic in the direct proxy runtime. + +### Subscription client API + +RMCP's client peer exposes `listen`, which: + +- opens an upstream `subscriptions/listen` request; +- validates the first acknowledgment; +- creates a notification channel scoped to the upstream request ID; +- cancels the request when the handle is dropped. + +The proxy should use this API for a modern child, then rewrite the upstream subscription ID to the downstream ID before delivering each notification. + +## Labby architecture findings + +### CLI + +- `crates/labby/src/cli.rs` has no `proxy` command. +- There is no existing Clap trailing-command precedent. +- The parser contract requires dedicated tests for: + - a script path with no flags; + - child arguments beginning with `-`; + - Labby options before the child target; + - an explicit `--` separator; + - non-UTF-8 arguments on Unix. + +### Configuration + +- `LabConfig` has no proxy section. +- Existing precedence is CLI/process environment, `~/.labby/.env`, `config.toml`, built-in default. +- Atomic, comment-preserving config mutation already exists and should be reused. +- Secrets belong in `~/.labby/.env`; non-secret proxy preferences belong in `[proxy]`. + +### Existing HTTP runtime + +`crates/labby/src/cli/serve.rs` already provides useful pieces: + +- RMCP Streamable HTTP service construction; +- loopback listener and Axum serving; +- Host allowlisting; +- Origin enforcement through RMCP; +- graceful shutdown helpers; +- bearer and OAuth bootstrapping. + +The file is large and tightly coupled to the complete Labby app router. The proxy should extract reusable HTTP-listener and shutdown primitives rather than copy the full `serve` path or start the aggregate gateway. + +The proxy must not force JSON-only responses. It needs SSE for progress and `subscriptions/listen`. + +### Existing stdio connector + +`crates/labby-gateway/src/upstream/pool/connect_stdio.rs` already implements the difficult process-safety pieces: + +- direct process execution without a shell; +- cleared ambient environment plus a runtime allowlist; +- explicit environment injection; +- continuous stderr draining; +- Unix process groups; +- Windows Job Objects; +- descendant cleanup; +- modern discovery with legacy compatibility. + +The proxy should extract and reuse these pieces. A command typed directly by the local operator should use an `ExplicitLocalCli` spawn authority that bypasses the persisted-config executable allowlist while retaining every other safeguard. + +### Existing relay is insufficient + +`crates/labby-gateway/src/upstream/pool/relay.rs` explicitly relays interactive behavior for `call_tool` only. Prompt and resource calls use other paths and do not preserve all interaction or metadata behavior. + +A transparent proxy therefore needs a separate direct runtime. Extending aggregate catalog relay incrementally is not an acceptable implementation shortcut. + +### Existing OAuth route model is insufficient for random ports + +Current protected MCP routes derive a public resource from host plus path and assume HTTPS default-port formatting. A random Tailscale port is part of the OAuth resource identifier and audience, so the proxy must use an exact full resource URL such as: + +```text +https://node.tailnet.ts.net:53147/mcp +``` + +The existing protected-route config should not be overloaded for ephemeral proxy resources. + +### OAuth audiences are runtime state + +`labby-auth::AuthState` keeps additional accepted OAuth resource audiences in a shared in-memory map. The authorization and token endpoints reject an unregistered resource. + +Current route refresh code replaces that entire map from persisted protected routes. Therefore ephemeral proxy resources require a first-class lease registry with separate configured and leased resources. Calling the current replace method from request handling would otherwise erase active proxy leases. + +OAuth mode needs: + +- a stable, already-running Labby authorization server; +- a short-lived lease for the exact proxy resource URL and scopes; +- periodic lease renewal; +- removal on normal shutdown; +- expiry after crashes; +- local JWT validation against the exact audience, issuer, expiry, and scopes; +- no fallback to bearer or no-auth if lease registration fails. + +The existing `LiveGateway` thin-client path and generic action dispatch can carry lease create, renew, and release actions to the running daemon. + +## Tailscale findings + +The current CLI supports: + +- foreground or background Serve; +- a selected HTTPS port through `--https`; +- non-interactive updates through `--yes`; +- reverse proxying to a loopback HTTP target; +- JSON status output; +- multiple existing ports and handlers on one node. + +The proxy should use foreground Serve, owned as a child process. It should: + +1. read status and choose a port absent from both TCP and Web maps; +2. spawn `tailscale serve --yes --https= http://127.0.0.1:`; +3. wait until status contains the exact expected backend mapping; +4. watch both the Serve process and mapping during runtime; +5. stop the foreground process on shutdown; +6. verify the exact mapping disappeared; +7. use exact-port `off` cleanup only if the mapping still points to the proxy's backend; +8. never use `tailscale serve reset`; +9. refuse to remove a mapping that no longer matches the proxy's ownership record. + +A random port pre-check is not a reservation. Collision handling must be based on the actual Serve command result and verified status. + +## Resolved product decisions + +1. **Primary command:** `labby proxy /path/to/dist.js`. +2. **No required flags:** configured defaults and built-in defaults drive exposure, auth, and port selection. +3. **Built-in exposure default:** Tailscale Serve. +4. **Built-in auth default:** tailnet policy only. Bearer and OAuth become zero-flag after setup. +5. **No silent exposure fallback:** if Tailscale is selected but unavailable, fail. Do not bind to LAN. +6. **No silent auth fallback:** OAuth, bearer, tailnet, and none are distinct policies. +7. **Internal listener:** always `127.0.0.1:0` unless an explicit local-only development mode says otherwise. +8. **External random range:** IANA dynamic/private range, 49152 through 65535, configurable. +9. **Normal lifetime:** foreground. Ctrl+C owns and stops child, HTTP listener, OAuth lease, and Serve mapping. +10. **Transparent catalog:** no Labby built-ins, Code Mode, namespacing, filtering, or result normalization. +11. **Separate secret:** `LABBY_PROXY_BEARER_TOKEN`; never reuse `LABBY_MCP_HTTP_TOKEN`. +12. **OAuth issuer:** stable Labby daemon. Random-port proxy is the protected resource, not the issuer. +13. **Modern behavior:** full concurrent stateless forwarding with per-request metadata. +14. **Legacy behavior:** compatibility adapter with serialization where independent server-to-client request association would otherwise be ambiguous. +15. **Unknown extensions:** preserved and covered by round-trip tests. + +## Integration constraints + +### RMCP upgrade branch + +The proxy implementation must begin from a committed RMCP 3.1.0 upgrade. Do not duplicate the uncommitted work in `chore/pin-rmcp-3.1.0-20260731`. Merge or cherry-pick its final commit after it passes the workspace and conformance gates. + +### MCP capability branch + +The direct proxy should live in new modules and minimize overlap with the active aggregate-gateway capability work. It may reuse generic fixes after those changes land, but it must not rely on aggregate catalog behavior for correctness. + +### Conformance gate + +The current conformance script is pinned to RMCP 3.0.0-beta.2 and a matching commit. The RMCP upgrade deliverable must update: + +- script comments; +- RMCP version; +- RMCP tag; +- exact source commit; +- expected failure baselines where justified; +- upstream drift baseline. + +## Remaining implementation spikes + +These are bounded engineering spikes, not product questions: + +1. Prove the exact RMCP service/factory shape needed to keep one supervised stdio child behind a stateless HTTP handler factory. +2. Prove progress-token replacement and restoration with two concurrent requests using the same downstream token. +3. Prove modern `subscriptions/listen` bridging with two concurrent subscriptions. +4. Prove legacy resource subscription adaptation and ref-counted unsubscribe. +5. Prove Tailscale foreground termination and exact fallback cleanup on all supported Tailscale versions. +6. Prove dynamic OAuth resource lease renewal across a daemon restart. +7. Prove non-UTF-8 command arguments and process-tree cleanup on Linux and Windows. + +Each spike has a mandatory test and evidence artifact in the implementation plan. A failed spike changes the implementation mechanism, not the user-facing command contract. + +## Authoritative sources + +- MCP 2026-07-28 RC announcement: https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ +- MCP 2026-07-28 RC source tag: https://github.com/modelcontextprotocol/modelcontextprotocol/tree/2026-07-28-RC +- MCP versioning and compatibility: https://modelcontextprotocol.io/specification/draft/basic/versioning +- MCP stdio transport: https://modelcontextprotocol.io/specification/draft/basic/transports/stdio +- MCP Streamable HTTP transport: https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http +- MCP subscriptions: https://modelcontextprotocol.io/specification/draft/basic/patterns/subscriptions +- MCP discovery: https://modelcontextprotocol.io/specification/draft/server/discover +- MCP authorization: https://modelcontextprotocol.io/specification/draft/basic/authorization +- RMCP 3.1.0 release: https://github.com/modelcontextprotocol/rust-sdk/releases/tag/rmcp-v3.1.0 +- Tailscale Serve CLI: https://tailscale.com/docs/reference/tailscale-cli/serve +- Tailscale Serve overview: https://tailscale.com/docs/features/tailscale-serve +- Clap trailing arguments: https://docs.rs/clap/latest/clap/struct.Command.html#method.trailing_var_arg + +## Research conclusion + +No unresolved protocol or product question prevents implementation. The remaining uncertainty is concentrated in testable adapter mechanics. The implementation plan turns each uncertain mechanism into an early spike with a binary pass/fail gate, then requires protocol conformance, adversarial fault injection, cross-platform process tests, and a live Tailscale/OAuth proof pack before release. diff --git a/docs/superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md b/docs/superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md new file mode 100644 index 000000000..65e48d403 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md @@ -0,0 +1,1091 @@ +# Stdio MCP Proxy Implementation Plan + +> **For implementers:** Execute this plan in order. Every task has a test-first gate and an evidence requirement. Do not collapse the direct proxy into the aggregate gateway. + +**Goal:** After one-time configuration, `labby proxy /path/to/dist.js` launches a stdio MCP server, exposes a faithful Streamable HTTP endpoint on a random high port through Tailscale Serve, applies the configured tailnet, bearer, or OAuth policy, and owns complete cleanup on exit. + +**Research report:** `docs/reports/2026-07-31-stdio-mcp-proxy-research.md` + +**Base commit used for this plan:** `eff39c79d97c907f6c9956f4711ecab5cd8df62f` + +## Release contract + +No software test can prove that every possible defect is absent. This plan replaces vague confidence with a falsifiable release contract: + +- every protocol behavior has a named automated test; +- every external integration has a deterministic fake and a live proof; +- every cleanup path is fault-injected; +- every dependency and external fixture is pinned by version and commit; +- every release candidate produces a machine-readable evidence bundle; +- the full proof suite must pass twice from a clean checkout at the exact commit being released; +- no expected failure may be added without a linked rationale and owner; +- no deliverable is accepted from configuration, compilation, or logs alone when runtime verification is possible. + +A release is valid only when the evidence manifest reports every mandatory gate as passed and its recorded git tree is clean. + +## User-facing contract + +### Normal use + +```console +labby proxy /path/to/dist.js +``` + +Expected output: + +```text +MCP proxy ready + + Server node /path/to/dist.js + URL https://node.tailnet.ts.net:53147/mcp + Exposure Tailscale Serve + Auth OAuth + +Press Ctrl+C to stop. +``` + +### Child arguments + +```console +labby proxy /path/to/dist.js --workspace /srv/data --read-only +``` + +Once the first child-command token is consumed, subsequent tokens belong to the child, including tokens beginning with `-`. + +### One-run overrides + +Labby options appear before the child target: + +```console +labby proxy --port 52177 /path/to/dist.js +labby proxy --auth oauth /path/to/dist.js +labby proxy --bearer-token "$TOKEN" /path/to/dist.js +labby proxy --local --auth none /path/to/dist.js +``` + +An explicit separator remains supported for unusual command lines: + +```console +labby proxy -- npx -y @modelcontextprotocol/server-filesystem /srv/data +``` + +### Setup + +```console +labby setup proxy +``` + +The setup flow writes non-secrets to `~/.labby/config.toml` and secrets to `~/.labby/.env`. After setup, the normal proxy command requires no flags. + +## Product decisions + +1. The proxy is foreground-only in the first release. +2. The default exposure is Tailscale Serve. +3. The built-in auth default is `tailnet`, meaning Tailscale reachability and grants without an additional application token. +4. Bearer and OAuth are configuration defaults selected once through setup. +5. There is no silent fallback between exposure or auth modes. +6. The local HTTP listener binds to `127.0.0.1:0`. +7. The external random port range defaults to 49152 through 65535. +8. Tailscale Funnel is out of scope. +9. The proxy exposes only the child server. It does not add Labby tools, Code Mode, catalog prefixes, filters, or normalization. +10. Unknown MCP extension methods and payloads must round-trip unchanged. +11. Modern 2026 requests may run concurrently. +12. Legacy initialized requests use a serialization gate when independent server-to-client request association would be ambiguous. +13. The proxy bearer token is separate from the Labby administrator token. +14. OAuth uses a stable Labby authorization server and an ephemeral resource lease for the exact random-port URL. +15. A Tailscale or OAuth failure stops startup. It never produces a partially exposed or silently weakened endpoint. + +## Non-goals for the first release + +- detached or persistent proxies; +- `proxy list` and `proxy stop`; +- automatic child restart; +- public internet exposure; +- Tailscale Service virtual IPs; +- multiple stdio children behind one endpoint; +- persisted proxy definitions; +- editing tailnet ACLs or grants; +- remote deployment of the child executable; +- changing aggregate gateway naming or catalog behavior. + +## Configuration contract + +Add a top-level `[proxy]` table: + +```toml +[proxy] +exposure = "tailscale" +auth = "tailnet" +path = "/mcp" +external_port = "random" +port_range = { start = 49152, end = 65535 } +bearer_token_env = "LABBY_PROXY_BEARER_TOKEN" +oauth_scopes = ["mcp:read", "mcp:write"] +inherit_env = [] +shutdown_grace_ms = 3000 +``` + +A fixed external port is represented by an integer: + +```toml +[proxy] +external_port = 52177 +``` + +Recommended model: + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyPreferences { + pub exposure: ProxyExposure, + pub auth: ProxyAuthMode, + pub path: String, + pub external_port: ProxyPortPreference, + pub port_range: ProxyPortRange, + pub bearer_token_env: String, + pub oauth_scopes: Vec, + pub inherit_env: Vec, + pub shutdown_grace_ms: u64, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProxyExposure { + Tailscale, + Local, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProxyAuthMode { + Tailnet, + Bearer, + Oauth, + None, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ProxyPortPreference { + Fixed(u16), + Named(ProxyPortMode), +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProxyPortMode { + Random, +} +``` + +### Validation + +Reject configuration when: + +- the path is empty, lacks a leading slash, is `/`, or contains a query or fragment; +- the range start is greater than the end; +- either range endpoint is below 1024 unless an explicit advanced override is present; +- the fixed port is zero; +- `auth = "tailnet"` with `exposure = "local"`; +- `auth = "bearer"` has no token from the CLI, process environment, or configured env-file key; +- `auth = "oauth"` has no stable public issuer, no reachable live daemon, or no scopes; +- an inherited environment variable name is invalid; +- shutdown grace exceeds a documented upper bound. + +### Precedence + +1. one-run CLI override; +2. process environment and `~/.labby/.env`; +3. `[proxy]` configuration; +4. built-in defaults. + +Never persist a one-run literal bearer token. + +## CLI and command resolution + +### Parser + +Add `Proxy(ProxyArgs)` to the top-level command enum and `crates/labby/src/cli/proxy.rs`. + +Use a trailing positional vector of `OsString`, not `String`. The parser must preserve non-UTF-8 arguments on Unix and must never pass the command through a shell. + +The exact Clap shape must be proven by tests before runtime code is added. The intended behavior is: + +- Labby flags are parsed only before the first child token; +- all later tokens are child arguments; +- `--` is accepted but not required; +- missing child command is a usage error; +- `--bearer-token` implies `auth = bearer`; +- `--local` implies `exposure = local` but does not silently rewrite auth. + +### Resolver precedence + +For the first command token: + +1. If it names an existing executable file, execute it directly. +2. If it names a file with a valid shebang, use the operating system's direct executable path when possible. If not executable on Unix, parse only a standards-compliant shebang into an interpreter and optional single argument. +3. If it ends in `.js`, `.mjs`, or `.cjs`, resolve `node` through `PATH` and prepend the file. +4. If it ends in `.py`, resolve `python3` through `PATH` and prepend the file. +5. If it is a bare command, resolve it through `PATH`. +6. If it is an unknown non-executable file, fail with explicit suggested invocations. + +Do not guess a TypeScript runtime. A `.ts` file requires a shebang, explicit command, or future configured launcher map. + +### Child process policy + +Introduce: + +```rust +pub enum StdioSpawnAuthority { + PersistedConfiguration, + ExplicitLocalCli, +} +``` + +`ExplicitLocalCli` bypasses the persisted-config command-name allowlist because the operator typed the command directly. It does not bypass: + +- shell avoidance; +- environment clearing; +- runtime environment allowlist; +- explicit environment inheritance; +- stderr draining; +- process group or Job Object ownership; +- shutdown escalation; +- secret redaction. + +The default child working directory is the caller's current directory. Add a `--cwd` override. Do not add cwd to the shared persisted upstream schema solely for this feature unless another consumer needs it. + +## Architecture + +```text +Remote MCP client + | + | HTTPS over tailnet + v +Tailscale Serve child process + | + | HTTP to 127.0.0.1: + v +Proxy HTTP router + - Host and Origin validation + - tailnet / bearer / OAuth policy + - protected-resource metadata + | + v +RMCP StreamableHttpService + | + v +DirectProxyService + - request metadata forwarding + - ID and token correlation + - cancellation + - subscriptions + - legacy interaction gate + | + v +DirectUpstream + - RMCP 3.1 client lifecycle Auto + - stdio transport + - child process guard + | + v +stdio MCP child +``` + +### Module layout + +Create or extract: + +```text +crates/labby/src/cli/proxy.rs +crates/labby/src/proxy/mod.rs +crates/labby/src/proxy/config.rs +crates/labby/src/proxy/http.rs +crates/labby/src/proxy/auth.rs +crates/labby/src/proxy/supervisor.rs +crates/labby/src/proxy/tailscale.rs +crates/labby/src/proxy/verify.rs + +crates/labby-gateway/src/direct_proxy/mod.rs +crates/labby-gateway/src/direct_proxy/service.rs +crates/labby-gateway/src/direct_proxy/upstream.rs +crates/labby-gateway/src/direct_proxy/correlation.rs +crates/labby-gateway/src/direct_proxy/subscriptions.rs +crates/labby-gateway/src/direct_proxy/legacy.rs +crates/labby-gateway/src/upstream/stdio_process.rs + +crates/labby-auth/src/resource_leases.rs +``` + +Names may be adjusted to existing module conventions, but responsibilities must remain separated and testable. + +## Direct MCP forwarding design + +### Upstream startup + +1. Resolve the command vector. +2. Spawn the child with the extracted stdio process guard. +3. Construct a direct RMCP client service using `ClientLifecycleMode::Auto`. +4. Preferred versions begin with `2026-07-28`; legacy fallback uses the latest supported initialized version. +5. Record whether the child negotiated modern discovery or legacy initialize. +6. Probe the child before publishing any external URL. +7. Capture the child's implementation, versions, capabilities, and extension map without changing names or payloads. + +### Requests + +Implement low-level `Service` so complete request enums, including `CustomRequest`, remain available. + +For each downstream request: + +1. Clone the request and its metadata. +2. Preserve protocol version, client info, capabilities, log level, trace context, extension keys, and unknown fields. +3. Replace the downstream progress token with a unique upstream token and register a reverse mapping. +4. Send the complete request using a cancellable RMCP request handle and explicit request metadata. +5. Race the upstream result against the downstream cancellation token. +6. On downstream cancellation, send `notifications/cancelled` for the upstream request. +7. Return the complete `ServerResult` without reconstructing known result types. +8. Remove correlation entries in a drop-safe finalizer. + +Forward `server/discover` to the child rather than synthesizing a catalog result. This permits child capability responses to reflect the actual downstream request metadata. + +### Notifications + +Forward complete client notifications. Special cases: + +- cancellation uses mapped upstream request IDs; +- initialized is valid only for a legacy downstream lifecycle and must never leak into a modern child; +- custom notifications preserve method, params, and metadata; +- invalid or unknown correlation targets are logged with redacted identifiers and ignored or rejected according to the protocol. + +### Progress + +Maintain a correlation record equivalent to: + +```text +upstream_progress_token -> { + downstream_peer, + downstream_progress_token, + downstream_request_id +} +``` + +Progress from the child is delivered only to the originating HTTP response stream. Two concurrent downstream requests using the same progress token must remain isolated. + +### MRTR and tasks + +For 2026 children: + +- return `InputRequiredResult`, task creation, task status, and task acknowledgments unchanged; +- preserve `resultType`, task metadata, input request IDs, and input responses; +- do not automatically answer sampling, roots, or elicitation; +- support custom extension task methods through RMCP custom variants when not represented by a typed variant. + +For older children, use the legacy interaction adapter below. + +### Modern subscriptions + +For a modern child: + +1. Receive downstream `subscriptions/listen` and its requested filter. +2. Open upstream `peer.listen` with the same filter and explicit downstream metadata. +3. Wait for the upstream acknowledgment. +4. Acknowledge only the upstream-accepted subset to the downstream stream. +5. Rewrite every upstream subscription ID to the downstream request ID. +6. Preserve all notification bodies and unknown metadata. +7. Cancel the upstream listen request when the downstream SSE stream closes. +8. Deliver the final graceful teardown result when the child supplies one. + +### Legacy subscription adapter + +For an initialized child: + +- tools, prompts, and resources list-change notifications are registered as proxy listeners only if the child advertises the corresponding legacy list-changed capability; +- requested resource URIs are translated to legacy `resources/subscribe` calls; +- URI subscriptions are reference counted across downstream listen streams; +- `resources/unsubscribe` is sent only when the last downstream subscriber leaves; +- global legacy notifications are fanned out only to streams whose acknowledged filter includes them; +- each emitted notification receives the downstream subscription ID; +- the acknowledgment omits every unsupported filter field. + +### Legacy server-to-client requests + +Legacy stdio has no request-stream association marker. To avoid routing a sampling, roots, or elicitation request to the wrong downstream caller: + +- hold a fair `LegacyInteractionGate` around ordinary legacy requests that can produce independent server-to-client requests; +- expose the currently active downstream peer and request context to the legacy client handler; +- route create-message, list-roots, elicitation, and custom server requests through that context; +- clear the context before releasing the gate; +- never hold the gate for the lifetime of a modern subscription stream; +- add starvation and cancellation tests. + +This is a correctness tradeoff for old servers. Modern 2026 requests remain concurrent. + +## HTTP runtime + +### Listener + +- bind `127.0.0.1:0`; +- record the selected local port; +- expose `POST /mcp` using RMCP 3.1 Streamable HTTP; +- expose the RFC 9728 protected-resource metadata path when OAuth is enabled; +- expose local `/health` and `/ready` endpoints with no server catalog or secret data; +- use SSE-capable response configuration, not forced JSON responses; +- include `X-Accel-Buffering: no` on SSE responses; +- disable legacy protocol sessions for modern requests while retaining RMCP's negotiated legacy compatibility where required. + +### Host and Origin + +Allowed hosts are constructed only after the final external URL is known: + +- `127.0.0.1:`; +- `localhost:`; +- exact Tailscale DNS name plus external port; +- explicit local override host, when applicable. + +Allowed browser origins include the exact HTTPS Tailscale origin. Invalid present Origin headers return 403. Do not disable RMCP's metadata-header validation. + +### Readiness + +The endpoint becomes ready only after: + +- child discovery succeeds; +- HTTP listener is accepting; +- auth policy is constructed; +- OAuth resource lease is active, when selected; +- Tailscale status shows the exact mapping, when selected. + +Do not print the final URL before every readiness condition passes. + +## Authentication + +### Tailnet + +`auth = "tailnet"` adds no application token. It is valid only with Tailscale exposure. The output must clearly say that access is controlled by tailnet grants and ACLs. + +### Static bearer + +Sources, in precedence order: + +1. `--bearer-token`; +2. `--bearer-token-stdin`; +3. process environment variable named by `bearer_token_env`; +4. the same key in `~/.labby/.env`. + +Requirements: + +- separate key, default `LABBY_PROXY_BEARER_TOKEN`; +- constant-time comparison; +- `WWW-Authenticate: Bearer` on failure; +- protection applies to MCP POST and its SSE response lifecycle; +- token never appears in human output, JSON output, traces, panic messages, process titles, or evidence artifacts; +- literal CLI token is never persisted; +- setup can generate at least 256 bits of cryptographic randomness; +- `labby setup proxy --bearer-token-stdin` is the documented automation path. + +### OAuth + +The exact resource and audience is: + +```text +https://:/mcp +``` + +The stable authorization server is the configured Labby public URL. + +#### Resource lease registry + +Refactor `AuthState` so configured protected routes and ephemeral leases are independent inputs to one effective resource map. + +Recommended API: + +```rust +replace_configured_resource_scopes(...) +create_resource_lease(resource, scopes, ttl, owner) -> LeaseId +renew_resource_lease(lease_id, ttl) +release_resource_lease(lease_id) +prune_expired_resource_leases(now) +effective_resource_scopes(resource) +``` + +Do not let request-time route refresh erase leases. + +Expose administrator-only live-daemon actions: + +```text +gateway.oauth.resource_lease.create +gateway.oauth.resource_lease.renew +gateway.oauth.resource_lease.release +``` + +The create action returns a random lease ID and expiration. The proxy renews at one third of the TTL with jitter. The daemon prunes expired leases independently. Owner metadata is a non-secret process fingerprint used only for diagnostics. + +#### Token validation + +The proxy validates: + +- signature from the stable Labby issuer; +- exact issuer; +- exact resource audience including port and path; +- expiration and not-before; +- required scopes; +- token type and supported algorithm. + +Reuse `labby-auth` signing and JWKS validation rather than implementing JWT parsing in the CLI. A same-host issuer may load shared keys; a remote configured issuer must use metadata plus JWKS with bounded caching and refresh-on-key-miss. + +Serve Protected Resource Metadata that advertises: + +- the exact proxy resource; +- the stable authorization server; +- configured proxy scopes; +- header bearer method. + +On startup, OAuth mode must: + +1. detect the live Labby daemon; +2. verify its stable public issuer; +3. create the resource lease; +4. construct the validator; +5. verify metadata is reachable; +6. publish through Tailscale. + +If any step fails, release the lease if created and stop the child. Never downgrade auth. + +## Tailscale Serve controller + +### Discovery + +Run and parse: + +- `tailscale version`; +- `tailscale status --json`; +- `tailscale serve status --json`. + +Verify: + +- backend state is running; +- the local node is online; +- a DNS name exists; +- HTTPS Serve can be used; +- the configured fixed port is not already owned by another mapping. + +Trim the trailing dot from the DNS name only for URL construction. + +### Port allocation + +For random ports: + +1. use a CSPRNG to shuffle or sample candidates from the configured range; +2. skip candidates present in both current TCP and Web maps; +3. attempt the real Serve command; +4. retry only recognized collisions or concurrent configuration conflicts; +5. cap attempts and return a diagnostic with the range and last error. + +Never use a listener pre-bind as proof that a Tailscale virtual port is free. + +### Foreground process ownership + +Spawn: + +```console +tailscale serve --yes --https= http://127.0.0.1: +``` + +The controller records: + +- executable identity and version; +- child process guard; +- external port; +- expected DNS authority; +- exact local backend URL; +- a normalized status fingerprint of the mapping. + +Readiness is status-based, not stdout-text-based. + +### Cleanup + +1. signal the foreground Serve process; +2. wait for the mapping to disappear; +3. if it remains, re-read status; +4. use exact-port `off` only when the mapping still points to the recorded backend; +5. if ownership changed, refuse removal and report the conflict; +6. verify unrelated mappings match the pre-start snapshot; +7. never call `reset`. + +The exact supported command shape for fallback cleanup must be established by the Tailscale spike and covered by versioned tests. + +## Supervisor + +Create one cancellation tree for: + +- operator Ctrl+C or SIGTERM; +- child process exit; +- HTTP server exit; +- Tailscale Serve exit; +- OAuth lease renewal failure; +- unrecoverable mapping drift. + +Whichever terminal condition occurs first cancels the complete runtime. + +Shutdown order: + +1. mark readiness false; +2. stop accepting new HTTP requests; +3. cancel active MCP requests and subscriptions; +4. close child stdin and wait; +5. escalate child process termination after grace; +6. stop and verify Tailscale mapping cleanup; +7. release OAuth lease; +8. flush bounded logs and print final status. + +Cleanup must be idempotent and safe when startup stops halfway through. + +## Observability and output + +### Human output + +Print only: + +- resolved child command; +- child server identity; +- final endpoint; +- exposure mode; +- auth mode; +- selected port; +- stop instruction. + +Do not print secrets, full environment, JWT claims, or raw auth headers. + +### JSON output + +`--json` returns one startup record: + +```json +{ + "url": "https://node.tailnet.ts.net:53147/mcp", + "exposure": "tailscale", + "auth": "oauth", + "externalPort": 53147, + "localPort": 38417, + "command": ["node", "/path/to/dist.js"], + "protocol": "2026-07-28", + "server": {"name": "example", "version": "1.0.0"} +} +``` + +No token, lease ID, unredacted subject, or environment value is included. + +### Logs + +Use stable event names and fields for: + +- command resolution; +- child spawn and exit; +- protocol lifecycle selected; +- local listener readiness; +- auth lease create, renew, and release; +- Tailscale candidate, claim, drift, and cleanup; +- request cancellation; +- subscription open and close; +- supervisor terminal reason. + +Hash or redact user, subject, token, request ID, progress token, and lease identifiers. + +## Implementation tasks + +### Task 0: Land RMCP 3.1.0 as an isolated prerequisite + +**Files:** workspace Cargo files, `scripts/ci/mcp-conformance.sh`, drift baselines, migration call sites. + +**Steps:** + +1. Finish the existing `chore/pin-rmcp-3.1.0-20260731` worktree rather than duplicating it. +2. Verify the MCP `2026-07-28-RC` source tag still resolves to `9d700ed62dcf86cb77475c9b81930611a9182f46`, record any live-draft drift, and update this plan before coding if the target changes. +3. Update the exact workspace pin to `=3.1.0`. +4. Update the conformance script to tag `rmcp-v3.1.0` and source commit `1f9358eddca42d3a510c70ae6446dd6548c7c856`. +5. Resolve API changes without adding compatibility wrappers that hide metadata failures. +6. Run workspace build, all-features tests, auth tests, conformance, and docs checks. + +**Acceptance:** clean commit; all mandatory existing gates pass; no new unexplained expected failure. + +### Task 1: Add proxy configuration and validation + +**Files:** `crates/labby/src/config.rs`, a focused config module if extracted, runtime docs, config tests. + +**Tests first:** parse defaults, parse fixed and random ports, reject invalid combinations, preserve comments during mutation, keep secrets absent from serialized TOML. + +**Acceptance:** `LabConfig::default()` yields Tailscale, tailnet, and a random external port; all precedence paths are deterministic. + +### Task 2: Freeze CLI grammar and command resolver + +**Files:** `crates/labby/src/cli.rs`, `crates/labby/src/cli/proxy.rs`, resolver module. + +**Tests first:** all parser and resolver cases in the command-resolution section, including non-UTF-8 Unix arguments and Windows path forms. + +**Acceptance:** `labby proxy /path/to/dist.js` resolves to Node with no required flag; child flags are unmodified; no shell is used. + +### Task 3: Extract reusable stdio process ownership + +**Files:** extract from `connect_stdio.rs` into a process module used by both the pool and direct proxy. + +**Tests first:** environment scrub, explicit inheritance, stderr drain, clean EOF shutdown, timeout escalation, Unix grandchild reap, Windows Job Object reap. + +**Acceptance:** existing pool behavior remains unchanged and direct proxy can own a child without constructing an aggregate upstream configuration. + +### Task 4: Spike and implement direct modern forwarding + +**Files:** `labby-gateway/direct_proxy` modules and a deterministic fixture. + +**Tests first:** complete request and result round-trip for tools, prompts, resources, templates, completion, tasks, MRTR, custom request, custom notification, custom result, metadata, and errors. + +Add concurrency tests with duplicate downstream JSON-RPC IDs and progress tokens. + +**Acceptance:** the 2026 fixture supports concurrent requests; no metadata or extension field is dropped; cancellation reaches the correct upstream request. + +### Task 5: Implement subscription forwarding + +**Tests first:** acknowledgment first, accepted subset, two concurrent modern listens, ID rewriting, stream cancellation, child teardown, ref-counted legacy resources, and global list-change filtering. + +**Acceptance:** subscription conformance scenarios pass and no notification crosses streams. + +### Task 6: Implement legacy interaction compatibility + +**Tests first:** Auto fallback only on method-not-found, initialized lifecycle, serialized sampling, roots, and elicitation routing, cancellation while queued, no starvation, old resource subscribe translation. + +**Acceptance:** a legacy fixture remains usable through the modern HTTP endpoint without ambiguous server-to-client routing. + +### Task 7: Extract and build the proxy HTTP router + +**Tests first:** loopback bind, Host allowlist, Origin rejection, required headers, JSON response, SSE progress, SSE cancellation, readiness transitions. + +**Acceptance:** the direct service is available at local `/mcp`; aggregate Labby tools are absent; progress and subscriptions stream. + +### Task 8: Add bearer policy + +**Tests first:** correct token, wrong token, missing token, constant-time helper, challenge header, SSE path, token redaction, and process-output scanning. + +**Acceptance:** every protected request requires the dedicated proxy token; no secret appears in captured output or logs. + +### Task 9: Add OAuth resource leases and exact-audience auth + +**Files:** `labby-auth` lease registry, daemon actions, LiveGateway methods, proxy OAuth policy, metadata routes. + +**Tests first:** configured resources survive lease updates; leases survive configured-route refresh; expiry; renewal; release; daemon restart and re-registration; exact port and path audience; wrong issuer, audience, or scope; Protected Resource Metadata challenge. + +**Acceptance:** an MCP OAuth client can discover the stable issuer, obtain a token for the random-port resource, connect, and loses authorization when the lease expires. + +### Task 10: Implement Tailscale Serve ownership + +**Tests first:** fake CLI version and status parsing, random selection, collision retry, ready mapping, process exit, stale mapping cleanup, ownership drift refusal, unrelated mapping preservation, and proof that `reset` is never invoked. + +Then run the live versioned spike on dookie. + +**Acceptance:** the endpoint is reachable through tailnet HTTPS; Ctrl+C removes only its mapping; forced Labby termination leaves no mapping after the recovery path. + +### Task 11: Build supervisor and failure rollback + +**Tests first:** failure after each startup stage and each shutdown stage. Use a table-driven failpoint harness. + +Mandatory failpoints: + +- command resolution; +- child spawn; +- child discovery; +- listener bind; +- bearer resolution; +- OAuth lease create; +- OAuth validator create; +- Tailscale claim; +- Tailscale readiness; +- child runtime exit; +- Serve runtime exit; +- lease renewal failure; +- Ctrl+C with an active request; +- Ctrl+C with an active subscription. + +**Acceptance:** every failpoint leaves zero owned child processes, zero test Serve mappings, and zero active OAuth leases. + +### Task 12: Add setup and doctor integration + +Add `labby setup proxy` with interactive and noninteractive paths. + +Checks: + +- Tailscale installed, connected, DNS name present, HTTPS capability available; +- selected port or range valid; +- bearer secret present or generated; +- stable OAuth issuer and live daemon available; +- resource lease action supported; +- child runtime launchers present. + +**Acceptance:** setup is idempotent; a second run makes no change; generated secret permissions are restrictive. + +### Task 13: Documentation and generated CLI inventory + +Update: + +- CLI help inventory; +- runtime config and environment docs; +- OAuth docs; +- gateway and direct-proxy architecture docs; +- troubleshooting and examples; +- changelog and release notes. + +Explicitly document that random-port OAuth creates a distinct resource URL per run and that a fixed port is preferable for long-lived connector configuration. + +**Acceptance:** `just docs-check` passes from a clean tree. + +### Task 14: Verification harness and proof pack + +Add: + +```console +cargo run -p xtask -- proxy-verify --binary target/debug/labby +``` + +Optional live gate: + +```console +cargo run -p xtask -- proxy-verify --binary target/debug/labby --live-tailscale --live-oauth +``` + +The harness produces: + +```text +target/proxy-verification// + manifest.json + commands.jsonl + unit-tests.json + integration-tests.json + conformance/ + fault-injection.json + tailscale-before.json + tailscale-during.json + tailscale-after.json + oauth-metadata.json + oauth-negative-tests.json + process-tree-before.json + process-tree-after.json + redaction-scan.json + summary.md +``` + +The manifest records: + +- git commit and tree-clean status; +- Rust, Cargo, RMCP, Tailscale, operating system, and conformance versions; +- fixture commits and hashes; +- every command, exit status, duration, and artifact hash; +- mandatory gate results; +- final verdict. + +The manifest must never include secrets. + +## Test matrix + +| Area | Mandatory proof | +| --- | --- | +| CLI | no-flag JS path, child flags, explicit separator, non-UTF-8 arguments | +| Resolver | executable, shebang, JS, Python, PATH command, unknown extension | +| Process | environment scrub, stderr, EOF, escalation, Unix group, Windows Job Object | +| Modern protocol | discover, every core primitive, tasks, MRTR, custom extensions | +| Metadata | version, client info, capabilities, trace keys, log level, unknown keys | +| Correlation | duplicate IDs, duplicate progress tokens, cancellation isolation | +| Subscriptions | acknowledgment, filters, IDs, multiple streams, cancellation, graceful result | +| Legacy | Auto fallback, interaction routing, serialization, resource adaptation | +| HTTP | Host, Origin, required headers, JSON, SSE, disconnect cancellation | +| Bearer | positive, negative, challenge, redaction, SSE | +| OAuth | metadata, issuer, exact audience, scopes, lease create, renew, expire, release | +| Tailscale fake | status parse, port collision, drift, cleanup, unrelated routes | +| Tailscale live | HTTPS reachability, certificate, Ctrl+C cleanup, crash recovery | +| Supervisor | every startup, runtime, and shutdown failpoint | +| Compatibility | Linux and Windows mandatory; macOS compile and test where available | +| Documentation | generated help and config/environment inventory current | +| Security | no LAN bind, no auth downgrade, no shell, no secret leak, no reset | + +## Conformance strategy + +1. Update the existing pinned RMCP and MCP conformance gate to RMCP 3.1.0. +2. Add a direct-proxy scenario that launches an all-capability stdio fixture behind `labby proxy --local --auth none`. +3. Run the dated server suite against the proxy HTTP endpoint. +4. Run extension task scenarios. +5. Run custom Labby scenarios for metadata preservation, unknown extensions, progress, cancellation, and subscriptions not covered upstream. +6. Keep expected failures empty for the direct proxy unless the upstream suite itself marks a scenario inapplicable. +7. Store raw conformance output in the proof pack. + +## Live end-to-end proof + +The controlled live test must: + +1. snapshot Tailscale Serve status; +2. start a deterministic stdio fixture through the zero-flag configured path; +3. capture the printed URL without exposing a token; +4. verify HTTPS and certificate validity from a second tailnet client when available; +5. run discovery and each advertised primitive; +6. open two subscriptions and two concurrent progress-producing calls; +7. verify bearer or OAuth negative cases; +8. in OAuth mode, obtain a token for the exact resource and reject a token for the same host with the wrong port; +9. terminate with Ctrl+C; +10. verify the child and descendants are gone; +11. verify the exact Serve mapping is gone; +12. verify all pre-existing mappings are byte-for-byte equivalent after normalization; +13. verify the resource lease is released or expired; +14. rerun the same test with forced termination and the recovery cleanup path. + +## Required release commands + +At minimum, from a clean checkout of the candidate commit: + +```console +cargo fmt --all -- --check +cargo clippy --workspace --all-features --locked -- -D warnings +cargo build --workspace --all-features --locked +cargo nextest run --workspace --all-features --locked --profile ci +cargo nextest run -p labby --no-default-features --features gateway --locked +cargo test -p labby-auth --all-features --locked +just docs-check +scripts/ci/mcp-conformance.sh +cargo run -p xtask -- proxy-verify --binary target/debug/labby +cargo run -p xtask -- proxy-verify --binary target/debug/labby --live-tailscale --live-oauth +``` + +Run the complete mandatory sequence twice from clean target directories. Hash both manifests. The second run must produce the same verdict and equivalent normalized protocol results. + +## Deliverables + +### D0: RMCP 3.1.0 migration + +- exact dependency pin and lockfile; +- updated conformance pins; +- migration fixes; +- green existing gates. + +### D1: Stable zero-flag CLI + +- `labby proxy /path/to/dist.js`; +- command resolver; +- child argument passthrough; +- human and JSON output. + +### D2: Persisted proxy defaults + +- `[proxy]` schema; +- config validation and precedence; +- setup and doctor flows; +- dedicated bearer secret management. + +### D3: Faithful direct MCP bridge + +- all core primitives; +- tasks and MRTR; +- custom extensions; +- per-request metadata; +- cancellation and progress; +- modern and legacy lifecycle compatibility. + +### D4: Subscription bridge + +- modern listen forwarding; +- legacy adaptation; +- ID translation; +- multiple concurrent streams; +- cancellation and cleanup. + +### D5: Auth policies + +- tailnet; +- static bearer; +- OAuth Protected Resource Metadata and exact audience validation; +- ephemeral daemon resource leases. + +### D6: Tailscale Serve ownership + +- random and fixed external ports; +- readiness verification; +- collision handling; +- exact cleanup without disturbing other mappings. + +### D7: Unified supervisor + +- complete startup rollback; +- runtime failure propagation; +- cross-platform process-tree cleanup; +- idempotent shutdown. + +### D8: Security and observability + +- Host and Origin enforcement; +- no shell execution; +- environment scrub; +- secret redaction; +- structured lifecycle events. + +### D9: Documentation + +- generated CLI reference; +- config, environment, and OAuth documentation; +- examples and troubleshooting; +- architecture and release notes. + +### D10: Proof pack + +- deterministic xtask verifier; +- updated MCP conformance gate; +- fault injection; +- live Tailscale and OAuth evidence; +- machine-readable manifest with hashes and verdict. + +## Suggested commit sequence + +1. `chore(deps): update rmcp to 3.1.0` +2. `feat(proxy): add proxy preferences and CLI grammar` +3. `refactor(gateway): extract reusable stdio process ownership` +4. `feat(proxy): add direct modern MCP bridge` +5. `feat(proxy): bridge subscriptions and legacy interactions` +6. `feat(proxy): serve direct bridge over loopback HTTP` +7. `feat(proxy): add bearer authentication` +8. `feat(auth): add ephemeral OAuth resource leases` +9. `feat(proxy): publish with Tailscale Serve` +10. `feat(proxy): supervise lifecycle and rollback` +11. `feat(setup): configure proxy defaults` +12. `test(proxy): add conformance and proof-pack verification` +13. `docs(proxy): document stdio proxy workflows` + +Each commit should be independently reviewable and should leave the relevant focused tests green. + +## Merge coordination + +- Do not write into the active RMCP or MCP capability worktrees. +- Land or rebase onto the completed RMCP 3.1.0 commit before Task 1. +- Rebase after the capability branch lands if it changes shared relay or handler types. +- Keep new direct-proxy modules separate so aggregate-gateway conflicts are mechanical rather than architectural. +- Run the full proof pack after the final rebase, not only before it. + +## Rollback plan + +The feature is additive. If release verification fails after merge: + +1. hide the command behind the proxy feature gate if one is introduced; +2. preserve the RMCP 3.1.0 upgrade if its independent gates remain green; +3. revert proxy CLI, config, runtime modules, and lease actions as one feature series; +4. remove generated docs entries in the same revert; +5. verify existing `labby serve`, stdio bridge, gateway, auth, and conformance behavior. + +No persistent migration is required for ordinary proxy settings. OAuth leases are ephemeral and expire automatically. + +## Definition of done + +The implementation is complete only when all of the following are true: + +- the exact zero-flag command works with configured defaults; +- the child catalog and wire payloads are not altered by aggregate gateway behavior; +- modern requests preserve full per-request metadata; +- all core primitives, tasks, MRTR, custom extensions, progress, cancellation, and subscriptions have positive and negative tests; +- bearer and OAuth protect the endpoint without leaking credentials; +- OAuth tokens are bound to the exact random-port resource; +- Tailscale mapping ownership and cleanup are proven without touching unrelated mappings; +- child descendants are reaped on Linux and Windows; +- every startup and shutdown failpoint leaves no owned residue; +- the pinned conformance suite passes; +- the proof pack passes twice from a clean checkout; +- docs and generated CLI inventories are current; +- the release commit and evidence manifests are recorded in the pull request. From a28ae9cda45d3c117d68d4ef8ae61aef75cb4909 Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Sat, 1 Aug 2026 02:29:14 -0400 Subject: [PATCH 2/8] feat(proxy): add stdio proxy CLI foundation --- crates/labby-runtime/src/gateway_config.rs | 2 + crates/labby/src/cli.rs | 53 ++++ crates/labby/src/cli/proxy.rs | 246 +++++++++++++++ crates/labby/src/config.rs | 8 + crates/labby/src/lib.rs | 2 + crates/labby/src/proxy.rs | 4 + crates/labby/src/proxy/command.rs | 324 +++++++++++++++++++ crates/labby/src/proxy/config.rs | 290 +++++++++++++++++ docs/contracts/stdio-mcp-proxy.md | 216 +++++++++++++ docs/specs/stdio-mcp-proxy.md | 348 +++++++++++++++++++++ 10 files changed, 1493 insertions(+) create mode 100644 crates/labby/src/cli/proxy.rs create mode 100644 crates/labby/src/proxy.rs create mode 100644 crates/labby/src/proxy/command.rs create mode 100644 crates/labby/src/proxy/config.rs create mode 100644 docs/contracts/stdio-mcp-proxy.md create mode 100644 docs/specs/stdio-mcp-proxy.md diff --git a/crates/labby-runtime/src/gateway_config.rs b/crates/labby-runtime/src/gateway_config.rs index c9a7dae6a..3580f3ffc 100644 --- a/crates/labby-runtime/src/gateway_config.rs +++ b/crates/labby-runtime/src/gateway_config.rs @@ -1026,6 +1026,8 @@ pub enum ConfigError { InvalidUpstreamRelayTimeout { value: u64 }, #[error("gateway mcp.catalog_notification_timeout_ms={value} is invalid — expected 1..=60000")] InvalidCatalogNotificationTimeout { value: u64 }, + #[error("invalid proxy configuration: {reason}")] + InvalidProxyConfig { reason: String }, #[error("protected MCP route '{name}' has invalid {field}: {value}")] InvalidProtectedRoute { name: String, diff --git a/crates/labby/src/cli.rs b/crates/labby/src/cli.rs index 4e7de1563..2442a26e0 100644 --- a/crates/labby/src/cli.rs +++ b/crates/labby/src/cli.rs @@ -18,6 +18,7 @@ pub mod internal; pub mod logs; pub mod oauth; pub mod params; +pub mod proxy; pub mod serve; pub mod setup; #[cfg(feature = "gateway")] @@ -91,6 +92,8 @@ pub enum Command { Snippets(snippets::SnippetsArgs), /// Run local OAuth callback relay helpers. Oauth(oauth::OauthArgs), + /// Proxy a stdio MCP server to Streamable HTTP. + Proxy(proxy::ProxyArgs), /// Hidden internal process helpers. #[cfg(feature = "gateway")] #[command(hide = true)] @@ -117,6 +120,7 @@ pub async fn dispatch(cli: Cli, config: LabConfig) -> Result { #[cfg(feature = "gateway")] Command::Snippets(args) => snippets::run(args, format, &config).await, Command::Oauth(args) => oauth::run(args, format, &config).await, + Command::Proxy(args) => proxy::run(args, &config, format).await, #[cfg(feature = "gateway")] Command::Internal(args) => internal::run(args), // [lab-scaffold: cli-dispatch] @@ -205,6 +209,55 @@ mod tests { } } + #[test] + fn cli_accepts_proxy_command_with_js_file() { + let cli = Cli::parse_from(["labby", "proxy", "/path/to/dist.js"]); + assert!(matches!(cli.command, Command::Proxy(_))); + } + + #[test] + fn cli_proxy_accepts_child_arguments() { + let cli = Cli::parse_from([ + "labby", + "proxy", + "/path/to/dist.js", + "--workspace", + "/srv/data", + ]); + assert!(matches!(cli.command, Command::Proxy(args) if args.command.len() == 3)); + } + + #[test] + fn cli_proxy_accepts_explicit_separator() { + let cli = Cli::parse_from([ + "labby", + "proxy", + "--", + "npx", + "-y", + "@modelcontextprotocol/server-filesystem", + ]); + assert!(matches!(cli.command, Command::Proxy(_))); + } + + #[test] + fn cli_proxy_accepts_port_override() { + let cli = Cli::parse_from(["labby", "proxy", "--port", "52177", "server"]); + assert!(matches!( + cli.command, + Command::Proxy(args) if args.port == Some(52177) + )); + } + + #[test] + fn cli_proxy_accepts_bearer_token() { + let cli = Cli::parse_from(["labby", "proxy", "--bearer-token", "secret", "server"]); + assert!(matches!( + cli.command, + Command::Proxy(args) if args.bearer_token == Some("secret".to_string()) + )); + } + #[test] fn replacement_setup_commands_parse() { let cli = Cli::try_parse_from(["labby", "setup"]).expect("setup parses"); diff --git a/crates/labby/src/cli/proxy.rs b/crates/labby/src/cli/proxy.rs new file mode 100644 index 000000000..2548da2c7 --- /dev/null +++ b/crates/labby/src/cli/proxy.rs @@ -0,0 +1,246 @@ +//! Stdio MCP proxy command. + +use std::ffi::OsString; +use std::path::PathBuf; + +use anyhow::Result; +use clap::Args; + +use crate::config::LabConfig; +use crate::output::OutputFormat; + +/// Exit code for the proxy command. +pub type ExitCode = std::process::ExitCode; + +/// Proxy a stdio MCP server to Streamable HTTP. +#[derive(Debug, Args)] +pub struct ProxyArgs { + /// Override the external port for this invocation. + #[arg(long)] + pub port: Option, + + /// Override the configured auth policy. + #[arg(long, value_enum)] + pub auth: Option, + + /// One-run static bearer token; implies bearer auth. + #[arg(long, env = "LABBY_PROXY_BEARER_TOKEN", hide_env_values = true)] + pub bearer_token: Option, + + /// Read a one-run static bearer token from stdin; implies bearer auth. + #[arg(long, conflicts_with = "bearer_token")] + pub bearer_token_stdin: bool, + + /// Override exposure to a local loopback URL. + #[arg(long)] + pub local: bool, + + /// Child working directory. + #[arg(long)] + pub cwd: Option, + + /// Explicit child environment entry; repeatable. + #[arg(long = "env", value_name = "NAME=VALUE")] + pub env: Vec, + + /// Inherit one ambient environment variable; repeatable. + #[arg(long = "inherit-env", value_name = "NAME")] + pub inherit_env: Vec, + + /// Child program or script followed by its arguments. + #[arg(required = true, trailing_var_arg = true)] + pub command: Vec, +} + +impl ProxyArgs { + /// Resolve proxy preferences from CLI overrides and persisted configuration. + pub fn resolve_preferences( + &self, + config: &LabConfig, + ) -> crate::proxy::config::ProxyPreferences { + let mut prefs = config.proxy.clone(); + + if self.local { + prefs.exposure = crate::proxy::config::ProxyExposure::Local; + } + if let Some(auth) = self.auth { + prefs.auth = auth; + } + if self.bearer_token_stdin || self.bearer_token.is_some() { + prefs.auth = crate::proxy::config::ProxyAuthMode::Bearer; + } + if let Some(port) = self.port { + prefs.port = crate::proxy::config::ProxyPortPreference::Fixed(port); + } + + prefs + } + + /// Read a bearer token from the CLI/env value or stdin. + pub async fn read_bearer_token(&self) -> Result> { + if self.bearer_token_stdin { + let mut token = String::new(); + let mut stdin = tokio::io::BufReader::new(tokio::io::stdin()); + tokio::io::AsyncBufReadExt::read_line(&mut stdin, &mut token).await?; + Ok(Some(token.trim().to_string())) + } else { + Ok(self.bearer_token.clone()) + } + } +} + +/// Prepare the proxy invocation. Runtime wiring lands in the next checkpoint. +pub async fn run(args: ProxyArgs, config: &LabConfig, _format: OutputFormat) -> Result { + let cwd = args.cwd.clone().unwrap_or(std::env::current_dir()?); + let command = crate::proxy::command::resolve_proxy_command( + &args.command, + &cwd, + std::env::var_os("PATH").as_deref(), + ) + .map_err(|error| anyhow::anyhow!("proxy command resolution failed: {error}"))?; + + let prefs = args.resolve_preferences(config); + prefs + .validate() + .map_err(|error| anyhow::anyhow!("proxy preferences validation failed: {error}"))?; + + let _bearer_token = if matches!(prefs.auth, crate::proxy::config::ProxyAuthMode::Bearer) { + Some(args.read_bearer_token().await?.ok_or_else(|| { + anyhow::anyhow!( + "bearer auth requires LABBY_PROXY_BEARER_TOKEN, --bearer-token, or --bearer-token-stdin" + ) + })?) + } else { + None + }; + + tracing::info!( + surface = "cli", + service = "proxy", + action = "proxy.prepare", + command = %command.display, + exposure = ?prefs.exposure, + auth = ?prefs.auth, + path = %prefs.path, + "prepared stdio MCP proxy invocation" + ); + + anyhow::bail!("stdio MCP proxy runtime is not implemented in this checkpoint") +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + + #[derive(Debug, Parser)] + struct TestCli { + #[command(flatten)] + proxy: ProxyArgs, + } + + fn parse(args: [&str; N]) -> ProxyArgs { + TestCli::parse_from(args).proxy + } + + #[test] + fn proxy_parses_js_file_with_child_args() { + let args = parse([ + "proxy", + "/path/to/dist.js", + "--workspace", + "/srv/data", + "--read-only", + ]); + assert_eq!(args.command.len(), 4); + assert_eq!(args.command[0], "/path/to/dist.js"); + assert_eq!(args.command[1], "--workspace"); + assert_eq!(args.command[2], "/srv/data"); + assert_eq!(args.command[3], "--read-only"); + } + + #[test] + fn proxy_requires_command() { + let error = TestCli::try_parse_from(["proxy"]).expect_err("proxy should require a command"); + assert!(error.to_string().contains("required")); + } + + #[test] + fn proxy_accepts_explicit_separator() { + let args = parse([ + "proxy", + "--", + "npx", + "-y", + "@modelcontextprotocol/server-filesystem", + "/srv/data", + ]); + assert_eq!(args.command[0], "npx"); + } + + #[test] + fn configured_auth_is_preserved_without_override() { + let args = parse(["proxy", "/path/to/dist.js"]); + let mut config = LabConfig::default(); + config.proxy.auth = crate::proxy::config::ProxyAuthMode::Oauth; + assert_eq!( + args.resolve_preferences(&config).auth, + crate::proxy::config::ProxyAuthMode::Oauth + ); + } + + #[test] + fn proxy_bearer_token_implies_bearer_auth() { + let args = parse(["proxy", "--bearer-token", "secret", "/path/to/dist.js"]); + assert_eq!( + args.resolve_preferences(&LabConfig::default()).auth, + crate::proxy::config::ProxyAuthMode::Bearer + ); + assert_eq!(args.bearer_token, Some("secret".to_string())); + } + + #[test] + fn proxy_auth_override_wins() { + let args = parse(["proxy", "--auth", "oauth", "/path/to/dist.js"]); + assert_eq!( + args.resolve_preferences(&LabConfig::default()).auth, + crate::proxy::config::ProxyAuthMode::Oauth + ); + } + + #[test] + fn proxy_local_implies_local_exposure() { + let args = parse(["proxy", "--local", "/path/to/dist.js"]); + assert_eq!( + args.resolve_preferences(&LabConfig::default()).exposure, + crate::proxy::config::ProxyExposure::Local + ); + } + + #[test] + fn proxy_env_flags_are_repeatable() { + let args = parse([ + "proxy", + "--env", + "FOO=bar", + "--env", + "BAZ=qux", + "/path/to/dist.js", + ]); + assert_eq!(args.env, vec!["FOO=bar", "BAZ=qux"]); + } + + #[test] + fn proxy_inherit_env_is_repeatable() { + let args = parse([ + "proxy", + "--inherit-env", + "PATH", + "--inherit-env", + "HOME", + "/path/to/dist.js", + ]); + assert_eq!(args.inherit_env, vec!["PATH", "HOME"]); + } +} diff --git a/crates/labby/src/config.rs b/crates/labby/src/config.rs index 45ee1b6b9..8c3f49f3e 100644 --- a/crates/labby/src/config.rs +++ b/crates/labby/src/config.rs @@ -269,6 +269,9 @@ pub struct LabConfig { /// MCP server defaults. #[serde(default)] pub mcp: McpPreferences, + /// Ephemeral stdio MCP proxy defaults. + #[serde(default)] + pub proxy: crate::proxy::config::ProxyPreferences, /// Logging preferences (overridden by `LABBY_LOG` / `LABBY_LOG_FORMAT` env vars). #[serde(default)] pub log: LogPreferences, @@ -467,6 +470,11 @@ impl From<&LabConfig> for GatewayConfig { impl LabConfig { pub fn validate(&self) -> Result<(), ConfigError> { self.code_mode.validate()?; + self.proxy + .validate() + .map_err(|error| ConfigError::InvalidProxyConfig { + reason: error.to_string(), + })?; if let Some(value) = self.upstream_request_timeout_ms && !(1..=300_000).contains(&value) { diff --git a/crates/labby/src/lib.rs b/crates/labby/src/lib.rs index f7ca7e54a..160a9296b 100644 --- a/crates/labby/src/lib.rs +++ b/crates/labby/src/lib.rs @@ -51,6 +51,8 @@ pub mod output; #[allow(unreachable_pub)] pub mod process; #[allow(unreachable_pub)] +pub mod proxy; +#[allow(unreachable_pub)] pub mod registry; #[cfg(test)] pub mod test_support; diff --git a/crates/labby/src/proxy.rs b/crates/labby/src/proxy.rs new file mode 100644 index 000000000..e634da2dc --- /dev/null +++ b/crates/labby/src/proxy.rs @@ -0,0 +1,4 @@ +//! Ephemeral stdio MCP proxy runtime. + +pub mod command; +pub mod config; diff --git a/crates/labby/src/proxy/command.rs b/crates/labby/src/proxy/command.rs new file mode 100644 index 000000000..58c5f5e28 --- /dev/null +++ b/crates/labby/src/proxy/command.rs @@ -0,0 +1,324 @@ +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyCommand { + pub program: OsString, + pub args: Vec, + pub cwd: PathBuf, + pub display: String, +} + +pub fn resolve_proxy_command( + raw: &[OsString], + cwd: &Path, + path_env: Option<&OsStr>, +) -> Result { + let Some(target) = raw.first() else { + return Err(ProxyCommandError::MissingCommand); + }; + let child_args = &raw[1..]; + let target_path = if Path::new(target).is_absolute() { + PathBuf::from(target) + } else { + cwd.join(target) + }; + + let (program, mut args) = if target_path.is_file() { + resolve_file_target(target, &target_path, path_env)? + } else if contains_path_separator(target) { + return Err(ProxyCommandError::NotFound { + target: target.to_string_lossy().into_owned(), + }); + } else { + let program = + resolve_on_path(target, path_env).ok_or_else(|| ProxyCommandError::NotFound { + target: target.to_string_lossy().into_owned(), + })?; + (program.into_os_string(), Vec::new()) + }; + args.extend(child_args.iter().cloned()); + let display = display_command(&program, &args); + Ok(ProxyCommand { + program, + args, + cwd: cwd.to_path_buf(), + display, + }) +} + +fn resolve_file_target( + original: &OsStr, + path: &Path, + path_env: Option<&OsStr>, +) -> Result<(OsString, Vec), ProxyCommandError> { + if is_executable(path) { + return Ok((path.as_os_str().to_os_string(), Vec::new())); + } + if let Some((interpreter, optional_arg)) = parse_shebang(path)? { + let program = resolve_on_path(OsStr::new(&interpreter), path_env).ok_or_else(|| { + ProxyCommandError::RuntimeNotFound { + target: original.to_string_lossy().into_owned(), + runtime: interpreter.clone(), + } + })?; + let mut args = Vec::new(); + if let Some(arg) = optional_arg { + args.push(arg.into()); + } + args.push(path.as_os_str().to_os_string()); + return Ok((program.into_os_string(), args)); + } + let extension = path.extension().and_then(OsStr::to_str).unwrap_or_default(); + let runtime = match extension { + "js" | "mjs" | "cjs" => Some("node"), + "py" => Some("python3"), + "ts" => { + return Err(ProxyCommandError::AmbiguousTypeScriptRuntime { + target: original.to_string_lossy().into_owned(), + }); + } + _ => None, + }; + let Some(runtime) = runtime else { + return Err(ProxyCommandError::UnsupportedFile { + target: original.to_string_lossy().into_owned(), + }); + }; + let program = resolve_on_path(OsStr::new(runtime), path_env).ok_or_else(|| { + ProxyCommandError::RuntimeNotFound { + target: original.to_string_lossy().into_owned(), + runtime: runtime.to_string(), + } + })?; + Ok(( + program.into_os_string(), + vec![path.as_os_str().to_os_string()], + )) +} + +fn parse_shebang(path: &Path) -> Result)>, ProxyCommandError> { + use std::io::Read as _; + + let mut file = std::fs::File::open(path).map_err(|error| ProxyCommandError::Inspect { + target: path.display().to_string(), + error: error.to_string(), + })?; + let mut bytes = [0_u8; 512]; + let count = file + .read(&mut bytes) + .map_err(|error| ProxyCommandError::Inspect { + target: path.display().to_string(), + error: error.to_string(), + })?; + let content = String::from_utf8_lossy(&bytes[..count]); + let Some(line) = content.lines().next() else { + return Ok(None); + }; + let Some(raw) = line.strip_prefix("#!") else { + return Ok(None); + }; + let mut parts = raw.split_whitespace(); + let Some(interpreter) = parts.next() else { + return Ok(None); + }; + if interpreter.ends_with("/env") { + let Some(command) = parts.next() else { + return Ok(None); + }; + let remaining = parts.collect::>(); + if remaining.len() > 1 { + return Err(ProxyCommandError::UnsupportedShebang { + target: path.display().to_string(), + }); + } + return Ok(Some(( + command.to_string(), + remaining.first().map(ToString::to_string), + ))); + } + let remaining = parts.collect::>(); + if remaining.len() > 1 { + return Err(ProxyCommandError::UnsupportedShebang { + target: path.display().to_string(), + }); + } + Ok(Some(( + interpreter.to_string(), + remaining.first().map(ToString::to_string), + ))) +} + +fn resolve_on_path(program: &OsStr, path_env: Option<&OsStr>) -> Option { + let path_env = path_env?; + std::env::split_paths(path_env) + .map(|dir| dir.join(program)) + .find(|candidate| candidate.is_file() && is_executable(candidate)) +} + +fn contains_path_separator(value: &OsStr) -> bool { + Path::new(value).components().count() > 1 +} + +#[cfg(unix)] +fn is_executable(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt as _; + + path.metadata() + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(windows)] +fn is_executable(path: &Path) -> bool { + path.is_file() +} + +#[cfg(not(any(unix, windows)))] +fn is_executable(path: &Path) -> bool { + path.is_file() +} + +fn display_command(program: &OsStr, args: &[OsString]) -> String { + std::iter::once(program) + .chain(args.iter().map(OsString::as_os_str)) + .map(shell_escape_for_display) + .collect::>() + .join(" ") +} + +fn shell_escape_for_display(value: &OsStr) -> String { + let value = value.to_string_lossy(); + if value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '\\' | '.' | '_' | '-' | ':')) + { + value.into_owned() + } else { + format!("{:?}", value.as_ref()) + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ProxyCommandError { + #[error("proxy command is required")] + MissingCommand, + #[error("proxy command or file `{target}` was not found")] + NotFound { target: String }, + #[error("cannot inspect proxy target `{target}`: {error}")] + Inspect { target: String, error: String }, + #[error("runtime `{runtime}` required by proxy target `{target}` was not found on PATH")] + RuntimeNotFound { target: String, runtime: String }, + #[error("cannot infer how to launch proxy target `{target}`")] + UnsupportedFile { target: String }, + #[error( + "TypeScript target `{target}` requires an explicit runtime such as bun, deno, or npx tsx" + )] + AmbiguousTypeScriptRuntime { target: String }, + #[error("proxy target `{target}` has an unsupported multi-argument shebang")] + UnsupportedShebang { target: String }, +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn make_executable(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); + } + } + + #[test] + fn resolves_javascript_file_through_node() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + fs::create_dir(&bin).unwrap(); + let node = bin.join("node"); + fs::write(&node, "").unwrap(); + make_executable(&node); + let script = dir.path().join("server.js"); + fs::write(&script, "console.log('server')").unwrap(); + let path = std::env::join_paths([&bin]).unwrap(); + + let command = resolve_proxy_command( + &[script.as_os_str().to_os_string(), "--child-flag".into()], + dir.path(), + Some(&path), + ) + .unwrap(); + + assert_eq!(command.program, node); + assert_eq!( + command.args, + vec![script.into_os_string(), "--child-flag".into()] + ); + } + + #[test] + fn executes_executable_target_directly() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("server"); + fs::write( + &target, + "#!/bin/sh +", + ) + .unwrap(); + make_executable(&target); + let command = resolve_proxy_command( + &[target.as_os_str().to_os_string()], + dir.path(), + Some(OsStr::new("")), + ) + .unwrap(); + assert_eq!(command.program, target); + assert!(command.args.is_empty()); + } + + #[test] + fn rejects_ambiguous_typescript_target() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("server.ts"); + fs::write(&target, "export {};").unwrap(); + let error = resolve_proxy_command( + &[target.as_os_str().to_os_string()], + dir.path(), + Some(OsStr::new("")), + ) + .unwrap_err(); + assert!(matches!( + error, + ProxyCommandError::AmbiguousTypeScriptRuntime { .. } + )); + } + + #[test] + fn resolves_env_shebang() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join("bin"); + fs::create_dir(&bin).unwrap(); + let python = bin.join("python3"); + fs::write(&python, "").unwrap(); + make_executable(&python); + let target = dir.path().join("server"); + fs::write( + &target, + "#!/usr/bin/env python3 +", + ) + .unwrap(); + let path = std::env::join_paths([&bin]).unwrap(); + let command = resolve_proxy_command( + &[target.as_os_str().to_os_string()], + dir.path(), + Some(&path), + ) + .unwrap(); + assert_eq!(command.program, python); + assert_eq!(command.args, vec![target.into_os_string()]); + } +} diff --git a/crates/labby/src/proxy/config.rs b/crates/labby/src/proxy/config.rs new file mode 100644 index 000000000..1521daf42 --- /dev/null +++ b/crates/labby/src/proxy/config.rs @@ -0,0 +1,290 @@ +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_PROXY_PORT_RANGE_START: u16 = 49_152; +pub const DEFAULT_PROXY_PORT_RANGE_END: u16 = 65_535; +pub const DEFAULT_PROXY_SHUTDOWN_GRACE_MS: u64 = 3_000; +pub const DEFAULT_PROXY_BEARER_TOKEN_ENV: &str = "LABBY_PROXY_BEARER_TOKEN"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ProxyExposure { + #[default] + Tailscale, + Local, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum, Default)] +#[serde(rename_all = "snake_case")] +pub enum ProxyAuthMode { + #[default] + Tailnet, + Bearer, + Oauth, + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum ProxyPortMode { + #[default] + Random, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ProxyPortPreference { + Fixed(u16), + Mode(ProxyPortMode), +} + +impl Default for ProxyPortPreference { + fn default() -> Self { + Self::Mode(ProxyPortMode::Random) + } +} + +impl ProxyPortPreference { + #[must_use] + pub const fn fixed(self) -> Option { + match self { + Self::Fixed(port) => Some(port), + Self::Mode(ProxyPortMode::Random) => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProxyPreferences { + #[serde(default)] + pub exposure: ProxyExposure, + #[serde(default)] + pub auth: ProxyAuthMode, + #[serde(default = "default_proxy_path")] + pub path: String, + #[serde(default)] + pub port: ProxyPortPreference, + #[serde(default = "default_proxy_port_range_start")] + pub port_range_start: u16, + #[serde(default = "default_proxy_port_range_end")] + pub port_range_end: u16, + #[serde(default = "default_proxy_bearer_token_env")] + pub bearer_token_env: String, + #[serde(default = "default_proxy_oauth_scopes")] + pub oauth_scopes: Vec, + #[serde(default)] + pub inherit_env: Vec, + #[serde(default = "default_proxy_shutdown_grace_ms")] + pub shutdown_grace_ms: u64, +} + +impl Default for ProxyPreferences { + fn default() -> Self { + Self { + exposure: ProxyExposure::Tailscale, + auth: ProxyAuthMode::Tailnet, + path: default_proxy_path(), + port: ProxyPortPreference::default(), + port_range_start: DEFAULT_PROXY_PORT_RANGE_START, + port_range_end: DEFAULT_PROXY_PORT_RANGE_END, + bearer_token_env: default_proxy_bearer_token_env(), + oauth_scopes: default_proxy_oauth_scopes(), + inherit_env: Vec::new(), + shutdown_grace_ms: DEFAULT_PROXY_SHUTDOWN_GRACE_MS, + } + } +} + +impl ProxyPreferences { + pub fn validate(&self) -> Result<(), ProxyConfigError> { + validate_proxy_path(&self.path)?; + if self.port_range_start > self.port_range_end { + return Err(ProxyConfigError::InvalidPortRange { + start: self.port_range_start, + end: self.port_range_end, + }); + } + if self.port_range_start < 1_024 { + return Err(ProxyConfigError::PrivilegedPortRange { + start: self.port_range_start, + }); + } + if matches!(self.port, ProxyPortPreference::Fixed(0)) { + return Err(ProxyConfigError::InvalidFixedPort); + } + if matches!(self.exposure, ProxyExposure::Local) + && matches!(self.auth, ProxyAuthMode::Tailnet) + { + return Err(ProxyConfigError::TailnetAuthRequiresTailscale); + } + if self.bearer_token_env.trim().is_empty() { + return Err(ProxyConfigError::EmptyBearerTokenEnv); + } + if !is_env_name(&self.bearer_token_env) { + return Err(ProxyConfigError::InvalidEnvName { + name: self.bearer_token_env.clone(), + }); + } + for name in &self.inherit_env { + if !is_env_name(name) { + return Err(ProxyConfigError::InvalidEnvName { name: name.clone() }); + } + } + if matches!(self.auth, ProxyAuthMode::Oauth) && self.oauth_scopes.is_empty() { + return Err(ProxyConfigError::MissingOauthScopes); + } + if self + .oauth_scopes + .iter() + .any(|scope| scope.trim().is_empty() || scope.chars().any(char::is_whitespace)) + { + return Err(ProxyConfigError::InvalidOauthScope); + } + if !(1..=60_000).contains(&self.shutdown_grace_ms) { + return Err(ProxyConfigError::InvalidShutdownGrace { + value: self.shutdown_grace_ms, + }); + } + Ok(()) + } +} + +fn validate_proxy_path(path: &str) -> Result<(), ProxyConfigError> { + let path = path.trim(); + if path.is_empty() + || path == "/" + || !path.starts_with('/') + || path.contains('?') + || path.contains('#') + || path.split('/').any(|segment| matches!(segment, "." | "..")) + { + return Err(ProxyConfigError::InvalidPath { + path: path.to_string(), + }); + } + Ok(()) +} + +fn is_env_name(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) +} + +fn default_proxy_path() -> String { + "/mcp".to_string() +} + +const fn default_proxy_port_range_start() -> u16 { + DEFAULT_PROXY_PORT_RANGE_START +} + +const fn default_proxy_port_range_end() -> u16 { + DEFAULT_PROXY_PORT_RANGE_END +} + +fn default_proxy_bearer_token_env() -> String { + DEFAULT_PROXY_BEARER_TOKEN_ENV.to_string() +} + +fn default_proxy_oauth_scopes() -> Vec { + vec!["mcp:read".to_string(), "mcp:write".to_string()] +} + +const fn default_proxy_shutdown_grace_ms() -> u64 { + DEFAULT_PROXY_SHUTDOWN_GRACE_MS +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ProxyConfigError { + #[error( + "proxy path `{path}` must be an absolute non-root path without query, fragment, or dot segments" + )] + InvalidPath { path: String }, + #[error("proxy port range start {start} exceeds end {end}")] + InvalidPortRange { start: u16, end: u16 }, + #[error("proxy random port range must start at 1024 or higher, got {start}")] + PrivilegedPortRange { start: u16 }, + #[error("proxy fixed port must not be zero")] + InvalidFixedPort, + #[error("proxy auth `tailnet` requires Tailscale exposure")] + TailnetAuthRequiresTailscale, + #[error("proxy bearer token environment key must not be empty")] + EmptyBearerTokenEnv, + #[error("invalid environment variable name `{name}`")] + InvalidEnvName { name: String }, + #[error("proxy OAuth mode requires at least one scope")] + MissingOauthScopes, + #[error("proxy OAuth scopes must be non-empty single tokens")] + InvalidOauthScope, + #[error("proxy shutdown_grace_ms={value} is invalid; expected 1..=60000")] + InvalidShutdownGrace { value: u64 }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_preferences_are_zero_flag_safe() { + let cfg = ProxyPreferences::default(); + assert_eq!(cfg.exposure, ProxyExposure::Tailscale); + assert_eq!(cfg.auth, ProxyAuthMode::Tailnet); + assert_eq!(cfg.path, "/mcp"); + assert_eq!(cfg.port.fixed(), None); + assert_eq!(cfg.port_range_start, 49_152); + assert_eq!(cfg.port_range_end, 65_535); + assert_eq!(cfg.bearer_token_env, "LABBY_PROXY_BEARER_TOKEN"); + cfg.validate().expect("default proxy preferences validate"); + } + + #[test] + fn toml_accepts_random_and_fixed_ports() { + let random: ProxyPreferences = toml::from_str(r#"port = "random""#).unwrap(); + assert_eq!(random.port.fixed(), None); + let fixed: ProxyPreferences = toml::from_str("port = 52177").unwrap(); + assert_eq!(fixed.port.fixed(), Some(52_177)); + } + + #[test] + fn local_tailnet_combination_is_rejected() { + let cfg = ProxyPreferences { + exposure: ProxyExposure::Local, + ..ProxyPreferences::default() + }; + assert_eq!( + cfg.validate(), + Err(ProxyConfigError::TailnetAuthRequiresTailscale) + ); + } + + #[test] + fn invalid_paths_are_rejected() { + for path in ["", "/", "mcp", "/mcp?x=1", "/mcp#x", "/a/../b"] { + let cfg = ProxyPreferences { + path: path.to_string(), + ..ProxyPreferences::default() + }; + assert!(matches!( + cfg.validate(), + Err(ProxyConfigError::InvalidPath { .. }) + )); + } + } + + #[test] + fn invalid_environment_names_are_rejected() { + let cfg = ProxyPreferences { + inherit_env: vec!["GOOD_NAME".into(), "bad-name".into()], + ..ProxyPreferences::default() + }; + assert_eq!( + cfg.validate(), + Err(ProxyConfigError::InvalidEnvName { + name: "bad-name".into() + }) + ); + } +} diff --git a/docs/contracts/stdio-mcp-proxy.md b/docs/contracts/stdio-mcp-proxy.md new file mode 100644 index 000000000..9ec820a7e --- /dev/null +++ b/docs/contracts/stdio-mcp-proxy.md @@ -0,0 +1,216 @@ +--- +title: "Contract: Stdio MCP Proxy" +created: "2026-07-31" +updated: "2026-07-31" +--- + +# Contract: Stdio MCP Proxy + +Status: implementation +Surfaces: CLI, Streamable HTTP, internal HTTP API +Related: [spec](../specs/stdio-mcp-proxy.md), `docs/dev/ERRORS.md`, `docs/design/SERIALIZATION.md` + +This contract pins the stable CLI grammar, configuration vocabulary, output shape, HTTP discovery behavior, auth challenges, and internal OAuth lease API for `labby proxy`. + +## CLI grammar + +```text +labby proxy [LABBY_OPTIONS] [CHILD_ARGUMENTS...] +``` + +Stable options: + +| Option | Meaning | +|---|---| +| `--port ` | Override the external port for this invocation. | +| `--auth ` | Override the auth policy. | +| `--bearer-token ` | One-run static token; implies bearer. | +| `--bearer-token-stdin` | Read one-run static token from stdin; implies bearer. | +| `--local` | Override exposure to a local loopback URL. | +| `--cwd ` | Child working directory. | +| `--env ` | Explicit child environment entry; repeatable. | +| `--inherit-env ` | Inherit one ambient environment variable; repeatable. | + +The global `--json` flag applies normally. + +After the first program or script token, all remaining tokens are child arguments. An explicit `--` before the program is accepted. Unknown Labby-looking flags after the program are not rejected by Labby. + +## Exit codes + +- `0`: clean shutdown after a successful startup. +- `1`: runtime, auth, child, HTTP, or exposure failure. +- `2`: Clap usage or validation error. + +## Configuration + +Stable TOML keys: + +```toml +[proxy] +exposure = "tailscale" # tailscale | local +auth = "tailnet" # tailnet | bearer | oauth | none +path = "/mcp" +port = "random" # random or integer +port_range_start = 49152 +port_range_end = 65535 +bearer_token_env = "LABBY_PROXY_BEARER_TOKEN" +oauth_scopes = ["mcp:read", "mcp:write"] +inherit_env = [] +shutdown_grace_ms = 3000 +``` + +Unknown future keys are rejected by TOML deserialization only when the surrounding Labby config policy requires it; callers must not depend on unknown-key acceptance. + +## Human startup output + +The human output includes these labels when startup succeeds: + +```text +MCP proxy ready + + Server + URL + Exposure + Auth + +Press Ctrl+C to stop. +``` + +Whitespace, color, and symbols are not contractual. Labels and values are. + +## JSON startup output + +`--json` writes one object after readiness: + +```jsonc +{ + "url": "https://node.example.ts.net:53147/mcp", + "exposure": "tailscale", + "auth": "oauth", + "external_port": 53147, + "local_addr": "127.0.0.1:38417", + "command": ["node", "/path/to/dist.js"], + "child_pid": 12345, + "protocol_version": "2026-07-28" +} +``` + +The object never includes bearer tokens, authorization headers, JWTs, OAuth codes, or child environment values. + +## Public MCP endpoint + +The configured path accepts MCP Streamable HTTP traffic. The public URL includes the selected external port when it is not 443. + +The endpoint preserves child MCP results and errors. Labby-generated failures use MCP JSON-RPC error data consistent with the existing bridge error mapping. + +## OAuth metadata + +For OAuth mode, the proxy serves: + +```text +GET /.well-known/oauth-protected-resource +GET /.well-known/oauth-protected-resource +``` + +The response shape is: + +```jsonc +{ + "resource": "https://node.example.ts.net:53147/mcp", + "authorization_servers": ["https://labby.example.com"], + "scopes_supported": ["mcp:read", "mcp:write"], + "bearer_methods_supported": ["header"] +} +``` + +An unauthenticated request returns HTTP 401 with: + +```text +WWW-Authenticate: Bearer resource_metadata="https://node.example.ts.net:53147/.well-known/oauth-protected-resource/mcp", scope="mcp:read mcp:write" +``` + +A token with insufficient scope returns HTTP 403 and an `insufficient_scope` challenge. Issuer and audience mismatches return 401. + +## Bearer mode + +Missing or invalid static bearer credentials return HTTP 401. Static token comparison is constant-time. The bearer challenge may advertise the MCP resource URL but does not expose the configured token source. + +## Internal resource lease API + +These routes are under existing authenticated `/v1/*` middleware and require `lab:admin`: + +### Create + +`POST /v1/internal/proxy-resource-leases` + +Request: + +```json +{ + "resource": "https://node.example.ts.net:53147/mcp", + "scopes": ["mcp:read", "mcp:write"], + "ttl_secs": 120 +} +``` + +Response: HTTP 201 with a `ResourceLease` document. + +### Renew + +`PUT /v1/internal/proxy-resource-leases/` + +Request: + +```json +{ "ttl_secs": 120 } +``` + +Response: HTTP 200 with the renewed lease. Unknown or expired lease returns 404. + +### Release + +`DELETE /v1/internal/proxy-resource-leases/` + +Response: HTTP 204. Releasing an unknown lease is idempotent and also returns 204. + +### Lease document + +```json +{ + "id": "uuid", + "resource": "https://node.example.ts.net:53147/mcp", + "scopes": ["mcp:read", "mcp:write"], + "expires_at_unix": 1785555600 +} +``` + +Resource URLs must be absolute HTTPS URLs without credentials, query, or fragment. Loopback HTTP resources are allowed only for explicitly local development mode and are never accepted by the public daemon route. + +## Tailscale ownership + +Labby records the external port and exact loopback target it requested. Cleanup may issue an `off` command only if the current mapping still matches that ownership record. It must not call `tailscale serve reset`. + +## Error kinds + +Stable proxy-specific error kinds used in JSON or API envelopes: + +| Kind | Meaning | +|---|---| +| `proxy_invalid_config` | Proxy preference validation failed. | +| `proxy_command_not_found` | Program or inferred runtime could not be resolved. | +| `proxy_child_start_failed` | Child spawn or MCP lifecycle failed. | +| `proxy_auth_unavailable` | Selected auth policy cannot be established. | +| `proxy_oauth_lease_failed` | Lease create or renewal failed. | +| `proxy_tailscale_unavailable` | Tailscale is missing, disconnected, or unsupported. | +| `proxy_port_unavailable` | Fixed port is occupied or random attempts were exhausted. | +| `proxy_mapping_conflict` | Mapping changed and ownership-safe cleanup refused. | +| `proxy_runtime_failed` | A supervised component exited unexpectedly. | + +Messages may improve; kinds are stable. + +## Compatibility + +- The public endpoint supports the modern stateless revision targeted by the pinned RMCP SDK. +- Legacy stdio children are adapted internally. +- The CLI may gain new options without breaking this contract. +- Removing or renaming the listed options, JSON fields, config keys, API routes, auth modes, or error kinds is breaking. diff --git a/docs/specs/stdio-mcp-proxy.md b/docs/specs/stdio-mcp-proxy.md new file mode 100644 index 000000000..54ccb3246 --- /dev/null +++ b/docs/specs/stdio-mcp-proxy.md @@ -0,0 +1,348 @@ +--- +title: "Spec: Stdio MCP Proxy" +created: "2026-07-31" +updated: "2026-07-31" +--- + +# Spec: Stdio MCP Proxy + +Status: implementation +Owner: Labby runtime +Surfaces: CLI, Streamable HTTP, OAuth, Tailscale Serve +Related: [contract](../contracts/stdio-mcp-proxy.md), [research](../reports/2026-07-31-stdio-mcp-proxy-research.md), [implementation plan](../superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md) + +## Problem + +Many MCP servers are distributed only as stdio programs. Publishing one for a remote tailnet client currently requires the operator to understand the child runtime, MCP transport translation, HTTP binding, authentication, Tailscale Serve, port selection, and cleanup. + +Labby should make that operation one command: + +```console +labby proxy /path/to/dist.js +``` + +The command launches the child, presents its own MCP surface faithfully over Streamable HTTP, applies configured authentication, publishes it through Tailscale Serve, and owns every resource until shutdown. + +## Goals + +- Zero required flags after defaults are configured. +- Reuse Labby's existing stdio process ownership, MCP bridge, HTTP, auth, config, output, and test infrastructure. +- Preserve the child server's names, capabilities, schemas, metadata, results, errors, tasks, MRTR, custom methods, notifications, and subscriptions. +- Bind only to loopback and publish only through the selected exposure controller. +- Support tailnet-only, static bearer, OAuth, and explicit no-auth policies. +- Select a random unused high external port by default. +- Cleanly remove only the mapping and processes created by this invocation. +- Provide deterministic unit, integration, conformance, fault-injection, and live verification. + +## Non-goals + +- Aggregate multiple MCP servers. +- Add Labby built-ins or Code Mode to the proxied catalog. +- Rename or filter the child's primitives. +- Persist running proxy definitions. +- Run detached proxies or provide list/stop commands in v1. +- Expose through Tailscale Funnel. +- Modify tailnet ACLs or grants. +- Deploy the child executable to another machine. + +## User experience + +### Normal invocation + +```console +labby proxy /path/to/dist.js +``` + +### Explicit command + +```console +labby proxy npx -y @modelcontextprotocol/server-filesystem /srv/data +``` + +### One-run overrides + +```console +labby proxy --port 52177 /path/to/dist.js +labby proxy --auth bearer --bearer-token-stdin /path/to/dist.js +labby proxy --auth oauth /path/to/dist.js +labby proxy --local --auth none /path/to/dist.js +``` + +Labby options must appear before the first child token. All remaining tokens belong to the child. An explicit `--` remains accepted but is not required. + +## Configuration model + +```toml +[proxy] +exposure = "tailscale" +auth = "tailnet" +path = "/mcp" +port = "random" +port_range_start = 49152 +port_range_end = 65535 +bearer_token_env = "LABBY_PROXY_BEARER_TOKEN" +oauth_scopes = ["mcp:read", "mcp:write"] +inherit_env = [] +shutdown_grace_ms = 3000 +``` + +A numeric `port` selects a fixed external port. Secret values never belong in TOML. + +### Precedence + +1. CLI override. +2. Process environment and `~/.labby/.env`. +3. `[proxy]` TOML. +4. Built-in defaults. + +### Defaults + +- Exposure: Tailscale. +- Authentication: tailnet. +- Path: `/mcp`. +- External port: random from 49152 through 65535. +- Internal listener: `127.0.0.1:0`. +- Foreground lifetime. + +## Domain types + +```rust +pub struct ProxyPreferences { + pub exposure: ProxyExposure, + pub auth: ProxyAuthMode, + pub path: String, + pub port: ProxyPortPreference, + pub port_range_start: u16, + pub port_range_end: u16, + pub bearer_token_env: String, + pub oauth_scopes: Vec, + pub inherit_env: Vec, + pub shutdown_grace_ms: u64, +} + +pub enum ProxyExposure { + Tailscale, + Local, +} + +pub enum ProxyAuthMode { + Tailnet, + Bearer, + Oauth, + None, +} + +pub enum ProxyPortPreference { + Random, + Fixed(u16), +} + +pub struct ProxyCommand { + pub program: std::ffi::OsString, + pub args: Vec, + pub cwd: std::path::PathBuf, + pub display: String, +} + +pub struct ProxyEndpoint { + pub local_addr: std::net::SocketAddr, + pub public_url: url::Url, + pub external_port: u16, +} + +pub struct ProxyRuntimeInfo { + pub endpoint: ProxyEndpoint, + pub child: ProxyChildInfo, + pub exposure: ProxyExposure, + pub auth: ProxyAuthMode, + pub protocol_version: rmcp::model::ProtocolVersion, +} +``` + +### OAuth lease types + +```rust +pub struct ResourceLease { + pub id: uuid::Uuid, + pub resource: String, + pub scopes: Vec, + pub expires_at_unix: i64, +} + +pub struct ResourceLeaseRequest { + pub resource: String, + pub scopes: Vec, + pub ttl_secs: u64, +} +``` + +Configured protected resources and ephemeral leases are separate collections. Replacing configured resources must never remove active leases. + +## Command resolution + +1. Existing executable file: execute directly. +2. Existing file with a valid shebang: execute through the shebang interpreter when direct execution is unavailable. +3. `.js`, `.mjs`, or `.cjs`: resolve `node` through PATH. +4. `.py`: resolve `python3` through PATH. +5. Bare token: resolve through PATH. +6. Unknown non-executable file: fail with suggested explicit commands. + +The command is always passed as an argv vector to `tokio::process::Command`. It is never interpolated through a shell. + +## Architecture + +```text +remote MCP client + -> HTTPS over tailnet + -> Tailscale Serve foreground process + -> 127.0.0.1 ephemeral HTTP listener + -> proxy auth middleware + -> RMCP StreamableHttpService + -> generalized transparent BridgeServerHandler + -> reusable direct stdio connector + -> child MCP process +``` + +## Reuse boundaries + +### Generalize existing MCP bridge + +`crates/labby/src/mcp/bridge.rs` already forwards tools, prompts, resources, resource templates, completion, tasks, custom requests, custom notifications, subscriptions, and cancellation. It becomes the shared transparent bridge used by both: + +- `labby mcp` when bridging to a live daemon; +- `labby proxy` when bridging to a stdio child. + +The generalized bridge adds per-request metadata forwarding, progress correlation, request-ID cancellation mapping, and legacy interaction serialization. + +### Expose existing stdio connector + +`crates/labby-gateway/src/upstream/pool/connect_stdio.rs` remains the source of truth for: + +- environment clearing and runtime allowlist; +- explicit environment injection; +- stderr draining; +- package-runner spawn locking and repair; +- modern discovery and legacy initialization fallback; +- Unix process groups; +- Windows Job Objects; +- descendant cleanup. + +A narrow public direct-connection wrapper exposes a peer and owned shutdown without exposing pool internals. + +### Reuse HTTP and auth + +Extract loopback listener and RMCP service construction patterns from `cli/serve.rs`. Reuse `labby-auth::AuthLayer`, extending it with an explicit expected-audience override for ephemeral OAuth resources. + +## MCP fidelity + +### Lifecycle + +The child connection uses RMCP `ClientLifecycleMode::Auto`, preferring `2026-07-28` and falling back to legacy initialize only on method-not-found. + +The public endpoint advertises the modern stateless lifecycle. A legacy child is adapted behind that endpoint. + +### Requests + +For each downstream request the bridge preserves: + +- method and typed or custom params; +- protocol version; +- client implementation; +- client capabilities and extensions; +- trace context and unknown metadata; +- MRTR input responses; +- task fields; +- result and error variants. + +Proxy-owned request IDs and progress tokens are translated and never exposed upstream or downstream incorrectly. + +### Progress and cancellation + +The bridge records: + +- downstream request ID to upstream request ID; +- upstream progress token to downstream progress token and peer. + +HTTP SSE disconnect or explicit downstream cancellation sends an upstream `notifications/cancelled` with the translated request ID. Upstream progress is emitted only on the originating downstream request stream and uses the downstream token. + +### Subscriptions + +A downstream `subscriptions/listen` opens one upstream listen request. The bridge forwards the acknowledgment first, preserves the accepted filter, and rewrites subscription identity through RMCP's downstream subscription sink. Cancellation closes the upstream subscription. + +Multiple subscriptions remain isolated. + +### Legacy child interactions + +When a legacy child issues sampling, elicitation, or roots requests, the bridge forwards them to the downstream peer associated with the currently serialized request. Requests that could create ambiguous association are serialized for legacy children only. + +### Custom extensions + +RMCP `CustomRequest`, `CustomNotification`, and `CustomResult` are forwarded without interpreting method names or payloads. + +## Authentication + +### Tailnet + +No application bearer challenge is added. Reachability is controlled by Tailscale policy. The local listener remains loopback-only. + +### Bearer + +- Secret source: CLI stdin/literal override or the configured environment key. +- Constant-time comparison through `AuthLayer`. +- No token in logs, JSON output, errors, process titles generated by Labby, or evidence artifacts. +- Every MCP POST and SSE stream is protected. + +### OAuth + +- Stable issuer: configured Labby public URL. +- Protected resource and JWT audience: exact proxy URL including external port and MCP path. +- The live daemon creates a short-lived resource lease before publication. +- The proxy renews the lease while alive and releases it during normal shutdown. +- Expired leases are ignored and pruned. +- The proxy serves RFC 9728 Protected Resource Metadata and a matching `WWW-Authenticate` challenge. +- Failure to create or renew a lease terminates OAuth startup or the running proxy; there is no downgrade. + +## Tailscale exposure + +The real controller executes: + +```console +tailscale serve --yes --https= http://127.0.0.1: +``` + +It verifies the exact mapping in `tailscale serve status --json`, watches the child, and cleans up only its owned port. `tailscale serve reset` is forbidden. + +## Supervision + +Startup order: + +1. Validate configuration and command. +2. Resolve auth prerequisites. +3. Spawn and discover the child. +4. Bind loopback HTTP. +5. Choose the external URL and create the OAuth lease when required. +6. Start the HTTP listener. +7. Publish with Tailscale Serve. +8. Verify unauthenticated and authenticated readiness. +9. Print the endpoint. + +Shutdown is idempotent and runs on Ctrl+C, SIGTERM, child exit, HTTP failure, Tailscale exit, or OAuth renewal failure. + +## Observability + +Required fields include surface, service, action, phase, exposure, auth mode, external port, local port, child PID, and lifecycle mode. Tokens, authorization headers, JWTs, child secret environment values, and OAuth codes are forbidden. + +## Verification + +The feature is accepted only after: + +- parser and resolver unit tests; +- config and auth tests; +- bridge metadata, progress, cancellation, MRTR, task, custom-extension, and subscription tests; +- process-tree tests on Linux and Windows; +- fake Tailscale collision and cleanup tests; +- live ignored Tailscale test; +- OAuth lease and exact-audience tests; +- MCP conformance through the proxy; +- all-features build, nextest, Clippy, formatting, docs, and deny gates; +- remote CI success on the PR. From b986120370aa4c600a3ce2d1ce7774b8ab3c644b Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Sat, 1 Aug 2026 03:50:44 -0400 Subject: [PATCH 3/8] feat(proxy): run stdio servers over local HTTP --- crates/labby-gateway/src/upstream.rs | 1 + .../src/upstream/direct_stdio.rs | 92 +++++ crates/labby-gateway/src/upstream/pool.rs | 1 + .../src/upstream/pool/connect_stdio.rs | 162 +++++---- .../src/upstream/pool/connection.rs | 2 +- crates/labby/Cargo.toml | 7 + crates/labby/src/cli/proxy.rs | 140 +++++++- crates/labby/src/mcp/bridge.rs | 21 +- crates/labby/src/proxy.rs | 6 + crates/labby/src/proxy/runtime.rs | 235 +++++++++++++ crates/labby/src/proxy/runtime_tests.rs | 99 ++++++ .../labby/tests/fixtures/stdio_mcp_fixture.rs | 83 +++++ crates/labby/tests/stdio_proxy_runtime.rs | 318 ++++++++++++++++++ 13 files changed, 1087 insertions(+), 80 deletions(-) create mode 100644 crates/labby-gateway/src/upstream/direct_stdio.rs create mode 100644 crates/labby/src/proxy/runtime.rs create mode 100644 crates/labby/src/proxy/runtime_tests.rs create mode 100644 crates/labby/tests/fixtures/stdio_mcp_fixture.rs create mode 100644 crates/labby/tests/stdio_proxy_runtime.rs diff --git a/crates/labby-gateway/src/upstream.rs b/crates/labby-gateway/src/upstream.rs index 1c07bef27..e6dbb46ac 100644 --- a/crates/labby-gateway/src/upstream.rs +++ b/crates/labby-gateway/src/upstream.rs @@ -13,6 +13,7 @@ // wired public APIs. #[allow(dead_code)] pub mod auth; +pub mod direct_stdio; #[allow(dead_code)] pub mod http_client; #[allow(dead_code)] diff --git a/crates/labby-gateway/src/upstream/direct_stdio.rs b/crates/labby-gateway/src/upstream/direct_stdio.rs new file mode 100644 index 000000000..8a3e2c73a --- /dev/null +++ b/crates/labby-gateway/src/upstream/direct_stdio.rs @@ -0,0 +1,92 @@ +//! Public, explicitly-authorized stdio MCP connection API. + +use std::ffi::OsString; +use std::path::PathBuf; + +use rmcp::service::Peer; +use rmcp::{ClientHandler, RoleClient}; + +use super::pool::UpstreamConnection; + +/// An argv-based stdio command entered explicitly by a local operator. +#[derive(Debug, Clone)] +pub struct DirectStdioCommand { + pub program: OsString, + pub args: Vec, + pub cwd: PathBuf, + pub env: Vec<(OsString, OsString)>, + pub inherit_env: Vec, + pub display: String, +} + +/// A discovered MCP peer together with ownership of its child process tree. +pub struct DirectStdioConnection +where + H: ClientHandler, +{ + inner: UpstreamConnection, +} + +impl std::fmt::Debug for DirectStdioConnection +where + H: ClientHandler, +{ + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DirectStdioConnection") + .field("child_pid", &self.child_pid()) + .field("protocol_version", &self.protocol_version()) + .finish_non_exhaustive() + } +} + +impl DirectStdioConnection +where + H: ClientHandler, +{ + pub(crate) fn new(inner: UpstreamConnection) -> Self { + Self { inner } + } + + #[must_use] + pub fn peer(&self) -> &Peer { + &self.inner.peer + } + + #[must_use] + pub fn child_pid(&self) -> Option { + self.inner.runtime.pid + } + + #[must_use] + pub fn protocol_version(&self) -> Option { + self.inner + .peer + .peer_info() + .map(|info| info.protocol_version.clone()) + } + + #[must_use] + pub fn is_closed(&self) -> bool { + self.inner.peer.is_transport_closed() + } + + pub async fn shutdown(self) { + self.inner.shutdown("direct-stdio", "proxy_shutdown").await; + } +} + +/// Spawn, negotiate, and discover an explicitly-authorized local stdio server. +/// +/// Dropping the returned connection is fail-safe: the same Unix process-group +/// and Windows Job Object ownership used by the gateway pool reaps descendants. +pub async fn connect_direct_stdio( + command: DirectStdioCommand, + handler: H, +) -> anyhow::Result<(DirectStdioConnection, Vec)> +where + H: ClientHandler + Clone, +{ + let (connection, tools) = super::pool::connect_direct_stdio(command, handler).await?; + Ok((DirectStdioConnection::new(connection), tools)) +} diff --git a/crates/labby-gateway/src/upstream/pool.rs b/crates/labby-gateway/src/upstream/pool.rs index dbc30e681..b8c627c75 100644 --- a/crates/labby-gateway/src/upstream/pool.rs +++ b/crates/labby-gateway/src/upstream/pool.rs @@ -54,6 +54,7 @@ mod tools_call; mod usage_record; mod validate; +pub(crate) use connect_stdio::connect_direct_stdio; use helpers::{DEFAULT_RELAY_TIMEOUT, DEFAULT_REQUEST_TIMEOUT}; pub use helpers::{ UpstreamCachedSummary, in_process_upstream_name, redact_resource_uri_for_logging, diff --git a/crates/labby-gateway/src/upstream/pool/connect_stdio.rs b/crates/labby-gateway/src/upstream/pool/connect_stdio.rs index 079ec4055..116fc23a9 100644 --- a/crates/labby-gateway/src/upstream/pool/connect_stdio.rs +++ b/crates/labby-gateway/src/upstream/pool/connect_stdio.rs @@ -8,6 +8,8 @@ use labby_runtime::gateway_config::UpstreamConfig; use rmcp::service::ClientServiceExt; use rmcp::{ClientHandler, RoleClient}; +use std::ffi::OsString; +use std::path::PathBuf; use super::super::auth::configured_bearer_token; use super::super::types::{UpstreamRuntimeMetadata, UpstreamRuntimeOwner}; @@ -43,52 +45,103 @@ pub(super) async fn connect_stdio_upstream( runtime_origin: Option<&str>, runtime_owner: Option<&UpstreamRuntimeOwner>, handler: H, +) -> anyhow::Result<(UpstreamConnection, Vec)> { + let mut env = config + .env + .iter() + .map(|(key, value)| (OsString::from(key), OsString::from(value))) + .collect::>(); + if let Some(ref env_name) = config.bearer_token_env + && let Some(token) = configured_bearer_token(env_name) + { + env.push((OsString::from(env_name), OsString::from(token))); + } + let command_spec = StdioCommandSpec { + program: OsString::from(command), + args: args.iter().map(OsString::from).collect(), + cwd: None, + env, + inherit_env: Vec::new(), + display: command.to_string(), + name: config.name.clone(), + runtime_origin: runtime_origin_label(runtime_origin, runtime_owner), + runtime_owner: runtime_owner.cloned(), + }; + connect_stdio_command(command_spec, handler, true).await +} + +pub(crate) async fn connect_direct_stdio( + command: crate::upstream::direct_stdio::DirectStdioCommand, + handler: H, +) -> anyhow::Result<(UpstreamConnection, Vec)> { + let spec = StdioCommandSpec { + program: command.program, + args: command.args, + cwd: Some(command.cwd), + env: command.env, + inherit_env: command.inherit_env, + name: "direct-stdio".to_string(), + display: command.display, + runtime_origin: Some("proxy:local-cli".to_string()), + runtime_owner: None, + }; + connect_stdio_command(spec, handler, false).await +} + +#[derive(Clone)] +struct StdioCommandSpec { + program: OsString, + args: Vec, + cwd: Option, + env: Vec<(OsString, OsString)>, + inherit_env: Vec, + display: String, + name: String, + runtime_origin: Option, + runtime_owner: Option, +} + +async fn connect_stdio_command( + command: StdioCommandSpec, + handler: H, + allow_cache_repair: bool, ) -> anyhow::Result<(UpstreamConnection, Vec)> { // Cross-process spawn lock: stdio servers launched via `npx -y`/`uvx` install // into a shared package cache on first cold spawn; two processes installing // the same package at once corrupt it. Hold an advisory file lock (keyed on // the command + args) for the whole connect — spawn, handshake, list_tools, // and a possible targeted cache repair/retry. - let mut spawn_lock = super::spawn_lock::open(command, args); + let lock_command = command.program.to_string_lossy(); + let lock_args = command + .args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + let mut spawn_lock = super::spawn_lock::open(&lock_command, &lock_args); let _spawn_guard = super::spawn_lock::acquire(spawn_lock.as_mut()).await; - match connect_stdio_upstream_once( - command, - args, - config, - runtime_origin, - runtime_owner, - handler.clone(), - LifecycleAttempt::Modern, - ) - .await - { + match connect_stdio_upstream_once(&command, handler.clone(), LifecycleAttempt::Modern).await { Ok(ok) => Ok(ok), Err(first_error) => { let lifecycle_error = anyhow::anyhow!(first_error.diagnostics_with_error()); if let Some(attempt) = compatibility_retry(&lifecycle_error) { - log_fallback(&config.name, "stdio", attempt, &lifecycle_error); - return connect_stdio_upstream_once( - command, - args, - config, - runtime_origin, - runtime_owner, - handler, - attempt, - ) - .await - .map_err(StdioConnectError::into_anyhow); + log_fallback(&command.name, "stdio", attempt, &lifecycle_error); + return connect_stdio_upstream_once(&command, handler, attempt) + .await + .map_err(StdioConnectError::into_anyhow); } let diagnostics = first_error.diagnostics_with_error(); - let repair = super::cache_repair::maybe_repair(command, &diagnostics).await; + if !allow_cache_repair { + return Err(first_error.into_anyhow()); + } + let repair = super::cache_repair::maybe_repair(&lock_command, &diagnostics).await; match &repair { super::cache_repair::CacheRepairOutcome::Repaired { summary } => { tracing::warn!( surface = "dispatch", service = "upstream.pool", - upstream = %config.name, - command = %command, + upstream = %command.name, + command = %command.display, action = "upstream.cache_repair", repair = %summary, "stdio package-runner cache repaired after startup failure; retrying once" @@ -98,8 +151,8 @@ pub(super) async fn connect_stdio_upstream( tracing::warn!( surface = "dispatch", service = "upstream.pool", - upstream = %config.name, - command = %command, + upstream = %command.name, + command = %command.display, action = "upstream.cache_repair", repair = %summary, "stdio package-runner cache repair failed; returning original startup error" @@ -109,17 +162,7 @@ pub(super) async fn connect_stdio_upstream( _ => return Err(first_error.into_anyhow()), } - match connect_stdio_upstream_once( - command, - args, - config, - runtime_origin, - runtime_owner, - handler, - LifecycleAttempt::Modern, - ) - .await - { + match connect_stdio_upstream_once(&command, handler, LifecycleAttempt::Modern).await { Ok(ok) => Ok(ok), Err(retry_error) => Err(anyhow::anyhow!( "stdio upstream failed after package-runner cache repair retry: {}", @@ -131,11 +174,7 @@ pub(super) async fn connect_stdio_upstream( } async fn connect_stdio_upstream_once( - command: &str, - args: &[String], - config: &UpstreamConfig, - runtime_origin: Option<&str>, - runtime_owner: Option<&UpstreamRuntimeOwner>, + command: &StdioCommandSpec, handler: H, lifecycle: LifecycleAttempt, ) -> Result<(UpstreamConnection, Vec), StdioConnectError> { @@ -187,22 +226,23 @@ async fn connect_stdio_upstream_once( "HOMEPATH", ]; - let mut cmd = Command::new(command); - cmd.args(args); + let mut cmd = Command::new(&command.program); + cmd.args(&command.args); + if let Some(cwd) = &command.cwd { + cmd.current_dir(cwd); + } cmd.env_clear(); for key in STDIO_ENV_ALLOWLIST { - if let Ok(value) = std::env::var(key) { + if let Some(value) = std::env::var_os(key) { cmd.env(key, value); } } - cmd.envs(config.env.iter()); - - // Set bearer token env var on the child if configured - if let Some(ref env_name) = config.bearer_token_env - && let Some(token) = configured_bearer_token(env_name) - { - cmd.env(env_name, &token); + for key in &command.inherit_env { + if let Some(value) = std::env::var_os(key) { + cmd.env(key, value); + } } + cmd.envs(command.env.iter().cloned()); // A stdio MCP server logs to stderr (stdout is the JSON-RPC channel), so the // child's stderr is the ONLY place its server-side diagnostics go. Capture @@ -235,7 +275,7 @@ async fn connect_stdio_upstream_once( // EOF so failures are recoverable from the gateway log instead of lost. forward_upstream_stderr( child_stderr, - config.name.clone(), + command.name.clone(), stderr_level, stderr_capture.clone(), ); @@ -243,8 +283,8 @@ async fn connect_stdio_upstream_once( let pid = process.id(); tracing::info!( surface = "dispatch", service = "upstream.pool", - upstream = %config.name, transport = "stdio", - action = "upstream.connect.start", command = %command, pid = ?pid, + upstream = %command.name, transport = "stdio", + action = "upstream.connect.start", command = %command.display, pid = ?pid, "upstream connect start", ); @@ -282,7 +322,7 @@ async fn connect_stdio_upstream_once( }; tracing::info!( surface = "dispatch", service = "upstream.pool", - upstream = %config.name, transport = "stdio", + upstream = %command.name, transport = "stdio", action = "upstream.connect.finish", pid = ?pid, tool_count = tools.len(), "upstream connect finish", ); @@ -321,8 +361,8 @@ async fn connect_stdio_upstream_once( #[cfg(windows)] job_handle: job_handle_for_runtime, started_at: Some(std::time::SystemTime::now()), - origin: runtime_origin_label(runtime_origin, runtime_owner), - owner: runtime_owner.cloned(), + origin: command.runtime_origin.clone(), + owner: command.runtime_owner.clone(), }, ); diff --git a/crates/labby-gateway/src/upstream/pool/connection.rs b/crates/labby-gateway/src/upstream/pool/connection.rs index 3c4a68917..2162118f4 100644 --- a/crates/labby-gateway/src/upstream/pool/connection.rs +++ b/crates/labby-gateway/src/upstream/pool/connection.rs @@ -130,7 +130,7 @@ impl Drop for UpstreamConnection { } impl UpstreamConnection { - pub(super) async fn shutdown(mut self, upstream_name: &str, reason: &'static str) { + pub(crate) async fn shutdown(mut self, upstream_name: &str, reason: &'static str) { // Clone runtime BEFORE taking pgid / job_handle so subsequent log // lines still surface the original values. let runtime = self.runtime.clone(); diff --git a/crates/labby/Cargo.toml b/crates/labby/Cargo.toml index 0d8905cf6..0de0ea04f 100644 --- a/crates/labby/Cargo.toml +++ b/crates/labby/Cargo.toml @@ -17,6 +17,12 @@ name = "labby" path = "src/main.rs" test = false +[[bin]] +name = "stdio-mcp-fixture" +path = "tests/fixtures/stdio_mcp_fixture.rs" +test = false +required-features = ["proxy-testkit"] + [dependencies] labby-auth = { path = "../labby-auth", features = ["http-axum", "upstream-oauth-rmcp"] } labby-primitives = { path = "../labby-primitives" } @@ -148,5 +154,6 @@ default = ["gateway-host"] fs = ["dep:walkdir", "dep:globset", "dep:unicode-normalization", "dep:rustix"] lab-admin = [] systemd = ["dep:sd-notify"] +proxy-testkit = [] [lints] workspace = true diff --git a/crates/labby/src/cli/proxy.rs b/crates/labby/src/cli/proxy.rs index 2548da2c7..78f1f6669 100644 --- a/crates/labby/src/cli/proxy.rs +++ b/crates/labby/src/cli/proxy.rs @@ -7,7 +7,7 @@ use anyhow::Result; use clap::Args; use crate::config::LabConfig; -use crate::output::OutputFormat; +use crate::output::{OutputFormat, print}; /// Exit code for the proxy command. pub type ExitCode = std::process::ExitCode; @@ -89,8 +89,36 @@ impl ProxyArgs { } } -/// Prepare the proxy invocation. Runtime wiring lands in the next checkpoint. -pub async fn run(args: ProxyArgs, config: &LabConfig, _format: OutputFormat) -> Result { +#[cfg(feature = "gateway")] +#[derive(serde::Serialize)] +struct ProxyReadyOutput { + url: String, + exposure: &'static str, + auth: &'static str, + external_port: u16, + local_addr: String, + command: Vec, + child_pid: Option, + protocol_version: String, +} + +#[cfg(feature = "gateway")] +fn parse_explicit_env(values: &[String]) -> Result> { + values + .iter() + .map(|entry| { + let (name, value) = entry + .split_once('=') + .filter(|(name, _)| !name.is_empty()) + .ok_or_else(|| anyhow::anyhow!("--env requires NAME=VALUE, got `{entry}`"))?; + Ok((OsString::from(name), OsString::from(value))) + }) + .collect() +} + +/// Run the stdio MCP proxy command in the foreground. +#[cfg(feature = "gateway")] +pub async fn run(args: ProxyArgs, config: &LabConfig, format: OutputFormat) -> Result { let cwd = args.cwd.clone().unwrap_or(std::env::current_dir()?); let command = crate::proxy::command::resolve_proxy_command( &args.command, @@ -104,28 +132,116 @@ pub async fn run(args: ProxyArgs, config: &LabConfig, _format: OutputFormat) -> .validate() .map_err(|error| anyhow::anyhow!("proxy preferences validation failed: {error}"))?; - let _bearer_token = if matches!(prefs.auth, crate::proxy::config::ProxyAuthMode::Bearer) { - Some(args.read_bearer_token().await?.ok_or_else(|| { - anyhow::anyhow!( - "bearer auth requires LABBY_PROXY_BEARER_TOKEN, --bearer-token, or --bearer-token-stdin" - ) - })?) + let bearer_token = if matches!(prefs.auth, crate::proxy::config::ProxyAuthMode::Bearer) { + Some( + args.read_bearer_token() + .await? + .or_else(|| std::env::var(&prefs.bearer_token_env).ok()) + .ok_or_else(|| { + anyhow::anyhow!( + "bearer auth requires {}, --bearer-token, or --bearer-token-stdin", + prefs.bearer_token_env + ) + })?, + ) } else { None }; + let mut inherit_env = prefs + .inherit_env + .iter() + .map(OsString::from) + .collect::>(); + inherit_env.extend(args.inherit_env.iter().map(OsString::from)); + let command_json = std::iter::once(command.program.to_string_lossy().into_owned()) + .chain( + command + .args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()), + ) + .collect::>(); + tracing::info!( surface = "cli", service = "proxy", - action = "proxy.prepare", + action = "proxy.start", command = %command.display, exposure = ?prefs.exposure, auth = ?prefs.auth, path = %prefs.path, - "prepared stdio MCP proxy invocation" + "starting stdio MCP proxy" ); - anyhow::bail!("stdio MCP proxy runtime is not implemented in this checkpoint") + let proxy = + crate::proxy::runtime::LocalProxy::start(crate::proxy::runtime::LocalProxyOptions { + command, + preferences: prefs, + bearer_token, + explicit_env: parse_explicit_env(&args.env)?, + inherit_env, + }) + .await + .map_err(|error| anyhow::anyhow!("proxy startup failed: {error}"))?; + let info = proxy.info(); + + if format.is_json() { + print( + &ProxyReadyOutput { + url: info.url.to_string(), + exposure: "local", + auth: if info.auth == crate::proxy::config::ProxyAuthMode::Bearer { + "bearer" + } else { + "none" + }, + external_port: info.local_addr.port(), + local_addr: info.local_addr.to_string(), + command: command_json, + child_pid: info.child_pid, + protocol_version: info.protocol_version.to_string(), + }, + format, + )?; + } else { + #[allow(clippy::print_stdout)] + { + println!("MCP proxy ready"); + println!(); + println!(" Server {}", info.command); + println!(" URL {}", info.url); + println!(" Exposure Local"); + println!( + " Auth {}", + if info.auth == crate::proxy::config::ProxyAuthMode::Bearer { + "Bearer token" + } else { + "None" + } + ); + println!(); + println!("Press Ctrl+C to stop."); + } + } + + let failure = tokio::select! { + signal = tokio::signal::ctrl_c() => { + signal?; + None + } + result = proxy.wait_for_failure() => Some(result), + }; + proxy.shutdown().await?; + if let Some(result) = failure { + result?; + } + Ok(ExitCode::SUCCESS) +} + +#[cfg(not(feature = "gateway"))] +pub async fn run(_args: ProxyArgs, _config: &LabConfig, _format: OutputFormat) -> Result { + anyhow::bail!("stdio MCP proxy runtime requires the `gateway` feature") } #[cfg(test)] diff --git a/crates/labby/src/mcp/bridge.rs b/crates/labby/src/mcp/bridge.rs index 71f06b8d7..a5caba4be 100644 --- a/crates/labby/src/mcp/bridge.rs +++ b/crates/labby/src/mcp/bridge.rs @@ -85,12 +85,11 @@ impl ClientHandler for BridgeClientHandler { } } -/// Holds the live connection to the real daemon. `_service` keeps the -/// underlying transport worker (and its `BridgeClientHandler`) alive for as -/// long as the bridge runs; `peer` is the actual handle used to forward -/// downstream requests to the daemon. +/// Transparent MCP bridge over a client peer. When constructed from a full +/// running service, `_service` retains transport ownership. Proxy runtimes may +/// instead retain that ownership externally and construct the bridge from a peer. pub struct BridgeServerHandler { - _service: RunningService, + _service: Option>, peer: Peer, } @@ -98,7 +97,17 @@ impl BridgeServerHandler { pub fn new(service: RunningService) -> Self { let peer = service.peer().clone(); Self { - _service: service, + _service: Some(service), + peer, + } + } + + /// Build a transparent bridge over a peer whose connection ownership is + /// retained by another runtime supervisor. + #[must_use] + pub fn from_peer(peer: Peer) -> Self { + Self { + _service: None, peer, } } diff --git a/crates/labby/src/proxy.rs b/crates/labby/src/proxy.rs index e634da2dc..f1049583c 100644 --- a/crates/labby/src/proxy.rs +++ b/crates/labby/src/proxy.rs @@ -2,3 +2,9 @@ pub mod command; pub mod config; +#[cfg(feature = "gateway")] +pub mod runtime; + +#[cfg(test)] +#[cfg(feature = "gateway")] +mod runtime_tests; diff --git a/crates/labby/src/proxy/runtime.rs b/crates/labby/src/proxy/runtime.rs new file mode 100644 index 000000000..06338bd36 --- /dev/null +++ b/crates/labby/src/proxy/runtime.rs @@ -0,0 +1,235 @@ +//! Foreground loopback runtime for one explicitly selected stdio MCP child. + +use std::ffi::OsString; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use axum::Router; +use labby_auth::AuthLayer; +use labby_gateway::upstream::direct_stdio::{ + DirectStdioCommand, DirectStdioConnection, connect_direct_stdio, +}; +use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::never::NeverSessionManager, +}; +use tokio_util::sync::CancellationToken; + +use crate::mcp::bridge::{BridgeClientHandler, BridgeServerHandler}; +use crate::proxy::command::ProxyCommand; +use crate::proxy::config::{ProxyAuthMode, ProxyExposure, ProxyPreferences}; + +pub struct LocalProxyOptions { + pub command: ProxyCommand, + pub preferences: ProxyPreferences, + pub bearer_token: Option, + pub explicit_env: Vec<(OsString, OsString)>, + pub inherit_env: Vec, +} + +#[derive(Clone)] +pub struct LocalProxyInfo { + pub url: url::Url, + pub local_addr: std::net::SocketAddr, + pub command: String, + pub child_pid: Option, + pub protocol_version: rmcp::model::ProtocolVersion, + pub auth: ProxyAuthMode, +} + +impl std::fmt::Debug for LocalProxyInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LocalProxyInfo") + .field("url", &self.url) + .field("local_addr", &self.local_addr) + .field("command", &self.command) + .field("child_pid", &self.child_pid) + .field("protocol_version", &self.protocol_version) + .field("auth", &self.auth) + .finish() + } +} + +pub struct LocalProxy { + info: LocalProxyInfo, + connection: Option>, + cancellation: CancellationToken, + server_task: Option>>, +} + +impl std::fmt::Debug for LocalProxy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LocalProxy") + .field("info", &self.info) + .field("connection", &self.connection.is_some()) + .field("cancellation", &self.cancellation.is_cancelled()) + .field("server_task", &self.server_task.is_some()) + .finish() + } +} + +impl LocalProxy { + pub async fn start(options: LocalProxyOptions) -> Result { + if options.preferences.exposure != ProxyExposure::Local { + bail!( + "proxy exposure {:?} is unsupported in this runtime slice", + options.preferences.exposure + ); + } + if !matches!( + options.preferences.auth, + ProxyAuthMode::None | ProxyAuthMode::Bearer + ) { + bail!( + "proxy auth {:?} is unsupported in this runtime slice", + options.preferences.auth + ); + } + let bearer_token = match options.preferences.auth { + ProxyAuthMode::Bearer => Some( + options + .bearer_token + .filter(|token| !token.is_empty()) + .context("bearer auth requires a non-empty proxy token")?, + ), + ProxyAuthMode::None => None, + _ => unreachable!("unsupported auth modes rejected above"), + }; + + let display = options.command.display.clone(); + let (connection, _discovered_tools) = connect_direct_stdio( + DirectStdioCommand { + program: options.command.program, + args: options.command.args, + cwd: options.command.cwd, + env: options.explicit_env, + inherit_env: options.inherit_env, + display: display.clone(), + }, + BridgeClientHandler::new(), + ) + .await + .context("proxy child startup and MCP discovery failed")?; + + let protocol_version = connection + .protocol_version() + .unwrap_or(rmcp::model::ProtocolVersion::V_2026_07_28); + let child_pid = connection.child_pid(); + let peer = connection.peer().clone(); + + let bind_port = options.preferences.port.fixed().unwrap_or(0); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", bind_port)) + .await + .context("failed to bind proxy loopback listener")?; + let local_addr = listener.local_addr()?; + let url = url::Url::parse(&format!("http://{local_addr}{}", options.preferences.path))?; + + let cancellation = CancellationToken::new(); + let service_config = StreamableHttpServerConfig::default() + .with_allowed_hosts([ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + local_addr.to_string(), + ]) + .with_allowed_origins([ + format!("http://127.0.0.1:{}", local_addr.port()), + format!("http://localhost:{}", local_addr.port()), + ]) + .with_legacy_session_mode(false) + .with_json_response(false) + .with_cancellation_token(cancellation.clone()); + let session_manager = Arc::new(NeverSessionManager::default()); + let mcp_service = StreamableHttpService::new( + move || Ok(BridgeServerHandler::from_peer(peer.clone())), + session_manager, + service_config, + ); + + let mut router = Router::new().nest_service(&options.preferences.path, mcp_service); + if let Some(token) = bearer_token { + router = router.layer( + AuthLayer::new() + .with_static_token(Some(Arc::::from(token))) + .with_resource_url(Some(Arc::::from(url.as_str()))), + ); + } + + let shutdown = cancellation.clone(); + let server_task = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(shutdown.cancelled_owned()) + .await + .context("proxy HTTP server failed") + }); + + Ok(Self { + info: LocalProxyInfo { + url, + local_addr, + command: display, + child_pid, + protocol_version, + auth: options.preferences.auth, + }, + connection: Some(connection), + cancellation, + server_task: Some(server_task), + }) + } + + #[must_use] + pub fn info(&self) -> &LocalProxyInfo { + &self.info + } + + #[must_use] + pub fn url(&self) -> &url::Url { + &self.info.url + } + + /// Wait until a supervised component exits unexpectedly. + pub async fn wait_for_failure(&self) -> Result<()> { + loop { + if self + .connection + .as_ref() + .is_none_or(DirectStdioConnection::is_closed) + { + bail!("proxy stdio child connection closed unexpectedly"); + } + if self + .server_task + .as_ref() + .is_none_or(tokio::task::JoinHandle::is_finished) + { + bail!("proxy HTTP listener exited unexpectedly"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + pub async fn shutdown(mut self) -> Result<()> { + self.cancellation.cancel(); + if let Some(server_task) = self.server_task.take() { + match tokio::time::timeout(Duration::from_secs(3), server_task).await { + Ok(result) => result.context("proxy HTTP task panicked")??, + Err(_) => bail!("proxy HTTP server did not stop within 3 seconds"), + } + } + if let Some(connection) = self.connection.take() { + connection.shutdown().await; + } + Ok(()) + } +} + +impl Drop for LocalProxy { + fn drop(&mut self) { + self.cancellation.cancel(); + if let Some(task) = self.server_task.take() { + task.abort(); + } + // DirectStdioConnection::Drop owns fail-safe process-tree cleanup. + } +} diff --git a/crates/labby/src/proxy/runtime_tests.rs b/crates/labby/src/proxy/runtime_tests.rs new file mode 100644 index 000000000..5c489c40d --- /dev/null +++ b/crates/labby/src/proxy/runtime_tests.rs @@ -0,0 +1,99 @@ +//! Focused tests for the local proxy runtime validation. + +use std::ffi::OsString; +use std::path::PathBuf; + +use crate::proxy::command::ProxyCommand; +use crate::proxy::config::{ProxyAuthMode, ProxyExposure, ProxyPreferences}; +use crate::proxy::runtime::{LocalProxy, LocalProxyOptions}; + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn local_proxy_rejects_tailscale_exposure() { + let command = ProxyCommand { + program: OsString::from("echo"), + args: vec![OsString::from("hello")], + cwd: PathBuf::from("/tmp"), + display: "echo hello".to_string(), + }; + + let prefs = ProxyPreferences { + exposure: ProxyExposure::Tailscale, + ..Default::default() + }; + + let result = LocalProxy::start(LocalProxyOptions { + command, + preferences: prefs, + bearer_token: None, + explicit_env: vec![], + inherit_env: vec![], + }) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.to_string().contains("unsupported")); + } + + #[tokio::test] + async fn local_proxy_rejects_oauth_auth() { + let command = ProxyCommand { + program: OsString::from("echo"), + args: vec![OsString::from("hello")], + cwd: PathBuf::from("/tmp"), + display: "echo hello".to_string(), + }; + + let prefs = ProxyPreferences { + exposure: ProxyExposure::Local, + auth: ProxyAuthMode::Oauth, + ..Default::default() + }; + + let result = LocalProxy::start(LocalProxyOptions { + command, + preferences: prefs, + bearer_token: None, + explicit_env: vec![], + inherit_env: vec![], + }) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.to_string().contains("unsupported")); + } + + #[tokio::test] + async fn local_proxy_rejects_tailnet_auth() { + let command = ProxyCommand { + program: OsString::from("echo"), + args: vec![OsString::from("hello")], + cwd: PathBuf::from("/tmp"), + display: "echo hello".to_string(), + }; + + let prefs = ProxyPreferences { + exposure: ProxyExposure::Local, + auth: ProxyAuthMode::Tailnet, + ..Default::default() + }; + + let result = LocalProxy::start(LocalProxyOptions { + command, + preferences: prefs, + bearer_token: None, + explicit_env: vec![], + inherit_env: vec![], + }) + .await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.to_string().contains("unsupported")); + } +} diff --git a/crates/labby/tests/fixtures/stdio_mcp_fixture.rs b/crates/labby/tests/fixtures/stdio_mcp_fixture.rs new file mode 100644 index 000000000..fa3e9cf76 --- /dev/null +++ b/crates/labby/tests/fixtures/stdio_mcp_fixture.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use rmcp::model::{ + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ErrorData, + ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, +}; +use rmcp::service::RequestContext; +use rmcp::{RoleServer, ServerHandler, ServiceExt}; + +struct FixtureServer { + saw_non_utf8_argument: bool, +} + +impl ServerHandler for FixtureServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } + + async fn list_tools( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListToolsResult::with_all_items(vec![Tool::new( + "fixture.echo", + "Echo fixture input", + Arc::new(serde_json::Map::new()), + )])) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext, + ) -> Result { + if request.name.as_ref() != "fixture.echo" { + return Err(ErrorData::invalid_params("unknown fixture tool", None)); + } + let payload = serde_json::json!({ + "cwd": std::env::current_dir().ok(), + "explicit_env": std::env::var("PROXY_EXPLICIT").ok(), + "inherited_path": std::env::var("PATH").ok(), + "arguments": request.arguments, + "saw_non_utf8_argument": self.saw_non_utf8_argument, + }); + Ok(CallToolResult::success(vec![ContentBlock::text(payload.to_string())]).into()) + } +} + +#[cfg(unix)] +fn is_non_utf8_marker(argument: &std::ffi::OsStr) -> bool { + use std::os::unix::ffi::OsStrExt as _; + argument.as_bytes() == [b'x', 0xff] +} + +#[cfg(not(unix))] +fn is_non_utf8_marker(_argument: &std::ffi::OsStr) -> bool { + false +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mut args = std::env::args_os().skip(1); + let mut saw_non_utf8_argument = false; + while let Some(arg) = args.next() { + if arg == "--pid-file" { + let path = args + .next() + .ok_or_else(|| anyhow::anyhow!("missing pid path"))?; + std::fs::write(path, std::process::id().to_string())?; + } else if is_non_utf8_marker(&arg) { + saw_non_utf8_argument = true; + } + } + + let running = FixtureServer { + saw_non_utf8_argument, + } + .serve((tokio::io::stdin(), tokio::io::stdout())) + .await?; + running.waiting().await?; + Ok(()) +} diff --git a/crates/labby/tests/stdio_proxy_runtime.rs b/crates/labby/tests/stdio_proxy_runtime.rs new file mode 100644 index 000000000..b25dc4b49 --- /dev/null +++ b/crates/labby/tests/stdio_proxy_runtime.rs @@ -0,0 +1,318 @@ +#![cfg(all(feature = "gateway", feature = "proxy-testkit"))] + +use std::ffi::OsString; +use std::path::PathBuf; +use std::time::Duration; + +use labby::proxy::command::ProxyCommand; +use labby::proxy::config::{ProxyAuthMode, ProxyExposure, ProxyPortPreference, ProxyPreferences}; +use labby::proxy::runtime::{LocalProxy, LocalProxyOptions}; +use rmcp::service::{ClientLifecycleMode, ClientServiceExt}; +use rmcp::transport::streamable_http_client::{ + StreamableHttpClientTransportConfig, StreamableHttpClientWorker, +}; + +fn ensure_tls_provider() { + drop(rustls::crypto::ring::default_provider().install_default()); +} + +fn fixture_command(cwd: PathBuf, pid_file: &std::path::Path) -> ProxyCommand { + let program = OsString::from(env!("CARGO_BIN_EXE_stdio-mcp-fixture")); + let args = vec![ + OsString::from("--pid-file"), + pid_file.as_os_str().to_owned(), + ]; + ProxyCommand { + display: format!( + "{} --pid-file {}", + program.to_string_lossy(), + pid_file.display() + ), + program, + args, + cwd, + } +} + +fn local_preferences(auth: ProxyAuthMode) -> ProxyPreferences { + ProxyPreferences { + exposure: ProxyExposure::Local, + auth, + path: "/custom-mcp".to_string(), + ..ProxyPreferences::default() + } +} + +async fn connect( + url: &url::Url, + token: Option<&str>, +) -> rmcp::service::RunningService { + ensure_tls_provider(); + let mut config = StreamableHttpClientTransportConfig::with_uri(url.as_str().to_string()); + config.auth_header = token.map(str::to_string); + let worker = StreamableHttpClientWorker::new(reqwest::Client::new(), config); + ().serve_with_lifecycle( + worker, + ClientLifecycleMode::Discover { + preferred_versions: vec![rmcp::model::ProtocolVersion::V_2026_07_28], + }, + ) + .await + .expect("HTTP MCP client connects") +} + +#[tokio::test] +async fn local_proxy_forwards_tools_list_after_child_discovery_and_bind() { + let temp = tempfile::tempdir().unwrap(); + let pid_file = temp.path().join("child.pid"); + let mut command = fixture_command(temp.path().to_path_buf(), &pid_file); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + command.args.push(OsString::from_vec(vec![b'x', 0xff])); + } + let proxy = LocalProxy::start(LocalProxyOptions { + command, + preferences: local_preferences(ProxyAuthMode::None), + bearer_token: None, + explicit_env: vec![(OsString::from("PROXY_EXPLICIT"), OsString::from("present"))], + inherit_env: vec![OsString::from("PATH")], + }) + .await + .expect("proxy starts only after child discovery and listener bind"); + + assert_eq!(proxy.url().path(), "/custom-mcp"); + let service = connect(proxy.url(), None).await; + let tools = service.peer().list_all_tools().await.unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].name.as_ref(), "fixture.echo"); + + let sse_response = reqwest::Client::new() + .post(proxy.url().clone()) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header( + reqwest::header::ACCEPT, + "application/json, text/event-stream", + ) + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/list") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 77, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { + "name": "proxy-sse-test", + "version": "1.0.0" + }, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })) + .send() + .await + .unwrap(); + assert_eq!(sse_response.status(), reqwest::StatusCode::OK); + assert!( + sse_response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("text/event-stream")) + ); + let sse_body = sse_response.text().await.unwrap(); + assert!( + sse_body.contains("\"id\":77"), + "unexpected SSE body: {sse_body}" + ); + assert!( + sse_body.contains("\"tools\""), + "unexpected SSE body: {sse_body}" + ); + + let result = service + .peer() + .call_tool(rmcp::model::CallToolRequestParams::new("fixture.echo")) + .await + .unwrap(); + let context: serde_json::Value = serde_json::from_str( + &result.content[0] + .as_text() + .expect("fixture returns text") + .text, + ) + .unwrap(); + assert_eq!(context["cwd"], temp.path().to_string_lossy().as_ref()); + assert_eq!(context["explicit_env"], "present"); + assert!( + context["inherited_path"] + .as_str() + .is_some_and(|path| !path.is_empty()) + ); + #[cfg(unix)] + assert_eq!(context["saw_non_utf8_argument"], true); + + let bad_host = reqwest::Client::new() + .post(proxy.url().clone()) + .header(reqwest::header::HOST, "attacker.example") + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"server/discover"}"#) + .send() + .await + .unwrap(); + assert_eq!(bad_host.status(), reqwest::StatusCode::FORBIDDEN); + let bad_origin = reqwest::Client::new() + .post(proxy.url().clone()) + .header(reqwest::header::ORIGIN, "https://attacker.example") + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"server/discover"}"#) + .send() + .await + .unwrap(); + assert_eq!(bad_origin.status(), reqwest::StatusCode::FORBIDDEN); + + proxy.shutdown().await.expect("clean proxy shutdown"); +} + +#[tokio::test] +async fn local_proxy_honors_a_fixed_port() { + let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let fixed_port = reservation.local_addr().unwrap().port(); + drop(reservation); + + let temp = tempfile::tempdir().unwrap(); + let pid_file = temp.path().join("fixed-port-child.pid"); + let mut preferences = local_preferences(ProxyAuthMode::None); + preferences.port = ProxyPortPreference::Fixed(fixed_port); + let proxy = LocalProxy::start(LocalProxyOptions { + command: fixture_command(temp.path().to_path_buf(), &pid_file), + preferences, + bearer_token: None, + explicit_env: Vec::new(), + inherit_env: vec![OsString::from("PATH")], + }) + .await + .unwrap(); + + assert_eq!(proxy.url().port(), Some(fixed_port)); + proxy.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn bearer_proxy_rejects_missing_token_and_accepts_configured_token() { + ensure_tls_provider(); + let temp = tempfile::tempdir().unwrap(); + let pid_file = temp.path().join("child.pid"); + let proxy = LocalProxy::start(LocalProxyOptions { + command: fixture_command(temp.path().to_path_buf(), &pid_file), + preferences: local_preferences(ProxyAuthMode::Bearer), + bearer_token: Some("proxy-secret".to_string()), + explicit_env: Vec::new(), + inherit_env: vec![OsString::from("PATH")], + }) + .await + .unwrap(); + + let rejected = reqwest::Client::new() + .post(proxy.url().clone()) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"server/discover"}"#) + .send() + .await + .unwrap(); + assert_eq!(rejected.status(), reqwest::StatusCode::UNAUTHORIZED); + assert!( + rejected + .headers() + .contains_key(reqwest::header::WWW_AUTHENTICATE) + ); + let wrong_token = reqwest::Client::new() + .post(proxy.url().clone()) + .bearer_auth("wrong-secret") + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(r#"{"jsonrpc":"2.0","id":1,"method":"server/discover"}"#) + .send() + .await + .unwrap(); + assert_eq!(wrong_token.status(), reqwest::StatusCode::UNAUTHORIZED); + + let service = connect(proxy.url(), Some("proxy-secret")).await; + assert_eq!(service.peer().list_all_tools().await.unwrap().len(), 1); + proxy.shutdown().await.unwrap(); +} + +#[cfg(unix)] +#[tokio::test] +async fn shutdown_reaps_the_owned_stdio_child() { + let temp = tempfile::tempdir().unwrap(); + let pid_file = temp.path().join("child.pid"); + let proxy = LocalProxy::start(LocalProxyOptions { + command: fixture_command(temp.path().to_path_buf(), &pid_file), + preferences: local_preferences(ProxyAuthMode::None), + bearer_token: None, + explicit_env: Vec::new(), + inherit_env: vec![OsString::from("PATH")], + }) + .await + .unwrap(); + let pid: i32 = std::fs::read_to_string(&pid_file).unwrap().parse().unwrap(); + + proxy.shutdown().await.unwrap(); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None).is_ok() { + assert!( + tokio::time::Instant::now() < deadline, + "child PID {pid} survived shutdown" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +#[cfg(unix)] +#[tokio::test] +async fn cli_prints_real_url_serves_tools_and_stops_cleanly_on_sigint() { + use tokio::io::{AsyncBufReadExt, BufReader}; + use tokio::process::Command; + + let temp = tempfile::tempdir().unwrap(); + let pid_file = temp.path().join("cli-child.pid"); + let mut child = Command::new(env!("CARGO_BIN_EXE_labby")) + .args(["--json", "proxy", "--local", "--auth", "none"]) + .arg(env!("CARGO_BIN_EXE_stdio-mcp-fixture")) + .arg("--pid-file") + .arg(&pid_file) + .env("LABBY_HOME", temp.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + let stdout = child.stdout.take().unwrap(); + let line = tokio::time::timeout( + Duration::from_secs(10), + BufReader::new(stdout).lines().next_line(), + ) + .await + .expect("CLI readiness output timed out") + .unwrap() + .expect("CLI exited before readiness"); + let ready: serde_json::Value = + serde_json::from_str(&line).expect("readiness is one JSON object"); + let url = url::Url::parse(ready["url"].as_str().unwrap()).unwrap(); + let service = connect(&url, None).await; + assert_eq!(service.peer().list_all_tools().await.unwrap().len(), 1); + + nix::sys::signal::kill( + nix::unistd::Pid::from_raw(i32::try_from(child.id().unwrap()).unwrap()), + nix::sys::signal::Signal::SIGINT, + ) + .unwrap(); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .expect("CLI did not stop after Ctrl+C") + .unwrap(); + assert!(status.success(), "CLI exited with {status}"); +} From 8ce314fd8b11f4b3326114265e6330ed77ef7d22 Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Sat, 1 Aug 2026 09:20:04 -0400 Subject: [PATCH 4/8] feat(proxy): publish through Tailscale Serve --- crates/labby/src/cli/proxy.rs | 184 +++++++- crates/labby/src/proxy.rs | 2 + crates/labby/src/proxy/tailscale.rs | 532 ++++++++++++++++++++++ crates/labby/tests/stdio_proxy_runtime.rs | 98 ++++ crates/labby/tests/tailscale_serve.rs | 276 +++++++++++ 5 files changed, 1067 insertions(+), 25 deletions(-) create mode 100644 crates/labby/src/proxy/tailscale.rs create mode 100644 crates/labby/tests/tailscale_serve.rs diff --git a/crates/labby/src/cli/proxy.rs b/crates/labby/src/cli/proxy.rs index 78f1f6669..35400e677 100644 --- a/crates/labby/src/cli/proxy.rs +++ b/crates/labby/src/cli/proxy.rs @@ -3,7 +3,7 @@ use std::ffi::OsString; use std::path::PathBuf; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Args; use crate::config::LabConfig; @@ -116,6 +116,33 @@ fn parse_explicit_env(values: &[String]) -> Result> { .collect() } +#[cfg(feature = "gateway")] +fn local_runtime_preferences( + preferences: &crate::proxy::config::ProxyPreferences, +) -> Result { + use crate::proxy::config::{ + ProxyAuthMode, ProxyExposure, ProxyPortPreference, ProxyPreferences, + }; + + if preferences.auth == ProxyAuthMode::Oauth { + anyhow::bail!("proxy OAuth auth is unsupported in the Tailscale publication slice"); + } + let auth = match (preferences.exposure, preferences.auth) { + (ProxyExposure::Tailscale, ProxyAuthMode::Tailnet) => ProxyAuthMode::None, + (_, auth @ (ProxyAuthMode::None | ProxyAuthMode::Bearer)) => auth, + (ProxyExposure::Local, ProxyAuthMode::Tailnet) => { + anyhow::bail!("tailnet auth requires Tailscale exposure") + } + (_, ProxyAuthMode::Oauth) => unreachable!("OAuth rejected above"), + }; + Ok(ProxyPreferences { + exposure: ProxyExposure::Local, + auth, + port: ProxyPortPreference::default(), + ..preferences.clone() + }) +} + /// Run the stdio MCP proxy command in the foreground. #[cfg(feature = "gateway")] pub async fn run(args: ProxyArgs, config: &LabConfig, format: OutputFormat) -> Result { @@ -132,6 +159,7 @@ pub async fn run(args: ProxyArgs, config: &LabConfig, format: OutputFormat) -> R .validate() .map_err(|error| anyhow::anyhow!("proxy preferences validation failed: {error}"))?; + let local_preferences = local_runtime_preferences(&prefs)?; let bearer_token = if matches!(prefs.auth, crate::proxy::config::ProxyAuthMode::Bearer) { Some( args.read_bearer_token() @@ -177,62 +205,133 @@ pub async fn run(args: ProxyArgs, config: &LabConfig, format: OutputFormat) -> R let proxy = crate::proxy::runtime::LocalProxy::start(crate::proxy::runtime::LocalProxyOptions { command, - preferences: prefs, + preferences: local_preferences, bearer_token, explicit_env: parse_explicit_env(&args.env)?, inherit_env, }) .await .map_err(|error| anyhow::anyhow!("proxy startup failed: {error}"))?; - let info = proxy.info(); + let info = proxy.info().clone(); + let mut tailscale = if prefs.exposure == crate::proxy::config::ProxyExposure::Tailscale { + let mut options = crate::proxy::tailscale::TailscaleServeOptions::for_proxy( + info.local_addr, + prefs.path.clone(), + prefs.port, + prefs.port_range_start, + prefs.port_range_end, + ); + if let Some(executable) = std::env::var_os("LABBY_TAILSCALE_BIN") { + options.executable = executable.into(); + } + match crate::proxy::tailscale::TailscaleServe::start(options).await { + Ok(serve) => Some(serve), + Err(error) => { + let shutdown = proxy.shutdown().await; + if let Err(shutdown) = shutdown { + return Err(error).context(format!( + "LocalProxy cleanup also failed after Tailscale startup failure: {shutdown:#}" + )); + } + return Err(error).context("Tailscale Serve publication failed"); + } + } + } else { + None + }; + let public_url = tailscale + .as_ref() + .map_or_else(|| info.url.clone(), |serve| serve.public_url().clone()); + let external_port = tailscale.as_ref().map_or( + info.local_addr.port(), + crate::proxy::tailscale::TailscaleServe::external_port, + ); + let exposure = if tailscale.is_some() { + "tailscale" + } else { + "local" + }; + let auth = match prefs.auth { + crate::proxy::config::ProxyAuthMode::Tailnet => "tailnet", + crate::proxy::config::ProxyAuthMode::Bearer => "bearer", + crate::proxy::config::ProxyAuthMode::Oauth => "oauth", + crate::proxy::config::ProxyAuthMode::None => "none", + }; - if format.is_json() { + let ready_output = if format.is_json() { print( &ProxyReadyOutput { - url: info.url.to_string(), - exposure: "local", - auth: if info.auth == crate::proxy::config::ProxyAuthMode::Bearer { - "bearer" - } else { - "none" - }, - external_port: info.local_addr.port(), + url: public_url.to_string(), + exposure, + auth, + external_port, local_addr: info.local_addr.to_string(), command: command_json, child_pid: info.child_pid, protocol_version: info.protocol_version.to_string(), }, format, - )?; + ) } else { #[allow(clippy::print_stdout)] { println!("MCP proxy ready"); println!(); println!(" Server {}", info.command); - println!(" URL {}", info.url); - println!(" Exposure Local"); + println!(" URL {public_url}"); println!( - " Auth {}", - if info.auth == crate::proxy::config::ProxyAuthMode::Bearer { - "Bearer token" + " Exposure {}", + if tailscale.is_some() { + "Tailscale Serve" } else { - "None" + "Local" + } + ); + println!( + " Auth {}", + match prefs.auth { + crate::proxy::config::ProxyAuthMode::Tailnet => "Tailnet", + crate::proxy::config::ProxyAuthMode::Bearer => "Bearer token", + crate::proxy::config::ProxyAuthMode::Oauth => "OAuth", + crate::proxy::config::ProxyAuthMode::None => "None", } ); println!(); println!("Press Ctrl+C to stop."); } + Ok(()) + }; + if let Err(output_error) = ready_output { + let tailscale_shutdown = match tailscale.take() { + Some(serve) => serve.shutdown().await, + None => Ok(()), + }; + let proxy_shutdown = proxy.shutdown().await; + tailscale_shutdown + .context("Tailscale Serve shutdown failed after readiness output error")?; + proxy_shutdown.context("LocalProxy shutdown failed after readiness output error")?; + return Err(output_error); } - let failure = tokio::select! { - signal = tokio::signal::ctrl_c() => { - signal?; - None + let failure = if let Some(serve) = tailscale.as_mut() { + tokio::select! { + signal = tokio::signal::ctrl_c() => Some(signal.map_err(anyhow::Error::from)), + result = proxy.wait_for_failure() => Some(result), + result = serve.wait_for_failure() => Some(result), } - result = proxy.wait_for_failure() => Some(result), + } else { + tokio::select! { + signal = tokio::signal::ctrl_c() => Some(signal.map_err(anyhow::Error::from)), + result = proxy.wait_for_failure() => Some(result), + } + }; + let tailscale_shutdown = match tailscale { + Some(serve) => serve.shutdown().await, + None => Ok(()), }; - proxy.shutdown().await?; + let proxy_shutdown = proxy.shutdown().await; + tailscale_shutdown.context("Tailscale Serve shutdown failed")?; + proxy_shutdown.context("LocalProxy shutdown failed")?; if let Some(result) = failure { result?; } @@ -359,4 +458,39 @@ mod tests { ]); assert_eq!(args.inherit_env, vec!["PATH", "HOME"]); } + + #[test] + fn tailscale_tailnet_uses_ephemeral_loopback_without_application_auth() { + let prefs = crate::proxy::config::ProxyPreferences { + port: crate::proxy::config::ProxyPortPreference::Fixed(52_177), + ..Default::default() + }; + let local = local_runtime_preferences(&prefs).unwrap(); + assert_eq!(local.exposure, crate::proxy::config::ProxyExposure::Local); + assert_eq!(local.auth, crate::proxy::config::ProxyAuthMode::None); + assert_eq!(local.port.fixed(), None); + } + + #[test] + fn tailscale_bearer_stays_bearer_on_loopback() { + let prefs = crate::proxy::config::ProxyPreferences { + auth: crate::proxy::config::ProxyAuthMode::Bearer, + ..Default::default() + }; + assert_eq!( + local_runtime_preferences(&prefs).unwrap().auth, + crate::proxy::config::ProxyAuthMode::Bearer + ); + } + + #[test] + fn oauth_is_an_explicitly_unsupported_proxy_slice() { + let prefs = crate::proxy::config::ProxyPreferences { + auth: crate::proxy::config::ProxyAuthMode::Oauth, + ..Default::default() + }; + let error = local_runtime_preferences(&prefs).unwrap_err(); + assert!(error.to_string().contains("OAuth")); + assert!(error.to_string().contains("unsupported")); + } } diff --git a/crates/labby/src/proxy.rs b/crates/labby/src/proxy.rs index f1049583c..7ac563b92 100644 --- a/crates/labby/src/proxy.rs +++ b/crates/labby/src/proxy.rs @@ -4,6 +4,8 @@ pub mod command; pub mod config; #[cfg(feature = "gateway")] pub mod runtime; +#[cfg(feature = "gateway")] +pub mod tailscale; #[cfg(test)] #[cfg(feature = "gateway")] diff --git a/crates/labby/src/proxy/tailscale.rs b/crates/labby/src/proxy/tailscale.rs new file mode 100644 index 000000000..04fba2fc8 --- /dev/null +++ b/crates/labby/src/proxy/tailscale.rs @@ -0,0 +1,532 @@ +//! Tailscale Serve publication for the ephemeral stdio proxy. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ffi::OsString; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use serde::Deserialize; +use tokio::io::AsyncReadExt; +use tokio::process::{Child, Command}; +use tokio::task::JoinHandle; + +use crate::proxy::config::ProxyPortPreference; + +/// Relevant fields from `tailscale status --json`. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct TailscaleStatus { + backend_state: String, + #[serde(rename = "Self")] + self_node: TailscaleSelf, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct TailscaleSelf { + online: bool, + #[serde(rename = "DNSName")] + dns_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TailscaleIdentity { + pub dns_name: String, +} + +impl TailscaleStatus { + pub fn parse(json: &str) -> Result { + serde_json::from_str(json).context("invalid `tailscale status --json` response") + } + + pub fn require_online(&self) -> Result { + if self.backend_state != "Running" { + bail!( + "Tailscale backend state is {:?}, expected Running", + self.backend_state + ); + } + if !self.self_node.online { + bail!("the local Tailscale node is offline"); + } + if self.self_node.dns_name.is_empty() { + bail!("the local Tailscale node has no DNS name"); + } + Ok(TailscaleIdentity { + dns_name: self.self_node.dns_name.clone(), + }) + } +} + +/// Relevant fields from `tailscale serve status --json`. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ServeStatus { + #[serde(default, rename = "TCP")] + tcp: BTreeMap, + #[serde(default)] + web: BTreeMap, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct ServeWeb { + #[serde(default)] + handlers: BTreeMap, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct ServeHandler { + proxy: Option, +} + +impl ServeStatus { + pub fn parse(json: &str) -> Result { + serde_json::from_str(json).context("invalid `tailscale serve status --json` response") + } + + #[must_use] + pub fn occupied_ports(&self) -> BTreeSet { + let mut ports = self + .tcp + .keys() + .filter_map(|port| port.parse().ok()) + .collect::>(); + ports.extend(self.web.keys().filter_map(|authority| { + authority + .rsplit_once(':') + .and_then(|(_, port)| port.parse::().ok()) + })); + ports + } + + #[must_use] + pub fn backend_for(&self, dns_name: &str, port: u16) -> Option<&str> { + let authority = format!("{dns_name}:{port}"); + self.web + .get(&authority)? + .handlers + .get("/")? + .proxy + .as_deref() + } +} + +pub fn build_public_url(dns_name: &str, port: u16, path: &str) -> Result { + let host = dns_name.strip_suffix('.').unwrap_or(dns_name); + url::Url::parse(&format!("https://{host}:{port}{path}")) + .context("failed to construct Tailscale proxy URL") +} + +pub fn select_port_from_candidates( + preference: ProxyPortPreference, + range_start: u16, + range_end: u16, + status: &ServeStatus, + candidates: impl IntoIterator, + max_attempts: usize, +) -> Result { + let occupied = status.occupied_ports(); + if let Some(port) = preference.fixed() { + if occupied.contains(&port) { + bail!("Tailscale Serve port {port} is already configured"); + } + return Ok(port); + } + + for port in candidates.into_iter().take(max_attempts) { + if (range_start..=range_end).contains(&port) && !occupied.contains(&port) { + return Ok(port); + } + } + bail!( + "no unused Tailscale Serve port found in {range_start}..={range_end} after {max_attempts} attempts" + ) +} + +#[derive(Debug, Clone)] +pub struct TailscaleServeOptions { + pub executable: PathBuf, + pub local_addr: SocketAddr, + pub path: String, + pub port: ProxyPortPreference, + pub port_range_start: u16, + pub port_range_end: u16, + pub candidate_ports: Vec, + pub max_attempts: usize, + pub poll_interval: Duration, + pub readiness_timeout: Duration, +} + +impl TailscaleServeOptions { + #[must_use] + pub fn for_proxy( + local_addr: SocketAddr, + path: String, + port: ProxyPortPreference, + port_range_start: u16, + port_range_end: u16, + ) -> Self { + Self { + executable: PathBuf::from("tailscale"), + local_addr, + path, + port, + port_range_start, + port_range_end, + candidate_ports: Vec::new(), + max_attempts: 32, + poll_interval: Duration::from_millis(50), + readiness_timeout: Duration::from_secs(5), + } + } +} + +#[derive(Debug)] +pub struct TailscaleServe { + executable: PathBuf, + child: Option, + stdout_task: Option>>>, + stderr_task: Option>>>, + dns_name: String, + external_port: u16, + backend: String, + public_url: url::Url, + poll_interval: Duration, + readiness_timeout: Duration, +} + +impl TailscaleServe { + pub async fn start(options: TailscaleServeOptions) -> Result { + if options.max_attempts == 0 { + bail!("Tailscale Serve port selection requires at least one attempt"); + } + let version = run_checked(&options.executable, ["version"]).await?; + if version.trim().is_empty() { + bail!("Tailscale CLI returned an empty version"); + } + let status_output = run_checked(&options.executable, ["status", "--json"]).await?; + let identity = TailscaleStatus::parse(&status_output)?.require_online()?; + let dns_name = identity + .dns_name + .strip_suffix('.') + .unwrap_or(&identity.dns_name) + .to_string(); + let serve_output = run_checked(&options.executable, ["serve", "status", "--json"]).await?; + let initial_status = ServeStatus::parse(&serve_output)?; + + let candidates = if let Some(port) = options.port.fixed() { + vec![port] + } else if options.candidate_ports.is_empty() { + random_candidates( + options.port_range_start, + options.port_range_end, + options.max_attempts, + )? + } else { + options.candidate_ports.clone() + }; + let occupied = initial_status.occupied_ports(); + let backend = format!("http://127.0.0.1:{}", options.local_addr.port()); + let mut last_error = None; + let random_mode = options.port.fixed().is_none(); + + for external_port in candidates.into_iter().take(options.max_attempts) { + if !(options.port_range_start..=options.port_range_end).contains(&external_port) + && random_mode + { + continue; + } + if occupied.contains(&external_port) { + if random_mode { + continue; + } + bail!("Tailscale Serve port {external_port} is already configured"); + } + + match Self::claim(&options, dns_name.clone(), external_port, backend.clone()).await { + Ok(serve) => return Ok(serve), + Err(error) if random_mode && is_collision_error(&error.to_string()) => { + last_error = Some(error); + } + Err(error) => return Err(error), + } + } + + let suffix = last_error + .map(|error| format!("; last Serve error: {error:#}")) + .unwrap_or_default(); + bail!( + "no usable Tailscale Serve port found in {}..={} after {} attempts{}", + options.port_range_start, + options.port_range_end, + options.max_attempts, + suffix + ) + } + + async fn claim( + options: &TailscaleServeOptions, + dns_name: String, + external_port: u16, + backend: String, + ) -> Result { + let mut child = Command::new(&options.executable) + .arg("serve") + .arg("--yes") + .arg(format!("--https={external_port}")) + .arg(&backend) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .with_context(|| { + format!( + "failed to start `{}` Serve process", + options.executable.display() + ) + })?; + let stdout_task = child.stdout.take().map(drain_pipe); + let stderr_task = child.stderr.take().map(drain_pipe); + let deadline = tokio::time::Instant::now() + options.readiness_timeout; + + loop { + if let Some(status) = child + .try_wait() + .context("failed to inspect Serve process")? + { + let stdout = join_output(stdout_task).await; + let stderr = join_output(stderr_task).await; + bail!( + "Tailscale Serve exited before exact mapping verification with {status}: {}{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ); + } + let status = read_serve_status(&options.executable).await?; + if status.backend_for(&dns_name, external_port) == Some(backend.as_str()) { + return Ok(Self { + executable: options.executable.clone(), + child: Some(child), + stdout_task, + stderr_task, + dns_name: dns_name.clone(), + external_port, + backend, + public_url: build_public_url(&dns_name, external_port, &options.path)?, + poll_interval: options.poll_interval, + readiness_timeout: options.readiness_timeout, + }); + } + if tokio::time::Instant::now() >= deadline { + terminate_child(&mut child).await; + bail!( + "timed out waiting for exact Tailscale Serve mapping on {dns_name}:{external_port}" + ); + } + tokio::time::sleep(options.poll_interval).await; + } + } + + #[must_use] + pub fn public_url(&self) -> &url::Url { + &self.public_url + } + + #[must_use] + pub const fn external_port(&self) -> u16 { + self.external_port + } + + pub async fn wait_for_failure(&mut self) -> Result<()> { + loop { + if let Some(status) = self + .child + .as_mut() + .context("Tailscale Serve process is no longer owned")? + .try_wait() + .context("failed to inspect Tailscale Serve process")? + { + bail!("Tailscale Serve foreground process exited unexpectedly: {status}"); + } + let status = read_serve_status(&self.executable).await?; + match status.backend_for(&self.dns_name, self.external_port) { + Some(backend) if backend == self.backend => {} + Some(backend) => bail!( + "Tailscale Serve mapping ownership changed from {} to {backend}", + self.backend + ), + None => bail!("owned Tailscale Serve mapping disappeared unexpectedly"), + } + tokio::time::sleep(self.poll_interval).await; + } + } + + pub async fn shutdown(mut self) -> Result<()> { + if let Some(mut child) = self.child.take() { + terminate_child_with_timeout(&mut child, self.readiness_timeout).await; + } + if let Some(task) = self.stdout_task.take() { + drop(task.await); + } + if let Some(task) = self.stderr_task.take() { + drop(task.await); + } + + let deadline = tokio::time::Instant::now() + self.readiness_timeout; + loop { + let status = read_serve_status(&self.executable).await?; + match status.backend_for(&self.dns_name, self.external_port) { + None => return Ok(()), + Some(backend) if backend != self.backend => { + bail!( + "Tailscale Serve mapping ownership changed from {} to {backend}; refusing cleanup", + self.backend + ); + } + Some(_) if tokio::time::Instant::now() < deadline => { + tokio::time::sleep(self.poll_interval).await; + } + Some(_) => break, + } + } + + run_checked( + &self.executable, + [ + OsString::from("serve"), + OsString::from("--yes"), + OsString::from(format!("--https={}", self.external_port)), + OsString::from("off"), + ], + ) + .await + .context("exact-port Tailscale Serve cleanup failed")?; + let status = read_serve_status(&self.executable).await?; + if status + .backend_for(&self.dns_name, self.external_port) + .is_some() + { + bail!("exact-port Tailscale Serve cleanup did not remove the owned mapping"); + } + Ok(()) + } +} + +impl Drop for TailscaleServe { + fn drop(&mut self) { + if let Some(child) = self.child.as_mut() { + drop(child.start_kill()); + } + if let Some(task) = self.stdout_task.take() { + task.abort(); + } + if let Some(task) = self.stderr_task.take() { + task.abort(); + } + } +} + +fn drain_pipe( + mut pipe: impl tokio::io::AsyncRead + Unpin + Send + 'static, +) -> JoinHandle>> { + tokio::spawn(async move { + let mut output = Vec::new(); + let mut chunk = [0_u8; 1_024]; + loop { + let read = pipe.read(&mut chunk).await?; + if read == 0 { + break; + } + output.extend_from_slice(&chunk[..read]); + const CAPTURE_LIMIT: usize = 16 * 1_024; + if output.len() > CAPTURE_LIMIT { + output.drain(..output.len() - CAPTURE_LIMIT); + } + } + Ok(output) + }) +} + +async fn join_output(task: Option>>>) -> Vec { + match task { + Some(task) => task.await.ok().and_then(Result::ok).unwrap_or_default(), + None => Vec::new(), + } +} + +async fn run_checked(executable: &PathBuf, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let output = Command::new(executable) + .args(args) + .stdin(Stdio::null()) + .output() + .await + .with_context(|| format!("failed to execute `{}`", executable.display()))?; + if !output.status.success() { + bail!( + "`{}` exited with {}: {}", + executable.display(), + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + String::from_utf8(output.stdout).context("Tailscale CLI emitted non-UTF-8 JSON") +} + +async fn read_serve_status(executable: &PathBuf) -> Result { + ServeStatus::parse(&run_checked(executable, ["serve", "status", "--json"]).await?) +} + +fn random_candidates(start: u16, end: u16, count: usize) -> Result> { + if start > end { + bail!("invalid proxy port range {start}..={end}"); + } + let width = u32::from(end) - u32::from(start) + 1; + let mut result = Vec::with_capacity(count); + while result.len() < count { + let mut bytes = [0_u8; 2]; + getrandom::fill(&mut bytes) + .context("OS randomness unavailable for proxy port selection")?; + let candidate = u32::from(u16::from_ne_bytes(bytes)) % width + u32::from(start); + let candidate = u16::try_from(candidate).context("proxy port candidate overflowed")?; + result.push(candidate); + } + Ok(result) +} + +fn is_collision_error(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("already configured") + || lower.contains("already in use") + || lower.contains("conflict") +} + +async fn terminate_child(child: &mut Child) { + terminate_child_with_timeout(child, Duration::from_secs(1)).await; +} + +async fn terminate_child_with_timeout(child: &mut Child, timeout: Duration) { + if child.try_wait().ok().flatten().is_some() { + return; + } + #[cfg(unix)] + if let Some(pid) = child.id() { + let _ignored = crate::process::unix::terminate_sigterm(pid); + } + #[cfg(not(unix))] + drop(child.start_kill()); + + if tokio::time::timeout(timeout, child.wait()).await.is_err() { + drop(child.start_kill()); + drop(child.wait().await); + } +} diff --git a/crates/labby/tests/stdio_proxy_runtime.rs b/crates/labby/tests/stdio_proxy_runtime.rs index b25dc4b49..b0c5e1a49 100644 --- a/crates/labby/tests/stdio_proxy_runtime.rs +++ b/crates/labby/tests/stdio_proxy_runtime.rs @@ -316,3 +316,101 @@ async fn cli_prints_real_url_serves_tools_and_stops_cleanly_on_sigint() { .unwrap(); assert!(status.success(), "CLI exited with {status}"); } + +#[cfg(unix)] +#[tokio::test] +async fn zero_flag_cli_publishes_verified_tailscale_url_with_fake_cli() { + use std::os::unix::fs::PermissionsExt; + use tokio::io::{AsyncBufReadExt, BufReader}; + use tokio::process::Command; + + let temp = tempfile::tempdir().unwrap(); + let pid_file = temp.path().join("tailscale-child.pid"); + let mapping = temp.path().join("mapping"); + let calls = temp.path().join("tailscale-calls"); + let fake_tailscale = temp.path().join("tailscale"); + let script = format!( + r#"#!/usr/bin/env bash +set -u +mapping='{mapping}' +calls='{calls}' +printf '%s\n' "$*" >> "$calls" +if [[ "${{1:-}} ${{2:-}}" == "status --json" ]]; then + printf '%s\n' '{{"BackendState":"Running","Self":{{"Online":true,"DNSName":"proxy-test.example.ts.net."}}}}' + exit 0 +fi +if [[ "${{1:-}}" == "version" ]]; then printf '%s\n' '1.98.10'; exit 0; fi +if [[ "${{1:-}} ${{2:-}} ${{3:-}}" == "serve status --json" ]]; then + if [[ -f "$mapping" ]]; then + IFS='|' read -r port backend < "$mapping" + printf '{{"TCP":{{}},"Web":{{"proxy-test.example.ts.net:%s":{{"Handlers":{{"/":{{"Proxy":"%s"}}}}}}}}}}\n' "$port" "$backend" + else + printf '%s\n' '{{"TCP":{{}},"Web":{{}}}}' + fi + exit 0 +fi +if [[ "${{1:-}}" == "serve" ]]; then + port="${{3#--https=}}"; backend="${{4:-}}" + if [[ "$backend" == "off" ]]; then rm -f "$mapping"; exit 0; fi + printf '%s|%s\n' "$port" "$backend" > "$mapping" + trap 'rm -f "$mapping"; exit 0' TERM INT + while :; do sleep 0.05; done +fi +exit 2 +"#, + mapping = mapping.display(), + calls = calls.display(), + ); + std::fs::write(&fake_tailscale, script).unwrap(); + std::fs::set_permissions(&fake_tailscale, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(temp.path().join("config.toml"), "[proxy]\nport = 54000\n").unwrap(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_labby")) + .args(["--json", "proxy"]) + .arg(env!("CARGO_BIN_EXE_stdio-mcp-fixture")) + .arg("--pid-file") + .arg(&pid_file) + .env("LABBY_HOME", temp.path()) + .env("LABBY_TAILSCALE_BIN", &fake_tailscale) + .current_dir(temp.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let line = tokio::time::timeout( + Duration::from_secs(10), + BufReader::new(child.stdout.take().unwrap()) + .lines() + .next_line(), + ) + .await + .expect("CLI readiness output timed out") + .unwrap() + .expect("CLI exited before readiness"); + let ready: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(ready["url"], "https://proxy-test.example.ts.net:54000/mcp"); + assert_eq!(ready["exposure"], "tailscale"); + assert_eq!(ready["auth"], "tailnet"); + assert_eq!(ready["external_port"], 54_000); + let local_url = url::Url::parse(&format!( + "http://{}/mcp", + ready["local_addr"].as_str().unwrap() + )) + .unwrap(); + let service = connect(&local_url, None).await; + assert_eq!(service.peer().list_all_tools().await.unwrap().len(), 1); + + nix::sys::signal::kill( + nix::unistd::Pid::from_raw(i32::try_from(child.id().unwrap()).unwrap()), + nix::sys::signal::Signal::SIGINT, + ) + .unwrap(); + let status = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .expect("CLI did not stop after Ctrl+C") + .unwrap(); + assert!(status.success(), "CLI exited with {status}"); + assert!(!mapping.exists()); + let calls = std::fs::read_to_string(calls).unwrap(); + assert!(!calls.contains("reset")); +} diff --git a/crates/labby/tests/tailscale_serve.rs b/crates/labby/tests/tailscale_serve.rs new file mode 100644 index 000000000..7eb52b2b0 --- /dev/null +++ b/crates/labby/tests/tailscale_serve.rs @@ -0,0 +1,276 @@ +#![cfg(all(feature = "gateway", feature = "proxy-testkit"))] + +use labby::proxy::config::ProxyPortPreference; +use labby::proxy::tailscale::{ + ServeStatus, TailscaleServe, TailscaleServeOptions, TailscaleStatus, build_public_url, + select_port_from_candidates, +}; +use std::fs; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const STATUS: &str = r#"{ + "BackendState": "Running", + "Self": {"Online": true, "DNSName": "dookie.example.ts.net."} +}"#; + +const SERVE_STATUS: &str = r#"{ + "TCP": {"52177": {"HTTPS": true}}, + "Web": { + "dookie.example.ts.net:53147": { + "Handlers": {"/": {"Proxy": "http://127.0.0.1:38417"}} + } + } +}"#; + +#[test] +fn status_requires_running_online_node_with_dns_name() { + let status = TailscaleStatus::parse(STATUS).unwrap(); + let identity = status.require_online().unwrap(); + assert_eq!(identity.dns_name, "dookie.example.ts.net."); + + for invalid in [ + r#"{"BackendState":"Stopped","Self":{"Online":true,"DNSName":"node.ts.net."}}"#, + r#"{"BackendState":"Running","Self":{"Online":false,"DNSName":"node.ts.net."}}"#, + r#"{"BackendState":"Running","Self":{"Online":true,"DNSName":""}}"#, + ] { + assert!( + TailscaleStatus::parse(invalid) + .unwrap() + .require_online() + .is_err() + ); + } +} + +#[test] +fn public_url_trims_only_the_dns_trailing_dot() { + let url = build_public_url("dookie.example.ts.net.", 53_147, "/mcp").unwrap(); + assert_eq!(url.as_str(), "https://dookie.example.ts.net:53147/mcp"); +} + +#[test] +fn serve_status_finds_exact_mapping_and_ports_from_both_maps() { + let status = ServeStatus::parse(SERVE_STATUS).unwrap(); + assert_eq!( + status.backend_for("dookie.example.ts.net", 53_147), + Some("http://127.0.0.1:38417") + ); + assert!(status.occupied_ports().contains(&52_177)); + assert!(status.occupied_ports().contains(&53_147)); +} + +#[test] +fn fixed_and_random_selection_respect_tcp_and_web_collisions() { + let status = ServeStatus::parse(SERVE_STATUS).unwrap(); + assert!( + select_port_from_candidates( + ProxyPortPreference::Fixed(52_177), + 49_152, + 65_535, + &status, + [], + 8, + ) + .is_err() + ); + assert_eq!( + select_port_from_candidates( + ProxyPortPreference::default(), + 49_152, + 65_535, + &status, + [52_177, 53_147, 54_000], + 8, + ) + .unwrap(), + 54_000 + ); +} + +#[cfg(unix)] +struct FakeTailscale { + _temp: tempfile::TempDir, + executable: PathBuf, + root: PathBuf, +} + +#[cfg(unix)] +impl FakeTailscale { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().to_path_buf(); + let executable = root.join("tailscale"); + let script = format!( + r#"#!/usr/bin/env bash +set -u +root='{root}' +printf '%s\n' "$*" >> "$root/invocations" +mapping="$root/mapping" +if [[ "${{1:-}} ${{2:-}}" == "status --json" ]]; then + printf '%s\n' '{{"BackendState":"Running","Self":{{"Online":true,"DNSName":"dookie.example.ts.net."}}}}' + exit 0 +fi +if [[ "${{1:-}}" == "version" ]]; then printf '%s\n' '1.98.10'; exit 0; fi +if [[ "${{1:-}} ${{2:-}} ${{3:-}}" == "serve status --json" ]]; then + if [[ -f "$mapping" ]]; then + IFS='|' read -r port backend < "$mapping" + if [[ -f "$root/drift_backend" ]]; then backend=$(<"$root/drift_backend"); fi + printf '{{"TCP":{{"443":{{"HTTPS":true}}}},"Web":{{"dookie.example.ts.net:443":{{"Handlers":{{"/":{{"Proxy":"http://127.0.0.1:8765"}}}}}},"dookie.example.ts.net:%s":{{"Handlers":{{"/":{{"Proxy":"%s"}}}}}}}}}}\n' "$port" "$backend" + else + printf '%s\n' '{{"TCP":{{"443":{{"HTTPS":true}}}},"Web":{{"dookie.example.ts.net:443":{{"Handlers":{{"/":{{"Proxy":"http://127.0.0.1:8765"}}}}}}}}}}' + fi + exit 0 +fi +if [[ "${{1:-}}" == "serve" ]]; then + port="${{3#--https=}}" + if [[ "${{4:-}}" == "off" ]]; then rm -f "$mapping"; exit 0; fi + backend="${{4:-}}" + if [[ -f "$root/collision_ports" ]] && grep -qx "$port" "$root/collision_ports"; then + printf '%s\n' 'port already configured' >&2 + exit 1 + fi + if [[ -f "$root/exit_early" ]]; then exit 23; fi + printf '%s|%s\n' "$port" "$backend" > "$mapping" + trap 'if [[ ! -f "$root/sticky" ]]; then rm -f "$mapping"; fi; exit 0' TERM INT + while :; do printf 'serve stdout\n'; printf 'serve stderr\n' >&2; sleep 0.02; done +fi +exit 2 +"#, + root = root.display() + ); + fs::write(&executable, script).unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + Self { + _temp: temp, + executable, + root, + } + } + + fn options(&self, candidates: Vec) -> TailscaleServeOptions { + TailscaleServeOptions { + executable: self.executable.clone(), + local_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 38_417), + path: "/mcp".to_string(), + port: ProxyPortPreference::default(), + port_range_start: 49_152, + port_range_end: 65_535, + candidate_ports: candidates, + max_attempts: 4, + poll_interval: Duration::from_millis(10), + readiness_timeout: Duration::from_secs(2), + } + } + + fn touch(&self, name: &str) { + fs::write(self.root.join(name), "1\n").unwrap(); + } + + fn invocations(&self) -> String { + fs::read_to_string(self.root.join("invocations")).unwrap_or_default() + } +} + +#[cfg(unix)] +fn mapping(root: &Path) -> Option { + fs::read_to_string(root.join("mapping")).ok() +} + +#[cfg(unix)] +#[tokio::test] +async fn foreground_serve_is_ready_only_after_exact_mapping_and_cleans_normally() { + let fake = FakeTailscale::new(); + let serve = TailscaleServe::start(fake.options(vec![54_000])) + .await + .unwrap(); + assert_eq!(serve.external_port(), 54_000); + assert_eq!( + serve.public_url().as_str(), + "https://dookie.example.ts.net:54000/mcp" + ); + assert_eq!( + mapping(&fake.root).as_deref(), + Some("54000|http://127.0.0.1:38417\n") + ); + + serve.shutdown().await.unwrap(); + assert!(mapping(&fake.root).is_none()); + let calls = fake.invocations(); + assert!(calls.lines().any(|call| call == "version")); + assert!(calls.contains("serve --yes --https=54000 http://127.0.0.1:38417")); + assert!(!calls.contains(" off")); + assert!(!calls.contains("reset")); + let status = ServeStatus::parse( + &std::process::Command::new(&fake.executable) + .args(["serve", "status", "--json"]) + .output() + .unwrap() + .stdout + .into_iter() + .map(char::from) + .collect::(), + ) + .unwrap(); + assert_eq!( + status.backend_for("dookie.example.ts.net", 443), + Some("http://127.0.0.1:8765") + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn real_serve_collision_retries_a_different_candidate() { + let fake = FakeTailscale::new(); + fs::write(fake.root.join("collision_ports"), "54000\n").unwrap(); + let serve = TailscaleServe::start(fake.options(vec![54_000, 54_001])) + .await + .unwrap(); + assert_eq!(serve.external_port(), 54_001); + serve.shutdown().await.unwrap(); +} + +#[cfg(unix)] +#[tokio::test] +async fn foreground_exit_before_verified_mapping_fails_startup() { + let fake = FakeTailscale::new(); + fake.touch("exit_early"); + let error = TailscaleServe::start(fake.options(vec![54_000])) + .await + .unwrap_err(); + assert!(error.to_string().contains("exited")); +} + +#[cfg(unix)] +#[tokio::test] +async fn sticky_owned_mapping_uses_exact_port_off_fallback() { + let fake = FakeTailscale::new(); + fake.touch("sticky"); + let serve = TailscaleServe::start(fake.options(vec![54_000])) + .await + .unwrap(); + serve.shutdown().await.unwrap(); + assert!(mapping(&fake.root).is_none()); + let calls = fake.invocations(); + assert!(calls.contains("serve --yes --https=54000 off")); + assert!(!calls.contains("reset")); +} + +#[cfg(unix)] +#[tokio::test] +async fn cleanup_refuses_mapping_whose_backend_changed_ownership() { + let fake = FakeTailscale::new(); + fake.touch("sticky"); + let serve = TailscaleServe::start(fake.options(vec![54_000])) + .await + .unwrap(); + fs::write(fake.root.join("drift_backend"), "http://127.0.0.1:49999\n").unwrap(); + let error = serve.shutdown().await.unwrap_err(); + assert!(error.to_string().contains("ownership")); + assert!(mapping(&fake.root).is_some()); + let calls = fake.invocations(); + assert!(!calls.contains(" off")); + assert!(!calls.contains("reset")); +} From 42c845fd4f00727079adba0d7028e1578d293b32 Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Sat, 1 Aug 2026 10:19:59 -0400 Subject: [PATCH 5/8] feat(auth): add ephemeral OAuth resource leases --- crates/labby-auth/src/lib.rs | 5 +- crates/labby-auth/src/middleware.rs | 337 ++++++++++++++++-- crates/labby-auth/src/resource_registry.rs | 329 +++++++++++++++++ crates/labby-auth/src/state.rs | 81 +++-- crates/labby-auth/tests/resource_registry.rs | 186 ++++++++++ crates/labby-gateway/src/gateway/catalog.rs | 68 ++++ crates/labby-gateway/src/gateway/dispatch.rs | 63 +++- .../src/gateway/dispatch_tests.rs | 113 ++++++ crates/labby-gateway/src/gateway/manager.rs | 1 + .../labby-gateway/src/gateway/manager/core.rs | 19 + crates/labby-gateway/src/gateway/params.rs | 19 + crates/labby-gateway/src/gateway/types.rs | 5 + .../src/upstream/pool/connect_tests.rs | 10 +- crates/labby/src/api/router.rs | 22 +- crates/labby/src/api/services/gateway.rs | 35 ++ crates/labby/src/cli/gateway.rs | 1 + crates/labby/src/cli/serve.rs | 67 +++- crates/labby/src/live_gateway.rs | 184 ++++++++++ 18 files changed, 1457 insertions(+), 88 deletions(-) create mode 100644 crates/labby-auth/src/resource_registry.rs create mode 100644 crates/labby-auth/tests/resource_registry.rs diff --git a/crates/labby-auth/src/lib.rs b/crates/labby-auth/src/lib.rs index 3b7264871..a9c84fbb5 100644 --- a/crates/labby-auth/src/lib.rs +++ b/crates/labby-auth/src/lib.rs @@ -17,6 +17,7 @@ pub mod metadata; pub mod middleware; #[cfg(feature = "http-axum")] mod remote; +pub mod resource_registry; #[cfg(feature = "http-axum")] pub mod routes; #[cfg(feature = "http-axum")] @@ -33,7 +34,9 @@ pub mod util; #[cfg(feature = "http-axum")] pub use auth_context::{AuthContext, auth_context, www_authenticate_value}; #[cfg(feature = "http-axum")] -pub use middleware::{ActorKeyDeriver, AuthLayer, AuthService, parse_bearer_token, tokens_equal}; +pub use middleware::{ + ActorKeyDeriver, AuthLayer, AuthService, RequiredScopes, parse_bearer_token, tokens_equal, +}; #[cfg(test)] pub mod test_support; diff --git a/crates/labby-auth/src/middleware.rs b/crates/labby-auth/src/middleware.rs index 257b68459..7d87f5b7d 100644 --- a/crates/labby-auth/src/middleware.rs +++ b/crates/labby-auth/src/middleware.rs @@ -29,7 +29,7 @@ use std::sync::Arc; use std::task::{Context, Poll}; use axum::body::Body; -use axum::http::{HeaderValue, Method, Request, header}; +use axum::http::{HeaderValue, Method, Request, StatusCode, header}; use axum::response::{IntoResponse, Redirect, Response}; use subtle::ConstantTimeEq; use tower::{Layer, Service}; @@ -51,6 +51,30 @@ use crate::state::AuthState; /// browser-session subject) and returns a per-request [`Arc`] key. pub type ActorKeyDeriver = dyn Fn(&str) -> Option> + Send + Sync; +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RequiredScopes(Vec); + +impl RequiredScopes { + #[must_use] + pub fn new(scopes: impl IntoIterator>) -> Self { + let mut scopes = scopes.into_iter().map(Into::into).collect::>(); + scopes.sort(); + scopes.dedup(); + Self(scopes) + } + + #[must_use] + pub fn as_slice(&self) -> &[String] { + &self.0 + } +} + +impl From> for RequiredScopes { + fn from(scopes: Vec) -> Self { + Self::new(scopes) + } +} + /// Tower layer that authenticates inbound requests and writes /// [`AuthContext`] into request extensions. /// @@ -67,6 +91,8 @@ struct AuthLayerInner { auth_state: Option>, actor_key_deriver: Option>, resource_url: Option>, + protected_resource_metadata_url: Option>, + required_scopes: RequiredScopes, allow_session_cookie: bool, /// Scopes minted into the [`AuthContext`] when the static bearer or /// session-cookie path matches. For the static path this is the legacy @@ -96,6 +122,8 @@ impl AuthLayer { auth_state: None, actor_key_deriver: None, resource_url: None, + protected_resource_metadata_url: None, + required_scopes: RequiredScopes::default(), allow_session_cookie: false, static_token_scopes: Vec::new(), login_path: crate::config::DEFAULT_LOGIN_PATH.to_string(), @@ -120,6 +148,8 @@ impl AuthLayer { auth_state: Some(auth_state), actor_key_deriver: None, resource_url: None, + protected_resource_metadata_url: None, + required_scopes: RequiredScopes::default(), allow_session_cookie: false, static_token_scopes, login_path, @@ -162,6 +192,16 @@ impl AuthLayer { self.with(|inner| inner.resource_url = resource_url) } + #[must_use] + pub fn with_protected_resource_metadata_url(self, url: Option>) -> Self { + self.with(|inner| inner.protected_resource_metadata_url = url) + } + + #[must_use] + pub fn with_required_scopes(self, scopes: impl Into) -> Self { + self.with(|inner| inner.required_scopes = scopes.into()) + } + #[must_use] pub fn with_allow_session_cookie(self, allow: bool) -> Self { self.with(|inner| inner.allow_session_cookie = allow) @@ -280,7 +320,7 @@ async fn authenticate( { let sub = "static-bearer".to_string(); let actor_key = derive_actor_key(layer.actor_key_deriver.as_deref(), &sub); - request.extensions_mut().insert(AuthContext { + let auth = AuthContext { sub, actor_key, scopes: layer.static_token_scopes.clone(), @@ -288,7 +328,11 @@ async fn authenticate( via_session: false, csrf_token: None, email: None, - }); + }; + if let Some(response) = insufficient_scope_response(layer, &auth.scopes) { + return Err(response); + } + request.extensions_mut().insert(auth); return Ok(request); } @@ -305,11 +349,13 @@ async fn authenticate( "server misconfigured: {}_PUBLIC_URL required for JWT validation", auth_state.config.env_prefix ), - layer.resource_url.as_deref(), - challenge_scopes(layer), + layer, )); }; - let expected_aud = canonical_resource_url(auth_state); + let expected_aud = layer + .resource_url + .as_deref() + .map_or_else(|| canonical_resource_url(auth_state), str::to_string); match auth_state.signing_keys.validate_access_token_with_issuer( &token, &expected_aud, @@ -318,7 +364,7 @@ async fn authenticate( Ok(claims) => { let actor_key = derive_actor_key(layer.actor_key_deriver.as_deref(), &claims.sub); - request.extensions_mut().insert(AuthContext { + let auth = AuthContext { actor_key, sub: claims.sub, scopes: claims @@ -331,7 +377,11 @@ async fn authenticate( via_session: false, csrf_token: None, email: None, - }); + }; + if let Some(response) = insufficient_scope_response(layer, &auth.scopes) { + return Err(response); + } + request.extensions_mut().insert(auth); return Ok(request); } Err(error) => { @@ -340,11 +390,7 @@ async fn authenticate( } } - return Err(auth_error_response( - "invalid bearer token", - layer.resource_url.as_deref(), - challenge_scopes(layer), - )); + return Err(auth_error_response("invalid bearer token", layer)); } // 3. Browser session cookie path. @@ -370,7 +416,7 @@ async fn authenticate( let actor_key = derive_actor_key(layer.actor_key_deriver.as_deref(), &session.subject); - request.extensions_mut().insert(AuthContext { + let auth = AuthContext { actor_key, sub: session.subject, scopes: layer.static_token_scopes.clone(), @@ -378,7 +424,11 @@ async fn authenticate( via_session: true, csrf_token: Some(session.csrf_token), email: session.email, - }); + }; + if let Some(response) = insufficient_scope_response(layer, &auth.scopes) { + return Err(response); + } + request.extensions_mut().insert(auth); return Ok(request); } Ok(None) => {} @@ -414,8 +464,7 @@ async fn authenticate( } else { "missing bearer token" }, - layer.resource_url.as_deref(), - challenge_scopes(layer), + layer, )) } @@ -445,13 +494,18 @@ fn derive_actor_key(deriver: Option<&ActorKeyDeriver>, subject: &str) -> Option< /// Build a 401 response wrapping [`AuthError::AuthFailed`] and decorate it /// with `WWW-Authenticate` when a `resource_url` was supplied. -fn auth_error_response(message: &str, resource_url: Option<&str>, scopes: &[String]) -> Response { +fn auth_error_response(message: &str, layer: &AuthLayerInner) -> Response { let mut response = AuthError::AuthFailed(message.to_string()).into_response(); - if let Some(url) = resource_url { + let challenge = layer + .protected_resource_metadata_url + .as_deref() + .map(|url| format!("Bearer resource_metadata=\"{url}\"")) + .or_else(|| layer.resource_url.as_deref().map(www_authenticate_value)); + if let Some(challenge) = challenge { let www_auth = format!( "{}, scope=\"{}\"", - www_authenticate_value(url), - scopes.join(" ") + challenge, + challenge_scopes(layer).join(" ") ); if let Ok(value) = HeaderValue::from_str(&www_auth) { response @@ -463,6 +517,9 @@ fn auth_error_response(message: &str, resource_url: Option<&str>, scopes: &[Stri } fn challenge_scopes(layer: &AuthLayerInner) -> &[String] { + if !layer.required_scopes.as_slice().is_empty() { + return layer.required_scopes.as_slice(); + } layer .auth_state .as_ref() @@ -471,6 +528,49 @@ fn challenge_scopes(layer: &AuthLayerInner) -> &[String] { }) } +fn insufficient_scope_response(layer: &AuthLayerInner, granted: &[String]) -> Option { + let required = layer.required_scopes.as_slice(); + if required + .iter() + .all(|scope| granted.iter().any(|granted| granted == scope)) + { + return None; + } + let metadata_url = layer + .protected_resource_metadata_url + .as_deref() + .map(str::to_string) + .or_else(|| layer.resource_url.as_deref().map(metadata_url_for_resource)); + let mut response = ( + StatusCode::FORBIDDEN, + axum::Json(serde_json::json!({ + "kind": "insufficient_scope", + "message": "authenticated principal lacks required scope", + })), + ) + .into_response(); + if let Some(metadata_url) = metadata_url { + let challenge = format!( + "Bearer error=\"insufficient_scope\", scope=\"{}\", resource_metadata=\"{}\"", + required.join(" "), + metadata_url + ); + if let Ok(value) = HeaderValue::from_str(&challenge) { + response + .headers_mut() + .insert(header::WWW_AUTHENTICATE, value); + } + } + Some(response) +} + +fn metadata_url_for_resource(resource: &str) -> String { + format!( + "{}/.well-known/oauth-protected-resource", + resource.trim_end_matches('/') + ) +} + fn csrf_error_response(message: &str) -> Response { AuthError::Validation(message.to_string()).into_response() } @@ -824,4 +924,199 @@ mod tests { // Must be 401 — static token blocked because OAuth is active. assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + + #[tokio::test(flavor = "current_thread")] + async fn exact_resource_audience_including_port_and_path_is_enforced() { + let state = Arc::new(test_auth_state().await); + let issuer = state + .config + .public_url + .as_ref() + .unwrap() + .as_str() + .trim_end_matches('/') + .to_string(); + let exact_resource = "https://proxy.example:53147/mcp"; + let token = state + .signing_keys + .issue_access_token(&crate::jwt::AccessClaims { + iss: issuer, + sub: "user@example.com".to_string(), + aud: exact_resource.to_string(), + exp: (crate::util::now_unix() + 60) as usize, + iat: crate::util::now_unix() as usize, + jti: "exact-resource".to_string(), + scope: "mcp:read".to_string(), + azp: String::new(), + }) + .unwrap(); + + for (configured_resource, expected) in [ + (exact_resource, StatusCode::OK), + ("https://proxy.example:53148/mcp", StatusCode::UNAUTHORIZED), + ( + "https://proxy.example:53147/other", + StatusCode::UNAUTHORIZED, + ), + ] { + let app = echo_app( + AuthLayer::from_state(Arc::clone(&state)) + .with_resource_url(Some(Arc::::from(configured_resource))) + .with_required_scopes(vec!["mcp:read".to_string()]), + ); + let response = app + .oneshot( + HttpRequest::builder() + .uri("/probe") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + expected, + "resource {configured_resource}" + ); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn insufficient_jwt_and_static_scopes_return_403_challenge() { + let state = Arc::new(test_auth_state().await); + let resource = "https://proxy.example:53147/mcp"; + let metadata = "https://proxy.example:53147/.well-known/oauth-protected-resource"; + let issuer = state + .config + .public_url + .as_ref() + .unwrap() + .as_str() + .trim_end_matches('/'); + let jwt = state + .signing_keys + .issue_access_token(&crate::jwt::AccessClaims { + iss: issuer.to_string(), + sub: "user@example.com".to_string(), + aud: resource.to_string(), + exp: (crate::util::now_unix() + 60) as usize, + iat: crate::util::now_unix() as usize, + jti: "insufficient-scope".to_string(), + scope: "mcp:read".to_string(), + azp: String::new(), + }) + .unwrap(); + + let cases = [ + ( + AuthLayer::from_state(Arc::clone(&state)), + format!("Bearer {jwt}"), + ), + ( + AuthLayer::new() + .with_static_token(Some(Arc::::from("static-secret"))) + .with_static_token_scopes(vec!["mcp:read".to_string()]), + "Bearer static-secret".to_string(), + ), + ]; + for (base_layer, authorization) in cases { + let app = echo_app( + base_layer + .with_resource_url(Some(Arc::::from(resource))) + .with_protected_resource_metadata_url(Some(Arc::::from(metadata))) + .with_required_scopes(vec!["mcp:write".to_string()]), + ); + let response = app + .oneshot( + HttpRequest::builder() + .uri("/probe") + .header(header::AUTHORIZATION, authorization) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let challenge = response + .headers() + .get(header::WWW_AUTHENTICATE) + .unwrap() + .to_str() + .unwrap(); + assert_eq!( + challenge, + concat!( + "Bearer error=\"insufficient_scope\", scope=\"mcp:write\", ", + "resource_metadata=\"https://proxy.example:53147/.well-known/oauth-protected-resource\"" + ) + ); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn browser_session_is_rejected_when_required_scope_is_missing() { + let state = Arc::new(test_auth_state().await); + let session = session::create_browser_session( + &state, + "user@example.com".to_string(), + Some("user@example.com".to_string()), + ) + .await + .unwrap(); + let cookie_name = state.config.session_cookie_name.clone(); + let app = echo_app( + AuthLayer::from_state(state) + .with_allow_session_cookie(true) + .with_required_scopes(vec!["scope:not-granted".to_string()]), + ); + let response = app + .oneshot( + HttpRequest::builder() + .uri("/probe") + .header( + header::COOKIE, + format!("{cookie_name}={}", session.session_id), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test(flavor = "current_thread")] + async fn unauthenticated_challenge_uses_explicit_metadata_url_override() { + let app = echo_app( + AuthLayer::new() + .with_resource_url(Some(Arc::::from("https://proxy.example:53147/mcp"))) + .with_protected_resource_metadata_url(Some(Arc::::from( + "https://proxy.example:53147/.well-known/oauth-protected-resource", + ))) + .with_required_scopes(vec!["mcp:read".to_string(), "mcp:write".to_string()]), + ); + let response = app + .oneshot( + HttpRequest::builder() + .uri("/probe") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response + .headers() + .get(header::WWW_AUTHENTICATE) + .unwrap() + .to_str() + .unwrap(), + concat!( + "Bearer resource_metadata=\"https://proxy.example:53147/.well-known/oauth-protected-resource\", ", + "scope=\"mcp:read mcp:write\"" + ) + ); + } } diff --git a/crates/labby-auth/src/resource_registry.rs b/crates/labby-auth/src/resource_registry.rs new file mode 100644 index 000000000..7c08dd522 --- /dev/null +++ b/crates/labby-auth/src/resource_registry.rs @@ -0,0 +1,329 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::Url; + +pub const MAX_RESOURCE_LEASE_TTL: Duration = Duration::from_hours(24); +pub const MAX_RESOURCE_LEASE_OWNER_LEN: usize = 128; +const LEASE_ID_BYTES: usize = 32; + +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct ResourceLease { + pub id: String, + pub resource: String, + pub scopes: Vec, + pub expires_at_unix: u64, +} + +impl std::fmt::Debug for ResourceLease { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ResourceLease") + .field("id", &"[REDACTED]") + .field("resource", &self.resource) + .field("scopes", &self.scopes) + .field("expires_at_unix", &self.expires_at_unix) + .finish() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct ResourceLeaseDiagnostic { + pub resource: String, + pub scopes: Vec, + pub owner: String, + pub expires_at_unix: u64, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum ResourceRegistryError { + #[error("resource must be an absolute HTTPS URL without credentials, query, or fragment")] + InvalidResource, + #[error("at least one valid OAuth scope is required")] + InvalidScopes, + #[error("resource lease TTL must be between 1 and 86400 seconds")] + InvalidTtl, + #[error("resource lease owner must be between 1 and 128 bytes")] + InvalidOwner, + #[error("resource lease was not found or has expired")] + LeaseNotFound, + #[error("failed to generate a resource lease identifier")] + RandomnessUnavailable, + #[error("system clock is before the Unix epoch")] + InvalidClock, +} + +#[derive(Clone)] +pub struct ResourceRegistry { + inner: Arc>, +} + +#[derive(Default)] +struct ResourceRegistryInner { + configured: BTreeMap>, + leases: BTreeMap, +} + +#[derive(Clone)] +struct StoredLease { + resource: String, + scopes: BTreeSet, + owner: String, + expires_at_unix: u64, +} + +impl ResourceRegistry { + #[must_use] + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(ResourceRegistryInner::default())), + } + } + + pub fn replace_configured_resource_scopes( + &self, + resources: impl IntoIterator)>, + ) -> Result<(), ResourceRegistryError> { + let mut configured = BTreeMap::new(); + for (resource, scopes) in resources { + configured.insert(canonical_resource(&resource)?, validated_scopes(scopes)?); + } + self.inner + .write() + .expect("resource registry lock") + .configured = configured; + Ok(()) + } + + pub fn create_resource_lease( + &self, + resource: &str, + scopes: impl IntoIterator>, + ttl: Duration, + owner: &str, + ) -> Result { + self.create_resource_lease_at(resource, scopes, ttl, owner, SystemTime::now()) + } + + pub fn create_resource_lease_at( + &self, + resource: &str, + scopes: impl IntoIterator>, + ttl: Duration, + owner: &str, + now: SystemTime, + ) -> Result { + validate_ttl(ttl)?; + let resource = canonical_resource(resource)?; + let scopes = validated_scopes(scopes)?; + let owner = owner.trim(); + if owner.is_empty() || owner.len() > MAX_RESOURCE_LEASE_OWNER_LEN { + return Err(ResourceRegistryError::InvalidOwner); + } + let now_unix = unix_seconds(now)?; + let expires_at_unix = now_unix + .checked_add(ttl.as_secs()) + .ok_or(ResourceRegistryError::InvalidTtl)?; + let id = random_lease_id()?; + let stored = StoredLease { + resource: resource.clone(), + scopes: scopes.clone(), + owner: owner.to_string(), + expires_at_unix, + }; + let mut inner = self.inner.write().expect("resource registry lock"); + prune_locked(&mut inner, now_unix); + inner.leases.insert(id.clone(), stored); + Ok(ResourceLease { + id, + resource, + scopes: scopes.into_iter().collect(), + expires_at_unix, + }) + } + + pub fn renew_resource_lease( + &self, + id: &str, + ttl: Duration, + ) -> Result { + self.renew_resource_lease_at(id, ttl, SystemTime::now()) + } + + pub fn renew_resource_lease_at( + &self, + id: &str, + ttl: Duration, + now: SystemTime, + ) -> Result { + validate_ttl(ttl)?; + let now_unix = unix_seconds(now)?; + let expires_at_unix = now_unix + .checked_add(ttl.as_secs()) + .ok_or(ResourceRegistryError::InvalidTtl)?; + let mut inner = self.inner.write().expect("resource registry lock"); + prune_locked(&mut inner, now_unix); + let lease = inner + .leases + .get_mut(id) + .ok_or(ResourceRegistryError::LeaseNotFound)?; + lease.expires_at_unix = expires_at_unix; + Ok(public_lease(id, lease)) + } + + pub fn release_resource_lease(&self, id: &str) -> Result<(), ResourceRegistryError> { + let now = unix_seconds(SystemTime::now())?; + let mut inner = self.inner.write().expect("resource registry lock"); + prune_locked(&mut inner, now); + inner + .leases + .remove(id) + .map(|_| ()) + .ok_or(ResourceRegistryError::LeaseNotFound) + } + + pub fn prune_expired_resource_leases(&self, now: SystemTime) -> usize { + let Ok(now_unix) = unix_seconds(now) else { + return 0; + }; + let mut inner = self.inner.write().expect("resource registry lock"); + let before = inner.leases.len(); + prune_locked(&mut inner, now_unix); + before - inner.leases.len() + } + + #[must_use] + pub fn effective_resource_scopes(&self, resource: &str) -> Option> { + self.effective_resource_scopes_at(resource, SystemTime::now()) + } + + #[must_use] + pub fn effective_resource_scopes_at( + &self, + resource: &str, + now: SystemTime, + ) -> Option> { + let resource = canonical_resource(resource).ok()?; + let now_unix = unix_seconds(now).ok()?; + let mut inner = self.inner.write().expect("resource registry lock"); + prune_locked(&mut inner, now_unix); + let mut scopes = inner.configured.get(&resource).cloned().unwrap_or_default(); + for lease in inner + .leases + .values() + .filter(|lease| lease.resource == resource) + { + scopes.extend(lease.scopes.iter().cloned()); + } + (!scopes.is_empty()).then(|| scopes.into_iter().collect()) + } + + #[must_use] + pub fn lease_count(&self) -> usize { + let Ok(now) = unix_seconds(SystemTime::now()) else { + return 0; + }; + let mut inner = self.inner.write().expect("resource registry lock"); + prune_locked(&mut inner, now); + inner.leases.len() + } + + #[must_use] + pub fn lease_diagnostics(&self) -> Vec { + let Ok(now) = unix_seconds(SystemTime::now()) else { + return Vec::new(); + }; + let mut inner = self.inner.write().expect("resource registry lock"); + prune_locked(&mut inner, now); + inner + .leases + .values() + .map(|lease| ResourceLeaseDiagnostic { + resource: lease.resource.clone(), + scopes: lease.scopes.iter().cloned().collect(), + owner: lease.owner.clone(), + expires_at_unix: lease.expires_at_unix, + }) + .collect() + } +} + +impl Default for ResourceRegistry { + fn default() -> Self { + Self::new() + } +} + +fn public_lease(id: &str, lease: &StoredLease) -> ResourceLease { + ResourceLease { + id: id.to_string(), + resource: lease.resource.clone(), + scopes: lease.scopes.iter().cloned().collect(), + expires_at_unix: lease.expires_at_unix, + } +} + +fn canonical_resource(resource: &str) -> Result { + let trimmed = resource.trim(); + let canonical = trimmed.strip_suffix('/').unwrap_or(trimmed); + let parsed = Url::parse(canonical).map_err(|_| ResourceRegistryError::InvalidResource)?; + if parsed.scheme() != "https" + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(ResourceRegistryError::InvalidResource); + } + Ok(canonical.to_string()) +} + +fn validated_scopes( + scopes: impl IntoIterator>, +) -> Result, ResourceRegistryError> { + let scopes = scopes + .into_iter() + .map(|scope| scope.as_ref().trim().to_string()) + .collect::>(); + if scopes.is_empty() || scopes.iter().any(|scope| !valid_scope(scope)) { + return Err(ResourceRegistryError::InvalidScopes); + } + Ok(scopes) +} + +fn valid_scope(scope: &str) -> bool { + !scope.is_empty() + && scope.bytes().all(|byte| { + byte == 0x21 || (0x23..=0x5b).contains(&byte) || (0x5d..=0x7e).contains(&byte) + }) +} + +fn validate_ttl(ttl: Duration) -> Result<(), ResourceRegistryError> { + if ttl.is_zero() || ttl > MAX_RESOURCE_LEASE_TTL || ttl.subsec_nanos() != 0 { + return Err(ResourceRegistryError::InvalidTtl); + } + Ok(()) +} + +fn unix_seconds(now: SystemTime) -> Result { + now.duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|_| ResourceRegistryError::InvalidClock) +} + +fn prune_locked(inner: &mut ResourceRegistryInner, now_unix: u64) { + inner + .leases + .retain(|_, lease| lease.expires_at_unix > now_unix); +} + +fn random_lease_id() -> Result { + let mut bytes = [0_u8; LEASE_ID_BYTES]; + getrandom::fill(&mut bytes).map_err(|_| ResourceRegistryError::RandomnessUnavailable)?; + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) +} diff --git a/crates/labby-auth/src/state.rs b/crates/labby-auth/src/state.rs index aa0c02450..19e62160c 100644 --- a/crates/labby-auth/src/state.rs +++ b/crates/labby-auth/src/state.rs @@ -1,7 +1,5 @@ -use std::collections::{BTreeMap, BTreeSet}; use std::net::IpAddr; use std::sync::Arc; -use std::sync::RwLock; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; @@ -16,6 +14,7 @@ use crate::config::{AuthConfig, AuthMode}; use crate::error::AuthError; use crate::google::GoogleProvider; use crate::jwt::SigningKeys; +use crate::resource_registry::ResourceRegistry; use crate::sqlite::SqliteStore; #[cfg(feature = "http-axum")] use crate::types::RegisteredClient; @@ -181,7 +180,7 @@ pub struct AuthState { pub store: SqliteStore, pub signing_keys: Arc, pub google: Arc, - allowed_resource_scopes: Arc>>>, + resource_registry: ResourceRegistry, authorize_limiter: PerIpRateLimiter, register_limiter: PerIpRateLimiter, token_limiter: PerIpRateLimiter, @@ -201,6 +200,13 @@ pub struct AuthState { impl AuthState { pub async fn new(config: AuthConfig) -> Result { + Self::new_with_resource_registry(config, ResourceRegistry::new()).await + } + + pub async fn new_with_resource_registry( + config: AuthConfig, + resource_registry: ResourceRegistry, + ) -> Result { if !matches!(config.mode, AuthMode::OAuth) { return Err(AuthError::Config(format!( "AuthState requires {prefix}_AUTH_MODE=oauth", @@ -245,7 +251,7 @@ impl AuthState { store, signing_keys: Arc::new(signing_keys), google: Arc::new(google), - allowed_resource_scopes: Arc::new(RwLock::new(BTreeMap::new())), + resource_registry, authorize_limiter, register_limiter, token_limiter, @@ -278,42 +284,32 @@ impl AuthState { &self, resources: impl IntoIterator)>, ) { - let mut allowed = self - .allowed_resource_scopes - .write() - .expect("allowed resource scope lock"); - allowed.clear(); - for (resource, scopes) in resources { - let resource = resource.trim().trim_end_matches('/').to_string(); - if resource.is_empty() { - continue; - } - let scopes = scopes - .into_iter() - .map(|scope| scope.trim().to_string()) - .filter(|scope| !scope.is_empty()) - .collect::>(); - allowed.insert(resource, scopes); + if let Err(error) = self.replace_configured_resource_scopes(resources) { + debug!(%error, "ignored invalid configured OAuth protected resource scopes"); } - debug!( - resource_count = allowed.len(), - "oauth allowed protected resource scopes refreshed" - ); + } + + pub fn replace_configured_resource_scopes( + &self, + resources: impl IntoIterator)>, + ) -> Result<(), crate::resource_registry::ResourceRegistryError> { + self.resource_registry + .replace_configured_resource_scopes(resources) + } + + #[must_use] + pub fn resource_registry(&self) -> ResourceRegistry { + self.resource_registry.clone() } pub fn is_allowed_resource_url(&self, resource: &str) -> bool { - self.allowed_resource_scopes - .read() - .expect("allowed resource scope lock") - .contains_key(resource.trim().trim_end_matches('/')) + self.resource_registry + .effective_resource_scopes(resource) + .is_some() } pub fn allowed_resource_scopes(&self, resource: &str) -> Option> { - self.allowed_resource_scopes - .read() - .expect("allowed resource scope lock") - .get(resource.trim().trim_end_matches('/')) - .map(|scopes| scopes.iter().cloned().collect()) + self.resource_registry.effective_resource_scopes(resource) } /// Rate-limit guard for `/authorize` and `/browser_login` endpoints. @@ -417,7 +413,7 @@ impl AuthState { store, signing_keys: Arc::new(signing_keys), google: Arc::new(google), - allowed_resource_scopes: Arc::new(RwLock::new(BTreeMap::new())), + resource_registry: ResourceRegistry::new(), authorize_limiter, register_limiter, token_limiter, @@ -459,6 +455,23 @@ mod tests { use crate::config::GoogleConfig; use crate::util::now_unix; + #[tokio::test] + async fn cloned_auth_states_share_resource_registry() { + let state = resolve_state("admin@example.com").await; + let clone = state.clone(); + state + .resource_registry() + .create_resource_lease( + "https://proxy.example:53147/mcp", + ["mcp:read"], + Duration::from_mins(1), + "clone-test", + ) + .unwrap(); + + assert_eq!(clone.resource_registry().lease_count(), 1); + } + #[tokio::test] async fn per_ip_rate_limiter_evicts_idle_and_lru_buckets_under_address_churn() { let limiter = PerIpRateLimiter::new_with_limits(60, 3, 10); diff --git a/crates/labby-auth/tests/resource_registry.rs b/crates/labby-auth/tests/resource_registry.rs new file mode 100644 index 000000000..05ac694f1 --- /dev/null +++ b/crates/labby-auth/tests/resource_registry.rs @@ -0,0 +1,186 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use labby_auth::resource_registry::{ResourceRegistry, ResourceRegistryError}; + +fn unix_time(seconds: u64) -> SystemTime { + UNIX_EPOCH + Duration::from_secs(seconds) +} + +#[test] +fn configured_resource_survives_lease_lifecycle_and_pruning() { + let registry = ResourceRegistry::new(); + registry + .replace_configured_resource_scopes([( + "https://configured.example:8443/mcp/".to_string(), + vec!["mcp:read".to_string()], + )]) + .unwrap(); + + let lease = registry + .create_resource_lease_at( + "https://leased.example:53147/mcp", + ["mcp:read", "mcp:write"], + Duration::from_mins(2), + "proxy-run-1", + unix_time(4_000_000_000), + ) + .unwrap(); + registry + .renew_resource_lease_at(&lease.id, Duration::from_mins(4), unix_time(4_000_000_030)) + .unwrap(); + registry.release_resource_lease(&lease.id).unwrap(); + registry.prune_expired_resource_leases(unix_time(2_000)); + + assert_eq!( + registry.effective_resource_scopes("https://configured.example:8443/mcp/"), + Some(vec!["mcp:read".to_string()]) + ); +} + +#[test] +fn lease_survives_configured_resource_replacement() { + let registry = ResourceRegistry::new(); + let lease = registry + .create_resource_lease_at( + "https://proxy.example:53147/mcp", + ["mcp:read"], + Duration::from_mins(2), + "proxy-run-1", + unix_time(4_000_000_000), + ) + .unwrap(); + + registry + .replace_configured_resource_scopes([( + "https://configured.example/mcp".to_string(), + vec!["lab:admin".to_string()], + )]) + .unwrap(); + + assert_eq!( + registry.effective_resource_scopes_at( + "https://proxy.example:53147/mcp", + unix_time(4_000_000_001) + ), + Some(vec!["mcp:read".to_string()]) + ); + assert_eq!(registry.lease_count(), 1); + assert_eq!(registry.lease_diagnostics().len(), 1); + assert!(!format!("{:?}", registry.lease_diagnostics()).contains(&lease.id)); +} + +#[test] +fn expiry_renewal_release_and_random_ids_are_enforced() { + let registry = ResourceRegistry::new(); + let first = registry + .create_resource_lease_at( + "https://proxy.example:53147/mcp", + ["mcp:read"], + Duration::from_secs(10), + "proxy-run-1", + unix_time(1_000), + ) + .unwrap(); + let second = registry + .create_resource_lease_at( + "https://proxy.example:53148/mcp", + ["mcp:read"], + Duration::from_secs(10), + "proxy-run-2", + unix_time(1_000), + ) + .unwrap(); + assert_ne!(first.id, second.id); + assert!(!format!("{first:?}").contains(&first.id)); + assert_eq!(first.expires_at_unix, 1_010); + + let renewed = registry + .renew_resource_lease_at(&first.id, Duration::from_secs(30), unix_time(1_005)) + .unwrap(); + assert_eq!(renewed.expires_at_unix, 1_035); + assert!( + registry + .effective_resource_scopes_at(&first.resource, unix_time(1_034)) + .is_some() + ); + assert!( + registry + .effective_resource_scopes_at(&first.resource, unix_time(1_035)) + .is_none() + ); + assert!(matches!( + registry.renew_resource_lease_at(&first.id, Duration::from_secs(10), unix_time(1_035)), + Err(ResourceRegistryError::LeaseNotFound) + )); + assert!(matches!( + registry.release_resource_lease(&first.id), + Err(ResourceRegistryError::LeaseNotFound) + )); +} + +#[test] +fn invalid_resource_scope_ttl_and_owner_are_rejected() { + let registry = ResourceRegistry::new(); + let cases = [ + ("", vec!["mcp:read"], 10, "owner"), + ("http://proxy.example/mcp", vec!["mcp:read"], 10, "owner"), + ("relative/mcp", vec!["mcp:read"], 10, "owner"), + ( + "https://proxy.example/mcp?x=1", + vec!["mcp:read"], + 10, + "owner", + ), + ("https://proxy.example/mcp", vec![], 10, "owner"), + ("https://proxy.example/mcp", vec!["bad scope"], 10, "owner"), + ("https://proxy.example/mcp", vec!["mcp:read"], 0, "owner"), + ( + "https://proxy.example/mcp", + vec!["mcp:read"], + 86_401, + "owner", + ), + ("https://proxy.example/mcp", vec!["mcp:read"], 10, ""), + ]; + + for (resource, scopes, ttl_secs, owner) in cases { + assert!( + registry + .create_resource_lease_at( + resource, + scopes, + Duration::from_secs(ttl_secs), + owner, + unix_time(1_000), + ) + .is_err(), + "case should be invalid: {resource:?} {owner:?}" + ); + } +} + +#[test] +fn canonicalization_preserves_exact_port_and_path_and_removes_one_slash() { + let registry = ResourceRegistry::new(); + registry + .replace_configured_resource_scopes([( + " https://proxy.example:53147/mcp/ ".to_string(), + vec![" mcp:read ".to_string()], + )]) + .unwrap(); + + assert_eq!( + registry.effective_resource_scopes("https://proxy.example:53147/mcp/"), + Some(vec!["mcp:read".to_string()]) + ); + assert!( + registry + .effective_resource_scopes("https://proxy.example:53148/mcp/") + .is_none() + ); + assert!( + registry + .effective_resource_scopes("https://proxy.example:53147/other/") + .is_none() + ); +} diff --git a/crates/labby-gateway/src/gateway/catalog.rs b/crates/labby-gateway/src/gateway/catalog.rs index 39d0588d2..61a147bbf 100644 --- a/crates/labby-gateway/src/gateway/catalog.rs +++ b/crates/labby-gateway/src/gateway/catalog.rs @@ -825,6 +825,73 @@ pub const ACTIONS: &[ActionSpec] = &[ description: "Upstream server name (as listed by gateway.servers).", }], }, + ActionSpec { + name: "gateway.oauth.resource_lease.create", + description: "Create an in-memory OAuth protected-resource lease", + destructive: false, + requires_admin: true, + returns: "ResourceLease", + params: &[ + ParamSpec { + name: "resource", + ty: "string", + required: true, + description: "Exact absolute HTTPS OAuth resource audience", + }, + ParamSpec { + name: "scopes", + ty: "string[]", + required: true, + description: "Scopes accepted for the resource", + }, + ParamSpec { + name: "ttl_secs", + ty: "integer", + required: true, + description: "Lease lifetime in seconds (1 through 86400)", + }, + ParamSpec { + name: "owner", + ty: "string", + required: true, + description: "Bounded diagnostic owner label", + }, + ], + }, + ActionSpec { + name: "gateway.oauth.resource_lease.renew", + description: "Renew an active in-memory OAuth protected-resource lease", + destructive: false, + requires_admin: true, + returns: "ResourceLease", + params: &[ + ParamSpec { + name: "id", + ty: "string", + required: true, + description: "Opaque resource lease identifier", + }, + ParamSpec { + name: "ttl_secs", + ty: "integer", + required: true, + description: "Replacement lease lifetime in seconds", + }, + ], + }, + ActionSpec { + name: "gateway.oauth.resource_lease.release", + description: "Release an active in-memory OAuth protected-resource lease", + destructive: true, + requires_admin: true, + returns: "ResourceLeaseReleaseView", + params: &[ParamSpec { + name: "id", + ty: "string", + required: true, + description: "Opaque resource lease identifier", + }], + }, ActionSpec { name: "gateway.oauth.probe", description: "Probe a URL for OAuth support via RFC 8414 AS metadata discovery. \ @@ -1032,6 +1099,7 @@ mod tests { "gateway.import_tombstones.restore", "gateway.remove", "gateway.mcp.cleanup", + "gateway.oauth.resource_lease.release", ]); let actual = ACTIONS .iter() diff --git a/crates/labby-gateway/src/gateway/dispatch.rs b/crates/labby-gateway/src/gateway/dispatch.rs index a7656e7fa..282bf66f9 100644 --- a/crates/labby-gateway/src/gateway/dispatch.rs +++ b/crates/labby-gateway/src/gateway/dispatch.rs @@ -15,7 +15,8 @@ use super::params::{ GatewayMcpToggleParams, GatewayNameParams, GatewayOauthNameParams, GatewayReloadParams, GatewayStatusParams, GatewayTestParams, GatewayUpdateParams, GatewayUpdatePatch, GatewayUsageCallsParams, GatewayUsageMetricsParams, ProtectedRouteNameParams, - ProtectedRouteSpecParams, ProtectedRouteUpdateParams, ServiceConfigGetParams, + ProtectedRouteSpecParams, ProtectedRouteUpdateParams, ResourceLeaseCreateParams, + ResourceLeaseReleaseParams, ResourceLeaseRenewParams, ServiceConfigGetParams, ServiceConfigSetParams, VirtualServerMcpPolicyParams, VirtualServerNameParams, VirtualServerSurfaceParams, }; @@ -699,6 +700,34 @@ async fn handle_oauth_actions( params_value: Value, ) -> Result { match action { + "gateway.oauth.resource_lease.create" => { + let params: ResourceLeaseCreateParams = parse_params(params_value)?; + let registry = require_resource_registry(manager)?; + let lease = registry + .create_resource_lease( + ¶ms.resource, + params.scopes, + std::time::Duration::from_secs(params.ttl_secs), + ¶ms.owner, + ) + .map_err(resource_registry_error)?; + to_json(lease) + } + "gateway.oauth.resource_lease.renew" => { + let params: ResourceLeaseRenewParams = parse_params(params_value)?; + let registry = require_resource_registry(manager)?; + let lease = registry + .renew_resource_lease(¶ms.id, std::time::Duration::from_secs(params.ttl_secs)) + .map_err(resource_registry_error)?; + to_json(lease) + } + "gateway.oauth.resource_lease.release" => { + let params: ResourceLeaseReleaseParams = parse_params(params_value)?; + require_resource_registry(manager)? + .release_resource_lease(¶ms.id) + .map_err(resource_registry_error)?; + to_json(super::types::ResourceLeaseReleaseView { released: true }) + } "gateway.oauth.probe" => { let url = require_str(¶ms_value, "url")?; to_json(crate::gateway::oauth::probe(manager, url).await?) @@ -757,6 +786,38 @@ async fn handle_oauth_actions( } } +fn require_resource_registry( + manager: &GatewayManager, +) -> Result { + manager.resource_registry().ok_or_else(|| ToolError::Sdk { + sdk_kind: "auth_failed".to_string(), + message: "OAuth resource leases are unavailable because daemon OAuth is not configured" + .to_string(), + }) +} + +fn resource_registry_error( + error: labby_auth::resource_registry::ResourceRegistryError, +) -> ToolError { + use labby_auth::resource_registry::ResourceRegistryError; + match error { + ResourceRegistryError::LeaseNotFound => ToolError::Sdk { + sdk_kind: "not_found".to_string(), + message: error.to_string(), + }, + ResourceRegistryError::InvalidResource + | ResourceRegistryError::InvalidScopes + | ResourceRegistryError::InvalidTtl + | ResourceRegistryError::InvalidOwner => ToolError::InvalidParam { + message: error.to_string(), + param: "params".to_string(), + }, + ResourceRegistryError::RandomnessUnavailable | ResourceRegistryError::InvalidClock => { + ToolError::internal_message(error.to_string()) + } + } +} + async fn handle_mcp_actions( manager: &GatewayManager, action: &str, diff --git a/crates/labby-gateway/src/gateway/dispatch_tests.rs b/crates/labby-gateway/src/gateway/dispatch_tests.rs index 7b836c64d..caffbd786 100644 --- a/crates/labby-gateway/src/gateway/dispatch_tests.rs +++ b/crates/labby-gateway/src/gateway/dispatch_tests.rs @@ -50,6 +50,9 @@ fn gateway_actions_include_management_surface() { assert!(names.contains(&"gateway.oauth.start")); assert!(names.contains(&"gateway.oauth.status")); assert!(names.contains(&"gateway.oauth.clear")); + assert!(names.contains(&"gateway.oauth.resource_lease.create")); + assert!(names.contains(&"gateway.oauth.resource_lease.renew")); + assert!(names.contains(&"gateway.oauth.resource_lease.release")); assert!(names.contains(&"gateway.mcp.enable")); assert!(names.contains(&"gateway.mcp.disable")); assert!(names.contains(&"gateway.mcp.cleanup")); @@ -68,6 +71,7 @@ fn gateway_actions_include_management_surface() { | "gateway.import_tombstones.restore" | "gateway.remove" | "gateway.mcp.cleanup" + | "gateway.oauth.resource_lease.release" ) { continue; } @@ -79,6 +83,115 @@ fn gateway_actions_include_management_surface() { } } +#[test] +fn resource_lease_actions_require_admin_scope() { + for name in [ + "gateway.oauth.resource_lease.create", + "gateway.oauth.resource_lease.renew", + "gateway.oauth.resource_lease.release", + ] { + let spec = ACTIONS.iter().find(|spec| spec.name == name).unwrap(); + assert!(spec.requires_admin, "{name} must require lab:admin"); + } +} + +#[tokio::test] +async fn resource_lease_actions_dispatch_typed_round_trip() { + let registry = labby_auth::resource_registry::ResourceRegistry::new(); + let manager = test_manager().with_resource_registry(registry.clone()); + let created = dispatch_with_manager( + &manager, + "gateway.oauth.resource_lease.create", + json!({ + "resource": "https://proxy.example:53147/mcp", + "scopes": ["mcp:read", "mcp:write"], + "ttl_secs": 120, + "owner": "live-gateway-test" + }), + ) + .await + .unwrap(); + let lease: labby_auth::resource_registry::ResourceLease = + serde_json::from_value(created).unwrap(); + assert_eq!(lease.resource, "https://proxy.example:53147/mcp"); + assert_eq!(lease.scopes, vec!["mcp:read", "mcp:write"]); + + let renewed = dispatch_with_manager( + &manager, + "gateway.oauth.resource_lease.renew", + json!({"id": lease.id, "ttl_secs": 240}), + ) + .await + .unwrap(); + let renewed: labby_auth::resource_registry::ResourceLease = + serde_json::from_value(renewed).unwrap(); + assert!(renewed.expires_at_unix > lease.expires_at_unix); + + let released = dispatch_with_manager( + &manager, + "gateway.oauth.resource_lease.release", + json!({"id": renewed.id}), + ) + .await + .unwrap(); + let released: super::super::types::ResourceLeaseReleaseView = + serde_json::from_value(released).unwrap(); + assert!(released.released); + assert_eq!(registry.lease_count(), 0); +} + +#[tokio::test] +async fn resource_lease_unknown_and_released_ids_fail_clearly() { + let manager = test_manager() + .with_resource_registry(labby_auth::resource_registry::ResourceRegistry::new()); + for action in [ + "gateway.oauth.resource_lease.renew", + "gateway.oauth.resource_lease.release", + ] { + let error = dispatch_with_manager( + &manager, + action, + json!({"id": "unknown-opaque-id", "ttl_secs": 60}), + ) + .await + .unwrap_err(); + assert_eq!(error.kind(), "not_found"); + } +} + +#[test] +fn manager_clones_share_registry_and_restart_does_not_restore_leases() { + let registry = labby_auth::resource_registry::ResourceRegistry::new(); + let manager = test_manager().with_resource_registry(registry.clone()); + let clone = manager.clone(); + manager + .resource_registry() + .unwrap() + .create_resource_lease( + "https://proxy.example:53147/mcp", + ["mcp:read"], + std::time::Duration::from_mins(1), + "restart-test", + ) + .unwrap(); + assert_eq!(clone.resource_registry().unwrap().lease_count(), 1); + + let restarted = test_manager() + .with_resource_registry(labby_auth::resource_registry::ResourceRegistry::new()); + assert_eq!(restarted.resource_registry().unwrap().lease_count(), 0); + restarted + .resource_registry() + .unwrap() + .create_resource_lease( + "https://proxy.example:53147/mcp", + ["mcp:read"], + std::time::Duration::from_mins(1), + "restart-test", + ) + .unwrap(); + assert_eq!(restarted.resource_registry().unwrap().lease_count(), 1); +} + #[test] fn import_mutations_are_destructive() { for name in [ diff --git a/crates/labby-gateway/src/gateway/manager.rs b/crates/labby-gateway/src/gateway/manager.rs index b7d01deac..285beecf8 100644 --- a/crates/labby-gateway/src/gateway/manager.rs +++ b/crates/labby-gateway/src/gateway/manager.rs @@ -92,6 +92,7 @@ pub struct GatewayManager { pub(super) oauth_sqlite: Option, pub(super) oauth_key: Option, pub(super) oauth_redirect_uri: Option>, + pub(super) resource_registry: Option, pub(super) usage_store: Option>, /// Durable append-only journal for `codemode.step` boundaries. `None` /// disables journaling (pure no-op path). Owned as an `Arc` so every `Clone` diff --git a/crates/labby-gateway/src/gateway/manager/core.rs b/crates/labby-gateway/src/gateway/manager/core.rs index d894792ba..5f5053113 100644 --- a/crates/labby-gateway/src/gateway/manager/core.rs +++ b/crates/labby-gateway/src/gateway/manager/core.rs @@ -45,6 +45,7 @@ pub struct GatewayManagerConfig { pub in_process_connector: Option, /// Optional upstream OAuth runtime. `None` when OAuth is not configured. pub oauth: Option, + pub resource_registry: Option, /// Optional call-usage recorder, shared with every `UpstreamPool` the /// manager builds. `None` disables telemetry capture. pub usage_store: Option>, @@ -80,6 +81,9 @@ impl GatewayManager { .with_oauth_client_cache(oauth.cache) .with_oauth_resources(oauth.sqlite, oauth.key, oauth.redirect_uri); } + if let Some(registry) = cfg.resource_registry { + manager = manager.with_resource_registry(registry); + } if let Some(store) = cfg.usage_store { manager = manager.with_usage_store(store); } @@ -130,6 +134,7 @@ impl GatewayManager { oauth_sqlite: None, oauth_key: None, oauth_redirect_uri: None, + resource_registry: None, usage_store: None, step_journal: None, step_buffers: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())), @@ -260,6 +265,20 @@ impl GatewayManager { self } + #[must_use] + pub fn with_resource_registry( + mut self, + registry: labby_auth::resource_registry::ResourceRegistry, + ) -> Self { + self.resource_registry = Some(registry); + self + } + + #[must_use] + pub fn resource_registry(&self) -> Option { + self.resource_registry.clone() + } + #[must_use] pub fn with_upstream_oauth_managers( mut self, diff --git a/crates/labby-gateway/src/gateway/params.rs b/crates/labby-gateway/src/gateway/params.rs index b79049ba2..65e95a47e 100644 --- a/crates/labby-gateway/src/gateway/params.rs +++ b/crates/labby-gateway/src/gateway/params.rs @@ -330,6 +330,25 @@ pub(crate) struct GatewayOauthNameParams { pub subject: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ResourceLeaseCreateParams { + pub resource: String, + pub scopes: Vec, + pub ttl_secs: u64, + pub owner: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ResourceLeaseRenewParams { + pub id: String, + pub ttl_secs: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ResourceLeaseReleaseParams { + pub id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct CodeModeSetParams { #[serde(default)] diff --git a/crates/labby-gateway/src/gateway/types.rs b/crates/labby-gateway/src/gateway/types.rs index 5347f24fd..96d685305 100644 --- a/crates/labby-gateway/src/gateway/types.rs +++ b/crates/labby-gateway/src/gateway/types.rs @@ -3,6 +3,11 @@ use tokio::sync::mpsc; use labby_runtime::gateway_config::ImportSource; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ResourceLeaseReleaseView { + pub released: bool, +} + /// Surface-neutral notification handle for catalog changes. /// /// The dispatch layer calls this after gateway reload/add/remove to inform diff --git a/crates/labby-gateway/src/upstream/pool/connect_tests.rs b/crates/labby-gateway/src/upstream/pool/connect_tests.rs index 8d82d4ba7..1dbc46302 100644 --- a/crates/labby-gateway/src/upstream/pool/connect_tests.rs +++ b/crates/labby-gateway/src/upstream/pool/connect_tests.rs @@ -146,15 +146,7 @@ impl Respond for MisclassifiedDiscoveryResponder { "jsonrpc": "2.0", "id": id, "result": { - "resultType": "complete", - "supportedVersions": [ - "2026-07-28", - "2025-11-25", - "2025-06-18" - ], - "capabilities": {"tools": {}}, - "ttlMs": 0, - "cacheScope": "private", + "tools": [], "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "metadata-discovery-test", diff --git a/crates/labby/src/api/router.rs b/crates/labby/src/api/router.rs index 50cc060b7..70becaf9c 100644 --- a/crates/labby/src/api/router.rs +++ b/crates/labby/src/api/router.rs @@ -71,12 +71,16 @@ async fn app_auth_state_with_protected_routes( route_count = routes.iter().filter(|route| route.enabled).count(), "oauth protected resource scope map refreshed from gateway routes" ); - auth_state.set_allowed_resource_scopes( - routes - .into_iter() - .filter(|route| route.enabled) - .map(|route| (route.public_resource(), route.scopes)), - ); + auth_state + .replace_configured_resource_scopes( + routes + .into_iter() + .filter(|route| route.enabled) + .map(|route| (route.public_resource(), route.scopes)), + ) + .map_err(|error| { + LabAuthError::Config(format!("invalid configured protected resource: {error}")) + })?; } Ok(auth_state) } @@ -1272,14 +1276,16 @@ pub(crate) fn build_router_with_external_auth( state = state.with_oauth_state(auth_state.clone()); } if let Some(auth_state) = auth_state.as_ref() { - auth_state.set_allowed_resource_scopes( + if let Err(error) = auth_state.replace_configured_resource_scopes( state .config .protected_mcp_routes .iter() .filter(|route| route.enabled) .map(|route| (route.public_resource(), route.scopes.clone())), - ); + ) { + tracing::error!(%error, "invalid configured OAuth protected resource route"); + } } let static_token = bearer_token.map(Arc::::from); state = state.with_bearer_token(static_token.clone()); diff --git a/crates/labby/src/api/services/gateway.rs b/crates/labby/src/api/services/gateway.rs index 9d028a978..44100d52c 100644 --- a/crates/labby/src/api/services/gateway.rs +++ b/crates/labby/src/api/services/gateway.rs @@ -455,6 +455,41 @@ mod tests { } } + #[tokio::test] + async fn resource_lease_api_returns_typed_document_for_admin() { + let registry = labby_auth::resource_registry::ResourceRegistry::new(); + let manager = Arc::new( + test_gateway_manager( + std::env::temp_dir().join("labby-resource-lease-api.toml"), + GatewayRuntimeHandle::default(), + ) + .with_resource_registry(registry), + ); + let app = gateway_routes_with_auth_context(manager, admin_auth_context()); + let response = post_gateway_routes( + app, + json!({ + "action": "gateway.oauth.resource_lease.create", + "params": { + "resource": "https://proxy.example:53147/mcp", + "scopes": ["mcp:read"], + "ttl_secs": 120, + "owner": "api-test" + } + }), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 16 * 1024) + .await + .unwrap(); + let lease: labby_auth::resource_registry::ResourceLease = + serde_json::from_slice(&body).unwrap(); + assert_eq!(lease.resource, "https://proxy.example:53147/mcp"); + assert_eq!(lease.scopes, vec!["mcp:read"]); + assert!(!lease.id.is_empty()); + } + /// T5 (MCP surface): every gateway action that has requires_admin=true is /// correctly identified by `builtin_action_requires_admin` in mcp/context.rs. #[test] diff --git a/crates/labby/src/cli/gateway.rs b/crates/labby/src/cli/gateway.rs index bf212ac39..e23ad804c 100644 --- a/crates/labby/src/cli/gateway.rs +++ b/crates/labby/src/cli/gateway.rs @@ -100,6 +100,7 @@ async fn build_manager_with_upstream_oauth_runtime( key: rt.key, redirect_uri: rt.redirect_uri, }), + resource_registry: None, usage_store: usage_store.clone(), }, runtime, diff --git a/crates/labby/src/cli/serve.rs b/crates/labby/src/cli/serve.rs index 9087f542e..4078ef905 100644 --- a/crates/labby/src/cli/serve.rs +++ b/crates/labby/src/cli/serve.rs @@ -227,6 +227,8 @@ pub async fn run(args: ServeArgs, config: &LabConfig) -> Result { let mut bearer_token = http_token(); let auth_config = resolve_auth_for_config(&config).context("invalid HTTP auth configuration")?; + let resource_registry = matches!(auth_config.mode, AuthMode::OAuth) + .then(labby_auth::resource_registry::ResourceRegistry::new); // SECURITY: Only log metadata — never resolved secret values. // Safe fields: enum names, booleans, counts. Forbidden: URL strings, token values, key material. tracing::info!( @@ -256,6 +258,7 @@ pub async fn run(args: ServeArgs, config: &LabConfig) -> Result { suppress_upstream_runtime, registry.clone(), notifier.clone(), + resource_registry.clone(), ) .await?; #[cfg(not(feature = "gateway"))] @@ -398,9 +401,14 @@ pub async fn run(args: ServeArgs, config: &LabConfig) -> Result { let oauth_state = if matches!(auth_config.mode, AuthMode::OAuth) { Some( - labby_auth::state::AuthState::new(auth_config.clone()) - .await - .context("initialize lab-auth oauth state")?, + labby_auth::state::AuthState::new_with_resource_registry( + auth_config.clone(), + resource_registry + .clone() + .expect("OAuth mode initializes a resource registry"), + ) + .await + .context("initialize lab-auth oauth state")?, ) } else { None @@ -841,6 +849,9 @@ async fn run_http( let web_assets_enabled = state.web_assets_enabled(); let bearer_token_configured = bearer_token.is_some(); + let resource_registry = auth_state + .as_ref() + .map(labby_auth::state::AuthState::resource_registry); tracing::info!( subsystem = "api_server", phase = "router.build.start", @@ -887,23 +898,49 @@ async fn run_http( #[cfg(unix)] peer_auth_enabled, }; - match transport { - Transport::Http => { - serve_tcp_listener(host, port, router, listener_status).await?; - } - Transport::UnixSocket => { - let unix_config = unix_listener_config.ok_or_else(|| { - anyhow::anyhow!("unix_socket transport resolved without listener configuration") - })?; - serve_unix_listener(unix_config, router, listener_status).await?; + let hosted_listener = async move { + match transport { + Transport::Http => serve_tcp_listener(host, port, router, listener_status).await, + Transport::UnixSocket => { + let unix_config = unix_listener_config.ok_or_else(|| { + anyhow::anyhow!("unix_socket transport resolved without listener configuration") + })?; + serve_unix_listener(unix_config, router, listener_status).await + } + Transport::Stdio => { + anyhow::bail!("stdio transport reached hosted listener startup unexpectedly") + } } - Transport::Stdio => { - anyhow::bail!("stdio transport reached hosted listener startup unexpectedly"); + }; + if let Some(registry) = resource_registry { + tokio::select! { + result = hosted_listener => result?, + never = prune_resource_leases(registry) => match never {}, } + } else { + hosted_listener.await?; } Ok(ExitCode::SUCCESS) } +async fn prune_resource_leases( + registry: labby_auth::resource_registry::ResourceRegistry, +) -> std::convert::Infallible { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + let pruned = registry.prune_expired_resource_leases(std::time::SystemTime::now()); + if pruned > 0 { + tracing::debug!( + resource_lease_count = registry.lease_count(), + pruned_resource_lease_count = pruned, + "expired OAuth resource leases pruned" + ); + } + } +} + #[derive(Debug, Clone, Copy)] struct HostedListenerStatus { web_assets_enabled: bool, @@ -1245,6 +1282,7 @@ async fn build_gateway_runtime( suppress_upstream_runtime: bool, registry: ToolRegistry, notifier: PeerNotifier, + resource_registry: Option, ) -> Result> { let gateway_runtime = GatewayRuntimeHandle::default(); let upstream_oauth_runtime = if suppress_upstream_runtime { @@ -1360,6 +1398,7 @@ async fn build_gateway_runtime( key: rt.key, redirect_uri: rt.redirect_uri, }), + resource_registry, usage_store: usage_store.clone(), }, gateway_runtime, diff --git a/crates/labby/src/live_gateway.rs b/crates/labby/src/live_gateway.rs index 61dc4f6e4..ab87549dd 100644 --- a/crates/labby/src/live_gateway.rs +++ b/crates/labby/src/live_gateway.rs @@ -182,6 +182,74 @@ fn actions_include_gateway_reload(actions: &Value) -> bool { } impl LiveGateway { + pub async fn supports_action(&self, action: &str) -> Result { + let mut request = self + .client + .get(format!("{}/v1/gateway/actions", self.base_url)); + if let Some(token) = &self.token { + request = request.bearer_auth(token); + } + let response = request.send().await.map_err(live_gateway_network_error)?; + let status = response.status(); + let body: Value = response.json().await.unwrap_or(Value::Null); + if !status.is_success() { + return Err(tool_error_from_response(status, &body)); + } + Ok(body.as_array().is_some_and(|actions| { + actions + .iter() + .any(|candidate| candidate.get("name").and_then(Value::as_str) == Some(action)) + })) + } + + pub async fn create_resource_lease( + &self, + resource: &str, + scopes: Vec, + ttl: Duration, + owner: &str, + ) -> Result { + let value = self + .dispatch_action( + "gateway.oauth.resource_lease.create", + serde_json::json!({ + "resource": resource, + "scopes": scopes, + "ttl_secs": ttl.as_secs(), + "owner": owner, + }), + ) + .await?; + serde_json::from_value(value).map_err(typed_response_error) + } + + pub async fn renew_resource_lease( + &self, + id: &str, + ttl: Duration, + ) -> Result { + let value = self + .dispatch_action( + "gateway.oauth.resource_lease.renew", + serde_json::json!({"id": id, "ttl_secs": ttl.as_secs()}), + ) + .await?; + serde_json::from_value(value).map_err(typed_response_error) + } + + pub async fn release_resource_lease( + &self, + id: &str, + ) -> Result { + let value = self + .dispatch_action( + "gateway.oauth.resource_lease.release", + serde_json::json!({"id": id}), + ) + .await?; + serde_json::from_value(value).map_err(typed_response_error) + } + /// Dispatch `action`/`params` through the daemon's generic gateway /// action route (`POST /v1/gateway`) -- the same `{action, params}` /// shape MCP and the CLI's own local dispatch already use, so this @@ -287,9 +355,39 @@ impl LiveGateway { } } +fn live_gateway_network_error(error: reqwest::Error) -> ToolError { + ToolError::Sdk { + sdk_kind: "network_error".to_string(), + message: format!("request to live gateway daemon failed: {error}"), + } +} + +fn typed_response_error(error: serde_json::Error) -> ToolError { + ToolError::Sdk { + sdk_kind: "decode_error".to_string(), + message: format!("invalid typed response from live gateway daemon: {error}"), + } +} + +fn tool_error_from_response(status: reqwest::StatusCode, body: &Value) -> ToolError { + ToolError::Sdk { + sdk_kind: body + .get("kind") + .and_then(Value::as_str) + .unwrap_or("internal_error") + .to_string(), + message: body + .get("message") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| format!("live gateway daemon returned HTTP {status}")), + } +} + #[cfg(test)] mod tests { use super::*; + use serde_json::json; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -550,4 +648,90 @@ mod tests { .expect("should fall through to public url"); assert_eq!(live.base_url, server.uri()); } + + #[tokio::test] + async fn typed_resource_lease_methods_use_generic_gateway_actions() { + let server = MockServer::start().await; + let lease = serde_json::json!({ + "id": "opaque-lease-id", + "resource": "https://proxy.example:53147/mcp", + "scopes": ["mcp:read", "mcp:write"], + "expires_at_unix": 4_000_000_000_u64 + }); + Mock::given(method("POST")) + .and(path("/v1/gateway")) + .and(wiremock::matchers::body_json(json!({ + "action": "gateway.oauth.resource_lease.create", + "params": { + "resource": "https://proxy.example:53147/mcp", + "scopes": ["mcp:read", "mcp:write"], + "ttl_secs": 120, + "owner": "proxy-test" + } + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(&lease)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/gateway")) + .and(wiremock::matchers::body_json(json!({ + "action": "gateway.oauth.resource_lease.renew", + "params": {"id": "opaque-lease-id", "ttl_secs": 240} + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(&lease)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/gateway")) + .and(wiremock::matchers::body_json(json!({ + "action": "gateway.oauth.resource_lease.release", + "params": {"id": "opaque-lease-id"} + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"released": true}))) + .mount(&server) + .await; + + let gateway = test_gateway(server.uri(), None); + let created = gateway + .create_resource_lease( + "https://proxy.example:53147/mcp", + vec!["mcp:read".to_string(), "mcp:write".to_string()], + Duration::from_mins(2), + "proxy-test", + ) + .await + .unwrap(); + assert_eq!(created.id, "opaque-lease-id"); + gateway + .renew_resource_lease(&created.id, Duration::from_mins(4)) + .await + .unwrap(); + gateway.release_resource_lease(&created.id).await.unwrap(); + } + + #[tokio::test] + async fn resource_lease_action_support_detection_reads_catalog() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/gateway/actions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + {"name": "gateway.reload"}, + {"name": "gateway.oauth.resource_lease.create"} + ]))) + .mount(&server) + .await; + let gateway = test_gateway(server.uri(), None); + assert!( + gateway + .supports_action("gateway.oauth.resource_lease.create") + .await + .unwrap() + ); + assert!( + !gateway + .supports_action("gateway.oauth.resource_lease.release") + .await + .unwrap() + ); + } } From da16c9d81eb1c6d476439fa222d77cdb4b625016 Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Sat, 1 Aug 2026 12:00:47 -0400 Subject: [PATCH 6/8] feat(proxy): supervise OAuth lifecycle and rollback --- crates/labby-auth/src/jwt.rs | 5 + crates/labby-auth/src/middleware.rs | 5 + crates/labby-auth/src/token.rs | 1 + crates/labby-auth/tests/resource_registry.rs | 39 ++ crates/labby/src/api/router.rs | 18 +- crates/labby/src/cli/proxy.rs | 277 ++++++++-- crates/labby/src/live_gateway.rs | 256 +++++++++ crates/labby/src/proxy.rs | 2 + crates/labby/src/proxy/oauth.rs | 290 ++++++++++ crates/labby/src/proxy/runtime.rs | 369 ++++++++++--- crates/labby/src/proxy/tailscale.rs | 97 ++++ crates/labby/tests/auth_admin_api.rs | 1 + crates/labby/tests/stdio_proxy_runtime.rs | 537 ++++++++++++++++++- crates/labby/tests/tailscale_serve.rs | 50 +- docs/contracts/stdio-mcp-proxy.md | 7 +- docs/specs/stdio-mcp-proxy.md | 3 + 16 files changed, 1840 insertions(+), 117 deletions(-) create mode 100644 crates/labby/src/proxy/oauth.rs diff --git a/crates/labby-auth/src/jwt.rs b/crates/labby-auth/src/jwt.rs index a08871783..876ffa2c6 100644 --- a/crates/labby-auth/src/jwt.rs +++ b/crates/labby-auth/src/jwt.rs @@ -20,6 +20,8 @@ pub struct AccessClaims { pub sub: String, pub aud: String, pub exp: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nbf: Option, pub iat: usize, pub jti: String, pub scope: String, @@ -128,6 +130,7 @@ impl SigningKeys { ) -> Result { let mut validation = Validation::new(Algorithm::EdDSA); validation.set_audience(&[expected_audience]); + validation.validate_nbf = true; decode::(token, &self.decoding_key, &validation) .map(|data| data.claims) .map_err(|_| AuthError::InvalidAccessToken) @@ -146,6 +149,7 @@ impl SigningKeys { let mut validation = Validation::new(Algorithm::EdDSA); validation.set_audience(&[expected_audience]); validation.set_issuer(&[expected_issuer]); + validation.validate_nbf = true; decode::(token, &self.decoding_key, &validation) .map(|data| data.claims) .map_err(|_| AuthError::InvalidAccessToken) @@ -302,6 +306,7 @@ mod tests { sub: "google-user".to_string(), aud: "https://lab.example.com".to_string(), exp: 4_102_444_800, + nbf: None, iat: 1_700_000_000, jti: "test-jti".to_string(), scope: "lab".to_string(), diff --git a/crates/labby-auth/src/middleware.rs b/crates/labby-auth/src/middleware.rs index 7d87f5b7d..a68702539 100644 --- a/crates/labby-auth/src/middleware.rs +++ b/crates/labby-auth/src/middleware.rs @@ -736,6 +736,7 @@ mod tests { sub: "user@example.com".to_string(), aud: aud.clone(), exp: (crate::util::now_unix() + 60) as usize, + nbf: None, iat: crate::util::now_unix() as usize, jti: "j-1".to_string(), scope: "syslog:read syslog:admin".to_string(), @@ -780,6 +781,7 @@ mod tests { sub: "user@example.com".to_string(), aud, exp: (crate::util::now_unix() + 60) as usize, + nbf: None, iat: crate::util::now_unix() as usize, jti: "j-1".to_string(), scope: "syslog:read".to_string(), @@ -817,6 +819,7 @@ mod tests { sub: "user@example.com".to_string(), aud: "https://other.example.com/mcp".to_string(), exp: (crate::util::now_unix() + 60) as usize, + nbf: None, iat: crate::util::now_unix() as usize, jti: "j-1".to_string(), scope: "syslog:read".to_string(), @@ -944,6 +947,7 @@ mod tests { sub: "user@example.com".to_string(), aud: exact_resource.to_string(), exp: (crate::util::now_unix() + 60) as usize, + nbf: None, iat: crate::util::now_unix() as usize, jti: "exact-resource".to_string(), scope: "mcp:read".to_string(), @@ -1001,6 +1005,7 @@ mod tests { sub: "user@example.com".to_string(), aud: resource.to_string(), exp: (crate::util::now_unix() + 60) as usize, + nbf: None, iat: crate::util::now_unix() as usize, jti: "insufficient-scope".to_string(), scope: "mcp:read".to_string(), diff --git a/crates/labby-auth/src/token.rs b/crates/labby-auth/src/token.rs index d92b933ef..766b37ed6 100644 --- a/crates/labby-auth/src/token.rs +++ b/crates/labby-auth/src/token.rs @@ -961,6 +961,7 @@ fn build_token_response( state.config.env_prefix )) })?, + nbf: None, iat: now, jti: random_token(18)?, scope: scope.clone(), diff --git a/crates/labby-auth/tests/resource_registry.rs b/crates/labby-auth/tests/resource_registry.rs index 05ac694f1..4b7eaf785 100644 --- a/crates/labby-auth/tests/resource_registry.rs +++ b/crates/labby-auth/tests/resource_registry.rs @@ -118,6 +118,45 @@ fn expiry_renewal_release_and_random_ids_are_enforced() { )); } +#[test] +fn forced_process_style_drop_relies_on_expiry_and_restart_can_reregister_resource() { + let registry = ResourceRegistry::new(); + let abandoned = registry + .create_resource_lease_at( + "https://proxy.example:53147/mcp", + ["mcp:read"], + Duration::from_secs(10), + "process-before-restart", + unix_time(1_000), + ) + .unwrap(); + assert!( + registry + .effective_resource_scopes_at(&abandoned.resource, unix_time(1_009)) + .is_some() + ); + assert!( + registry + .effective_resource_scopes_at(&abandoned.resource, unix_time(1_010)) + .is_none() + ); + + let restarted = registry + .create_resource_lease_at( + "https://proxy.example:53147/mcp", + ["mcp:read", "mcp:write"], + Duration::from_secs(20), + "process-after-restart", + unix_time(1_011), + ) + .unwrap(); + assert_ne!(abandoned.id, restarted.id); + assert_eq!( + registry.effective_resource_scopes_at(&restarted.resource, unix_time(1_012)), + Some(vec!["mcp:read".to_string(), "mcp:write".to_string()]) + ); +} + #[test] fn invalid_resource_scope_ttl_and_owner_are_rejected() { let registry = ResourceRegistry::new(); diff --git a/crates/labby/src/api/router.rs b/crates/labby/src/api/router.rs index 70becaf9c..d9f251c4b 100644 --- a/crates/labby/src/api/router.rs +++ b/crates/labby/src/api/router.rs @@ -1307,12 +1307,19 @@ pub(crate) fn build_router_with_external_auth( let v1_router = Router::new().nest("/v1", v1); let resource_url: Option> = auth_state .as_ref() - .and_then(|state| state.config.public_url.as_ref().map(url::Url::as_str)) + .map(|state| labby_auth::metadata::canonical_resource_url(state.as_ref())) .or_else(|| { - state - .auth_config - .as_ref() - .and_then(|cfg| cfg.public_url.as_ref().map(url::Url::as_str)) + state.auth_config.as_ref().and_then(|cfg| { + cfg.public_url.as_ref().map(|url| { + let base = url.as_str().trim_end_matches('/'); + let path = cfg.resource_path.trim_start_matches('/'); + if path.is_empty() { + base.to_string() + } else { + format!("{base}/{path}") + } + }) + }) }) .map(Arc::from); let layer_deriver = state.actor_key_deriver.clone().map(lab_auth_deriver); @@ -3780,6 +3787,7 @@ mod tests { sub: "google-user".to_string(), aud: audience.to_string(), exp: now + 3600, + nbf: None, iat: now, jti: "test-jti".to_string(), scope: scope.to_string(), diff --git a/crates/labby/src/cli/proxy.rs b/crates/labby/src/cli/proxy.rs index 35400e677..62c5aee14 100644 --- a/crates/labby/src/cli/proxy.rs +++ b/crates/labby/src/cli/proxy.rs @@ -116,6 +116,24 @@ fn parse_explicit_env(values: &[String]) -> Result> { .collect() } +#[cfg(feature = "gateway")] +fn tailscale_options( + local_addr: std::net::SocketAddr, + prefs: &crate::proxy::config::ProxyPreferences, +) -> crate::proxy::tailscale::TailscaleServeOptions { + let mut options = crate::proxy::tailscale::TailscaleServeOptions::for_proxy( + local_addr, + prefs.path.clone(), + prefs.port, + prefs.port_range_start, + prefs.port_range_end, + ); + if let Some(executable) = std::env::var_os("LABBY_TAILSCALE_BIN") { + options.executable = executable.into(); + } + options +} + #[cfg(feature = "gateway")] fn local_runtime_preferences( preferences: &crate::proxy::config::ProxyPreferences, @@ -124,16 +142,12 @@ fn local_runtime_preferences( ProxyAuthMode, ProxyExposure, ProxyPortPreference, ProxyPreferences, }; - if preferences.auth == ProxyAuthMode::Oauth { - anyhow::bail!("proxy OAuth auth is unsupported in the Tailscale publication slice"); - } let auth = match (preferences.exposure, preferences.auth) { (ProxyExposure::Tailscale, ProxyAuthMode::Tailnet) => ProxyAuthMode::None, - (_, auth @ (ProxyAuthMode::None | ProxyAuthMode::Bearer)) => auth, + (_, auth @ (ProxyAuthMode::None | ProxyAuthMode::Bearer | ProxyAuthMode::Oauth)) => auth, (ProxyExposure::Local, ProxyAuthMode::Tailnet) => { anyhow::bail!("tailnet auth requires Tailscale exposure") } - (_, ProxyAuthMode::Oauth) => unreachable!("OAuth rejected above"), }; Ok(ProxyPreferences { exposure: ProxyExposure::Local, @@ -202,28 +216,149 @@ pub async fn run(args: ProxyArgs, config: &LabConfig, format: OutputFormat) -> R "starting stdio MCP proxy" ); - let proxy = - crate::proxy::runtime::LocalProxy::start(crate::proxy::runtime::LocalProxyOptions { - command, - preferences: local_preferences, - bearer_token, - explicit_env: parse_explicit_env(&args.env)?, - inherit_env, - }) - .await - .map_err(|error| anyhow::anyhow!("proxy startup failed: {error}"))?; - let info = proxy.info().clone(); - let mut tailscale = if prefs.exposure == crate::proxy::config::ProxyExposure::Tailscale { - let mut options = crate::proxy::tailscale::TailscaleServeOptions::for_proxy( - info.local_addr, - prefs.path.clone(), - prefs.port, - prefs.port_range_start, - prefs.port_range_end, - ); - if let Some(executable) = std::env::var_os("LABBY_TAILSCALE_BIN") { - options.executable = executable.into(); + let local_options = crate::proxy::runtime::LocalProxyOptions { + command, + preferences: local_preferences, + bearer_token: bearer_token.clone(), + explicit_env: parse_explicit_env(&args.env)?, + inherit_env, + }; + let (mut proxy, mut oauth_lease, oauth_tailscale) = if prefs.auth + == crate::proxy::config::ProxyAuthMode::Oauth + { + let mut prepared = crate::proxy::runtime::LocalProxy::prepare(local_options) + .await + .map_err(|error| anyhow::anyhow!("proxy preparation failed: {error}"))?; + let oauth = crate::proxy::oauth::ProxyOauthContext::prepare(config).await?; + if prefs.exposure == crate::proxy::config::ProxyExposure::Local { + anyhow::bail!( + "local OAuth exposure is not enabled because the daemon lease API accepts HTTPS resources only; use Tailscale exposure" + ); } + let owner = crate::proxy::oauth::owner_fingerprint(); + let mut abandoned_ports = std::collections::BTreeSet::new(); + let max_attempts = 32_usize; + let mut attempt = 0_usize; + loop { + attempt += 1; + let plan = if prefs.exposure == crate::proxy::config::ProxyExposure::Tailscale { + let mut options = tailscale_options(prepared.local_addr(), &prefs); + options.max_attempts = max_attempts; + let plan = crate::proxy::tailscale::TailscaleServePlan::prepare(options).await?; + if abandoned_ports.contains(&plan.external_port()) && attempt < max_attempts { + continue; + } + Some(plan) + } else { + None + }; + let resource = plan.as_ref().map_or_else( + || prepared.local_url().clone(), + |plan| plan.public_url().clone(), + ); + let mut lease = crate::proxy::oauth::OAuthLeaseGuard::create( + oauth.gateway.clone(), + resource.as_str(), + prefs.oauth_scopes.clone(), + &owner, + crate::proxy::oauth::OAuthLeaseTiming::proxy_default(), + ) + .await?; + let started = prepared.start(crate::proxy::runtime::LocalProxyAuthPolicy::Oauth { + auth_state: std::sync::Arc::clone(&oauth.auth_state), + resource: resource.clone(), + issuer: oauth.issuer.clone(), + required_scopes: prefs.oauth_scopes.clone(), + }); + let mut proxy = match started { + Ok(proxy) => proxy, + Err(error) => { + let release = lease.release().await; + return Err(combine_cleanup_errors( + error.context("proxy OAuth router startup failed"), + [("OAuth lease", release.err())], + )); + } + }; + let Some(plan) = plan else { + unreachable!("local OAuth exposure is rejected before lease creation") + }; + match plan.claim_typed().await { + Ok(serve) => { + if let Err(error) = crate::proxy::oauth::verify_protected_resource_metadata( + proxy.url(), + &resource, + ) + .await + { + let http_cleanup = proxy.stop_http().await; + let serve_cleanup = serve.shutdown().await; + proxy.stop_child().await; + let lease_cleanup = lease.release().await; + return Err(combine_cleanup_errors( + error, + [ + ("LocalProxy HTTP", http_cleanup.err()), + ("Tailscale Serve", serve_cleanup.err()), + ("OAuth lease", lease_cleanup.err()), + ], + )); + } + break (proxy, Some(lease), Some(serve)); + } + Err(crate::proxy::tailscale::TailscaleClaimError::Collision(error)) + if prefs.port.fixed().is_none() && attempt < max_attempts => + { + let port = resource.port().unwrap_or_default(); + abandoned_ports.insert(port); + let rollback = proxy.rollback_to_prepared().await; + let release = lease.release().await; + match rollback { + Ok(next) if release.is_ok() => prepared = next, + Ok(_) => { + return Err(combine_cleanup_errors( + error.context("Tailscale collision rollback failed"), + [("OAuth lease", release.err())], + )); + } + Err(rollback) => { + return Err(combine_cleanup_errors( + error.context("Tailscale collision rollback failed"), + [ + ("LocalProxy rollback", Some(rollback)), + ("OAuth lease", release.err()), + ], + )); + } + } + } + Err(error) => { + let proxy_cleanup = proxy.shutdown().await; + let lease_cleanup = lease.release().await; + return Err(combine_cleanup_errors( + anyhow::Error::new(error), + [ + ("LocalProxy", proxy_cleanup.err()), + ("OAuth lease", lease_cleanup.err()), + ], + )); + } + } + } + } else { + ( + crate::proxy::runtime::LocalProxy::start(local_options) + .await + .map_err(|error| anyhow::anyhow!("proxy startup failed: {error}"))?, + None, + None, + ) + }; + let info = proxy.info().clone(); + let mut tailscale = if oauth_tailscale.is_some() { + oauth_tailscale + } else if prefs.exposure == crate::proxy::config::ProxyExposure::Tailscale { + let options = tailscale_options(info.local_addr, &prefs); match crate::proxy::tailscale::TailscaleServe::start(options).await { Ok(serve) => Some(serve), Err(error) => { @@ -302,18 +437,40 @@ pub async fn run(args: ProxyArgs, config: &LabConfig, format: OutputFormat) -> R Ok(()) }; if let Err(output_error) = ready_output { + let http_shutdown = proxy.stop_http().await; let tailscale_shutdown = match tailscale.take() { Some(serve) => serve.shutdown().await, None => Ok(()), }; - let proxy_shutdown = proxy.shutdown().await; - tailscale_shutdown - .context("Tailscale Serve shutdown failed after readiness output error")?; - proxy_shutdown.context("LocalProxy shutdown failed after readiness output error")?; - return Err(output_error); + proxy.stop_child().await; + let lease_shutdown = match oauth_lease.as_mut() { + Some(lease) => lease.release().await, + None => Ok(()), + }; + return Err(combine_cleanup_errors( + output_error, + [ + ("LocalProxy HTTP", http_shutdown.err()), + ("Tailscale Serve", tailscale_shutdown.err()), + ("OAuth lease", lease_shutdown.err()), + ], + )); } - let failure = if let Some(serve) = tailscale.as_mut() { + let failure = if let (Some(serve), Some(lease)) = (tailscale.as_mut(), oauth_lease.as_mut()) { + tokio::select! { + signal = tokio::signal::ctrl_c() => Some(signal.map_err(anyhow::Error::from)), + result = proxy.wait_for_failure() => Some(result), + result = serve.wait_for_failure() => Some(result), + result = lease.wait_for_failure() => Some(result), + } + } else if let Some(lease) = oauth_lease.as_mut() { + tokio::select! { + signal = tokio::signal::ctrl_c() => Some(signal.map_err(anyhow::Error::from)), + result = proxy.wait_for_failure() => Some(result), + result = lease.wait_for_failure() => Some(result), + } + } else if let Some(serve) = tailscale.as_mut() { tokio::select! { signal = tokio::signal::ctrl_c() => Some(signal.map_err(anyhow::Error::from)), result = proxy.wait_for_failure() => Some(result), @@ -325,19 +482,56 @@ pub async fn run(args: ProxyArgs, config: &LabConfig, format: OutputFormat) -> R result = proxy.wait_for_failure() => Some(result), } }; + let http_shutdown = proxy.stop_http().await; let tailscale_shutdown = match tailscale { Some(serve) => serve.shutdown().await, None => Ok(()), }; - let proxy_shutdown = proxy.shutdown().await; - tailscale_shutdown.context("Tailscale Serve shutdown failed")?; - proxy_shutdown.context("LocalProxy shutdown failed")?; - if let Some(result) = failure { - result?; + proxy.stop_child().await; + let lease_shutdown = match oauth_lease.as_mut() { + Some(lease) => lease.release().await, + None => Ok(()), + }; + let primary = failure.and_then(Result::err); + if let Some(primary) = primary { + return Err(combine_cleanup_errors( + primary, + [ + ("Tailscale Serve", tailscale_shutdown.err()), + ("LocalProxy HTTP", http_shutdown.err()), + ("OAuth lease", lease_shutdown.err()), + ], + )); + } + if tailscale_shutdown.is_err() || http_shutdown.is_err() || lease_shutdown.is_err() { + return Err(combine_cleanup_errors( + anyhow::anyhow!("proxy shutdown failed"), + [ + ("Tailscale Serve", tailscale_shutdown.err()), + ("LocalProxy HTTP", http_shutdown.err()), + ("OAuth lease", lease_shutdown.err()), + ], + )); } Ok(ExitCode::SUCCESS) } +#[cfg(feature = "gateway")] +fn combine_cleanup_errors( + primary: anyhow::Error, + cleanups: [(&str, Option); N], +) -> anyhow::Error { + let failures = cleanups + .into_iter() + .filter_map(|(name, error)| error.map(|error| format!("{name}: {error:#}"))) + .collect::>(); + if failures.is_empty() { + primary + } else { + primary.context(format!("cleanup failures: {}", failures.join("; "))) + } +} + #[cfg(not(feature = "gateway"))] pub async fn run(_args: ProxyArgs, _config: &LabConfig, _format: OutputFormat) -> Result { anyhow::bail!("stdio MCP proxy runtime requires the `gateway` feature") @@ -484,13 +678,14 @@ mod tests { } #[test] - fn oauth_is_an_explicitly_unsupported_proxy_slice() { + fn oauth_is_preserved_for_the_finalized_local_router_policy() { let prefs = crate::proxy::config::ProxyPreferences { auth: crate::proxy::config::ProxyAuthMode::Oauth, ..Default::default() }; - let error = local_runtime_preferences(&prefs).unwrap_err(); - assert!(error.to_string().contains("OAuth")); - assert!(error.to_string().contains("unsupported")); + assert_eq!( + local_runtime_preferences(&prefs).unwrap().auth, + crate::proxy::config::ProxyAuthMode::Oauth + ); } } diff --git a/crates/labby/src/live_gateway.rs b/crates/labby/src/live_gateway.rs index ab87549dd..dee0777e9 100644 --- a/crates/labby/src/live_gateway.rs +++ b/crates/labby/src/live_gateway.rs @@ -36,6 +36,7 @@ const PROBE_TIMEOUT: Duration = Duration::from_millis(800); // the reachability probe gets an explicit short timeout below. /// A reachable, already-running `labby serve` daemon. +#[derive(Clone)] pub struct LiveGateway { base_url: String, token: Option, @@ -182,6 +183,91 @@ fn actions_include_gateway_reload(actions: &Value) -> bool { } impl LiveGateway { + pub async fn verify_resource_lease_actions(&self) -> Result<(), ToolError> { + const REQUIRED: [&str; 3] = [ + "gateway.oauth.resource_lease.create", + "gateway.oauth.resource_lease.renew", + "gateway.oauth.resource_lease.release", + ]; + for action in REQUIRED { + if !self.supports_action(action).await? { + return Err(ToolError::Sdk { + sdk_kind: "proxy_auth_unavailable".to_string(), + message: format!( + "live Labby daemon does not support required action `{action}`" + ), + }); + } + } + Ok(()) + } + + pub async fn verify_oauth_issuer( + &self, + issuer: &url::Url, + ) -> Result { + let stable_issuer = issuer.as_str().trim_end_matches('/'); + let metadata_url = format!("{stable_issuer}/.well-known/oauth-authorization-server"); + let response = self + .client + .get(&metadata_url) + .timeout(PROBE_TIMEOUT) + .send() + .await + .map_err(|error| ToolError::Sdk { + sdk_kind: "proxy_auth_unavailable".to_string(), + message: format!("OAuth authorization-server metadata is unreachable: {error}"), + })?; + if !response.status().is_success() { + return Err(ToolError::Sdk { + sdk_kind: "proxy_auth_unavailable".to_string(), + message: format!( + "OAuth authorization-server metadata returned HTTP {}", + response.status() + ), + }); + } + let metadata: Value = response.json().await.map_err(|error| ToolError::Sdk { + sdk_kind: "proxy_auth_unavailable".to_string(), + message: format!("OAuth authorization-server metadata is invalid: {error}"), + })?; + if metadata.get("issuer").and_then(Value::as_str) != Some(stable_issuer) { + return Err(ToolError::Sdk { + sdk_kind: "proxy_auth_unavailable".to_string(), + message: + "OAuth metadata issuer does not exactly match the configured stable issuer" + .to_string(), + }); + } + let jwks_uri = metadata + .get("jwks_uri") + .and_then(Value::as_str) + .ok_or_else(|| ToolError::Sdk { + sdk_kind: "proxy_auth_unavailable".to_string(), + message: "OAuth metadata does not advertise a JWKS URI".to_string(), + })?; + let response = self + .client + .get(jwks_uri) + .timeout(PROBE_TIMEOUT) + .send() + .await + .map_err(|error| ToolError::Sdk { + sdk_kind: "proxy_auth_unavailable".to_string(), + message: format!("OAuth JWKS is unreachable: {error}"), + })?; + if !response.status().is_success() { + return Err(ToolError::Sdk { + sdk_kind: "proxy_auth_unavailable".to_string(), + message: format!("OAuth JWKS returned HTTP {}", response.status()), + }); + } + response.json().await.map_err(|error| ToolError::Sdk { + sdk_kind: "proxy_auth_unavailable".to_string(), + message: format!("OAuth JWKS is invalid: {error}"), + }) + } + pub async fn supports_action(&self, action: &str) -> Result { let mut request = self .client @@ -734,4 +820,174 @@ mod tests { .unwrap() ); } + + #[tokio::test] + async fn oauth_proxy_prerequisites_require_all_lease_actions() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/gateway/actions")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + {"name": "gateway.oauth.resource_lease.create"}, + {"name": "gateway.oauth.resource_lease.renew"} + ]))) + .mount(&server) + .await; + let error = test_gateway(server.uri(), None) + .verify_resource_lease_actions() + .await + .unwrap_err(); + assert!(error.to_string().contains("release")); + } + + #[tokio::test] + async fn oauth_proxy_prerequisites_verify_exact_issuer_metadata_and_jwks() { + let server = MockServer::start().await; + let issuer = server.uri(); + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "issuer": issuer, + "jwks_uri": format!("{}/jwks", server.uri()) + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"keys": []}))) + .mount(&server) + .await; + + let jwks = test_gateway(server.uri(), None) + .verify_oauth_issuer(&url::Url::parse(&server.uri()).unwrap()) + .await + .unwrap(); + assert!(jwks.keys.is_empty()); + } + + #[tokio::test] + async fn oauth_proxy_prerequisites_reject_unreachable_metadata() { + let server = MockServer::start().await; + let error = test_gateway(server.uri(), None) + .verify_oauth_issuer(&url::Url::parse(&server.uri()).unwrap()) + .await + .unwrap_err(); + assert!(error.to_string().contains("metadata")); + } + + #[tokio::test] + async fn oauth_lease_guard_renews_and_releases_without_exposing_id() { + use crate::proxy::oauth::{OAuthLeaseGuard, OAuthLeaseTiming}; + + let server = MockServer::start().await; + let lease = json!({ + "id": "lease-secret-id", + "resource": "https://proxy.example:53147/mcp", + "scopes": ["mcp:read"], + "expires_at_unix": 4_000_000_000_u64 + }); + for (action, response) in [ + ("gateway.oauth.resource_lease.create", lease.clone()), + ("gateway.oauth.resource_lease.renew", lease.clone()), + ( + "gateway.oauth.resource_lease.release", + json!({"released": true}), + ), + ] { + Mock::given(method("POST")) + .and(path("/v1/gateway")) + .and(wiremock::matchers::body_partial_json( + json!({"action": action}), + )) + .respond_with(ResponseTemplate::new(200).set_body_json(response)) + .mount(&server) + .await; + } + let mut guard = OAuthLeaseGuard::create( + test_gateway(server.uri(), None), + "https://proxy.example:53147/mcp", + vec!["mcp:read".to_string()], + "owner-fingerprint", + OAuthLeaseTiming { + ttl: Duration::from_millis(90), + renew_interval: Duration::from_millis(20), + jitter_max: Duration::ZERO, + }, + ) + .await + .unwrap(); + assert!(!format!("{guard:?}").contains("lease-secret-id")); + tokio::time::sleep(Duration::from_millis(35)).await; + guard.release().await.unwrap(); + + let requests = server.received_requests().await.unwrap(); + let bodies = requests + .iter() + .filter_map(|request| serde_json::from_slice::(&request.body).ok()) + .collect::>(); + assert!( + bodies + .iter() + .any(|body| body["action"] == "gateway.oauth.resource_lease.renew") + ); + assert!( + bodies + .iter() + .any(|body| body["action"] == "gateway.oauth.resource_lease.release") + ); + } + + #[tokio::test] + async fn oauth_lease_guard_propagates_renewal_failure_and_still_releases() { + use crate::proxy::oauth::{OAuthLeaseGuard, OAuthLeaseTiming}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/gateway")) + .and(wiremock::matchers::body_partial_json(json!({ + "action": "gateway.oauth.resource_lease.create" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "lease-secret-id", + "resource": "https://proxy.example:53147/mcp", + "scopes": ["mcp:read"], + "expires_at_unix": 4_000_000_000_u64 + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/gateway")) + .and(wiremock::matchers::body_partial_json(json!({ + "action": "gateway.oauth.resource_lease.renew" + }))) + .respond_with(ResponseTemplate::new(503).set_body_json(json!({ + "kind": "daemon_unavailable", "message": "renew failed" + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/gateway")) + .and(wiremock::matchers::body_partial_json(json!({ + "action": "gateway.oauth.resource_lease.release" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"released": true}))) + .mount(&server) + .await; + + let mut guard = OAuthLeaseGuard::create( + test_gateway(server.uri(), None), + "https://proxy.example:53147/mcp", + vec!["mcp:read".to_string()], + "owner-fingerprint", + OAuthLeaseTiming { + ttl: Duration::from_millis(90), + renew_interval: Duration::from_millis(10), + jitter_max: Duration::ZERO, + }, + ) + .await + .unwrap(); + let error = guard.wait_for_failure().await.unwrap_err(); + assert!(error.to_string().contains("renewal failed")); + guard.release().await.unwrap(); + } } diff --git a/crates/labby/src/proxy.rs b/crates/labby/src/proxy.rs index 7ac563b92..993c19095 100644 --- a/crates/labby/src/proxy.rs +++ b/crates/labby/src/proxy.rs @@ -3,6 +3,8 @@ pub mod command; pub mod config; #[cfg(feature = "gateway")] +pub mod oauth; +#[cfg(feature = "gateway")] pub mod runtime; #[cfg(feature = "gateway")] pub mod tailscale; diff --git a/crates/labby/src/proxy/oauth.rs b/crates/labby/src/proxy/oauth.rs new file mode 100644 index 000000000..a5671e9e1 --- /dev/null +++ b/crates/labby/src/proxy/oauth.rs @@ -0,0 +1,290 @@ +//! OAuth resource-lease ownership for an ephemeral proxy. + +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use sha2::{Digest, Sha256}; +use tokio_util::sync::CancellationToken; + +use crate::live_gateway::LiveGateway; + +pub struct ProxyOauthContext { + pub gateway: LiveGateway, + pub auth_state: std::sync::Arc, + pub issuer: url::Url, +} + +impl std::fmt::Debug for ProxyOauthContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProxyOauthContext") + .field("issuer", &self.issuer) + .finish_non_exhaustive() + } +} + +impl ProxyOauthContext { + pub async fn prepare(config: &crate::config::LabConfig) -> Result { + let mut auth_config = crate::config::resolve_auth_for_config(config) + .context("proxy OAuth configuration is invalid")?; + if !matches!(auth_config.mode, labby_auth::config::AuthMode::OAuth) { + bail!("proxy OAuth requires the live Labby daemon to run in OAuth mode"); + } + let issuer = auth_config + .public_url + .clone() + .context("proxy OAuth requires a stable Labby public issuer")?; + let gateway = crate::live_gateway::detect(config) + .await + .context("proxy OAuth requires a reachable live Labby daemon")?; + gateway + .verify_resource_lease_actions() + .await + .context("live Labby daemon does not support proxy OAuth leases")?; + let daemon_jwks = gateway + .verify_oauth_issuer(&issuer) + .await + .context("stable Labby OAuth issuer verification failed")?; + + // The proxy never accepts the daemon's static administrator token as an + // OAuth fallback. It validates only signed access tokens. + auth_config.disable_static_token_with_oauth = true; + let auth_state = std::sync::Arc::new( + labby_auth::state::AuthState::new(auth_config) + .await + .context("same-host OAuth auth state construction failed")?, + ); + if daemon_jwks != *auth_state.signing_keys.jwks() { + bail!("live daemon JWKS does not match the configured same-host signing keys"); + } + Ok(Self { + gateway, + auth_state, + issuer, + }) + } +} + +pub const DEFAULT_LEASE_TTL: Duration = Duration::from_mins(2); +pub const DEFAULT_RENEW_INTERVAL: Duration = Duration::from_secs(40); +pub const DEFAULT_RENEW_JITTER_MAX: Duration = Duration::from_secs(4); + +#[derive(Debug, Clone, Copy)] +pub struct OAuthLeaseTiming { + pub ttl: Duration, + pub renew_interval: Duration, + pub jitter_max: Duration, +} + +impl Default for OAuthLeaseTiming { + fn default() -> Self { + Self { + ttl: DEFAULT_LEASE_TTL, + renew_interval: DEFAULT_RENEW_INTERVAL, + jitter_max: DEFAULT_RENEW_JITTER_MAX, + } + } +} + +impl OAuthLeaseTiming { + #[must_use] + pub fn proxy_default() -> Self { + let timing = Self::default(); + #[cfg(feature = "proxy-testkit")] + if let Some(interval) = std::env::var("LABBY_PROXY_TEST_RENEW_MS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(Duration::from_millis) + { + return Self { + renew_interval: interval, + jitter_max: Duration::ZERO, + ..timing + }; + } + timing + } +} + +pub struct OAuthLeaseGuard { + gateway: LiveGateway, + lease_id: String, + cancellation: CancellationToken, + renewal_task: Option>>, + active: bool, +} + +impl std::fmt::Debug for OAuthLeaseGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuthLeaseGuard") + .field("lease_id", &"") + .field("active", &self.active) + .field("renewal_task", &self.renewal_task.is_some()) + .finish_non_exhaustive() + } +} + +impl OAuthLeaseGuard { + pub async fn create( + gateway: LiveGateway, + resource: &str, + scopes: Vec, + owner: &str, + timing: OAuthLeaseTiming, + ) -> Result { + if timing.ttl.is_zero() || timing.renew_interval.is_zero() { + bail!("OAuth lease TTL and renewal interval must be non-zero"); + } + let lease = gateway + .create_resource_lease(resource, scopes, timing.ttl, owner) + .await + .context("OAuth resource lease creation failed")?; + let lease_id = lease.id; + let cancellation = CancellationToken::new(); + let renew_cancel = cancellation.clone(); + let renew_gateway = gateway.clone(); + let renew_id = lease_id.clone(); + let renewal_task = tokio::spawn(async move { + loop { + let delay = timing.renew_interval + bounded_jitter(timing.jitter_max); + tokio::select! { + () = renew_cancel.cancelled() => return Ok(()), + () = tokio::time::sleep(delay) => {} + } + renew_gateway + .renew_resource_lease(&renew_id, timing.ttl) + .await + .context("OAuth resource lease renewal failed")?; + tracing::debug!( + surface = "cli", + service = "proxy", + action = "proxy.oauth.lease.renew", + "OAuth proxy resource lease renewed" + ); + } + }); + tracing::debug!( + surface = "cli", + service = "proxy", + action = "proxy.oauth.lease.create", + "OAuth proxy resource lease created" + ); + Ok(Self { + gateway, + lease_id, + cancellation, + renewal_task: Some(renewal_task), + active: true, + }) + } + + pub async fn wait_for_failure(&mut self) -> Result<()> { + let task = self + .renewal_task + .take() + .context("OAuth lease renewal supervisor is no longer running")?; + task.await.context("OAuth lease renewal task panicked")? + } + + pub async fn release(&mut self) -> Result<()> { + if !self.active { + return Ok(()); + } + self.cancellation.cancel(); + if let Some(task) = self.renewal_task.take() { + drop(task.await); + } + self.gateway + .release_resource_lease(&self.lease_id) + .await + .context("OAuth resource lease release failed")?; + self.active = false; + tracing::debug!( + surface = "cli", + service = "proxy", + action = "proxy.oauth.lease.release", + "OAuth proxy resource lease released" + ); + Ok(()) + } +} + +pub async fn verify_protected_resource_metadata( + local_url: &url::Url, + public_resource: &url::Url, +) -> Result<()> { + let metadata_url = local_url + .join("/.well-known/oauth-protected-resource") + .context("construct proxy protected-resource metadata URL")?; + let host = match public_resource.port() { + Some(port) => format!( + "{}:{port}", + public_resource + .host_str() + .context("proxy OAuth resource has no host")? + ), + None => public_resource + .host_str() + .context("proxy OAuth resource has no host")? + .to_string(), + }; + let response = reqwest::Client::new() + .get(metadata_url) + .header(reqwest::header::HOST, host) + .send() + .await + .context("proxy protected-resource metadata is unreachable")?; + if !response.status().is_success() { + bail!( + "proxy protected-resource metadata returned HTTP {}", + response.status() + ); + } + let metadata: labby_auth::types::ProtectedResourceMetadata = response + .json() + .await + .context("proxy protected-resource metadata is invalid")?; + if metadata.resource != public_resource.as_str() { + bail!("proxy protected-resource metadata advertises the wrong resource"); + } + Ok(()) +} + +impl Drop for OAuthLeaseGuard { + fn drop(&mut self) { + self.cancellation.cancel(); + if let Some(task) = self.renewal_task.take() { + task.abort(); + } + // An async release is deliberately impossible from Drop. The daemon TTL + // is the crash/forced-termination recovery boundary. + } +} + +#[must_use] +pub fn owner_fingerprint() -> String { + let mut nonce = [0_u8; 16]; + if getrandom::fill(&mut nonce).is_err() { + nonce.copy_from_slice(&std::process::id().to_le_bytes().repeat(4)); + } + let mut hash = Sha256::new(); + hash.update(std::process::id().to_le_bytes()); + hash.update(nonce); + let digest = hash.finalize(); + digest[..12] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn bounded_jitter(max: Duration) -> Duration { + if max.is_zero() { + return Duration::ZERO; + } + let mut bytes = [0_u8; 8]; + if getrandom::fill(&mut bytes).is_err() { + return Duration::ZERO; + } + let max_nanos = max.as_nanos().min(u128::from(u64::MAX)) as u64; + let nanos = u64::from_le_bytes(bytes) % max_nanos.saturating_add(1); + Duration::from_nanos(nanos) +} diff --git a/crates/labby/src/proxy/runtime.rs b/crates/labby/src/proxy/runtime.rs index 06338bd36..baf82fff5 100644 --- a/crates/labby/src/proxy/runtime.rs +++ b/crates/labby/src/proxy/runtime.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result, bail}; -use axum::Router; -use labby_auth::AuthLayer; +use axum::{Json, Router, routing::get}; +use labby_auth::{AuthLayer, state::AuthState, types::ProtectedResourceMetadata}; use labby_gateway::upstream::direct_stdio::{ DirectStdioCommand, DirectStdioConnection, connect_direct_stdio, }; @@ -27,6 +27,70 @@ pub struct LocalProxyOptions { pub inherit_env: Vec, } +#[derive(Clone)] +pub enum LocalProxyAuthPolicy { + None, + Bearer { + token: Arc, + resource: url::Url, + }, + Oauth { + auth_state: Arc, + resource: url::Url, + issuer: url::Url, + required_scopes: Vec, + }, +} + +impl std::fmt::Debug for LocalProxyAuthPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => f.write_str("None"), + Self::Bearer { resource, .. } => f + .debug_struct("Bearer") + .field("resource", resource) + .finish_non_exhaustive(), + Self::Oauth { + resource, + issuer, + required_scopes, + .. + } => f + .debug_struct("Oauth") + .field("resource", resource) + .field("issuer", issuer) + .field("required_scopes", required_scopes) + .finish_non_exhaustive(), + } + } +} + +pub struct PreparedLocalProxy { + display: String, + connection: Option>, + listener: Option, + local_addr: std::net::SocketAddr, + local_url: url::Url, + path: String, + protocol_version: rmcp::model::ProtocolVersion, + child_pid: Option, +} + +impl std::fmt::Debug for PreparedLocalProxy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PreparedLocalProxy") + .field("display", &self.display) + .field("connection", &self.connection.is_some()) + .field("listener", &self.listener.is_some()) + .field("local_addr", &self.local_addr) + .field("local_url", &self.local_url) + .field("path", &self.path) + .field("protocol_version", &self.protocol_version) + .field("child_pid", &self.child_pid) + .finish() + } +} + #[derive(Clone)] pub struct LocalProxyInfo { pub url: url::Url, @@ -52,6 +116,7 @@ impl std::fmt::Debug for LocalProxyInfo { pub struct LocalProxy { info: LocalProxyInfo, + path: String, connection: Option>, cancellation: CancellationToken, server_task: Option>>, @@ -61,6 +126,7 @@ impl std::fmt::Debug for LocalProxy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LocalProxy") .field("info", &self.info) + .field("path", &self.path) .field("connection", &self.connection.is_some()) .field("cancellation", &self.cancellation.is_cancelled()) .field("server_task", &self.server_task.is_some()) @@ -85,17 +151,42 @@ impl LocalProxy { options.preferences.auth ); } - let bearer_token = match options.preferences.auth { - ProxyAuthMode::Bearer => Some( - options + let auth = match options.preferences.auth { + ProxyAuthMode::Bearer => { + let token = options .bearer_token + .as_ref() .filter(|token| !token.is_empty()) - .context("bearer auth requires a non-empty proxy token")?, - ), - ProxyAuthMode::None => None, + .cloned() + .context("bearer auth requires a non-empty proxy token")?; + LocalProxyAuthPolicy::Bearer { + token: Arc::from(token), + resource: url::Url::parse("http://127.0.0.1/")?, + } + } + ProxyAuthMode::None => LocalProxyAuthPolicy::None, _ => unreachable!("unsupported auth modes rejected above"), }; + let prepared = Self::prepare(options).await?; + let auth = match auth { + LocalProxyAuthPolicy::Bearer { token, .. } => LocalProxyAuthPolicy::Bearer { + token, + resource: prepared.local_url.clone(), + }, + other => other, + }; + prepared.start(auth) + } + + pub async fn prepare(options: LocalProxyOptions) -> Result { + if options.preferences.exposure != ProxyExposure::Local { + bail!( + "proxy exposure {:?} is unsupported in this runtime slice", + options.preferences.exposure + ); + } + let display = options.command.display.clone(); let (connection, _discovered_tools) = connect_direct_stdio( DirectStdioCommand { @@ -115,8 +206,6 @@ impl LocalProxy { .protocol_version() .unwrap_or(rmcp::model::ProtocolVersion::V_2026_07_28); let child_pid = connection.child_pid(); - let peer = connection.peer().clone(); - let bind_port = options.preferences.port.fixed().unwrap_or(0); let listener = tokio::net::TcpListener::bind(("127.0.0.1", bind_port)) .await @@ -124,57 +213,15 @@ impl LocalProxy { let local_addr = listener.local_addr()?; let url = url::Url::parse(&format!("http://{local_addr}{}", options.preferences.path))?; - let cancellation = CancellationToken::new(); - let service_config = StreamableHttpServerConfig::default() - .with_allowed_hosts([ - "localhost".to_string(), - "127.0.0.1".to_string(), - "::1".to_string(), - local_addr.to_string(), - ]) - .with_allowed_origins([ - format!("http://127.0.0.1:{}", local_addr.port()), - format!("http://localhost:{}", local_addr.port()), - ]) - .with_legacy_session_mode(false) - .with_json_response(false) - .with_cancellation_token(cancellation.clone()); - let session_manager = Arc::new(NeverSessionManager::default()); - let mcp_service = StreamableHttpService::new( - move || Ok(BridgeServerHandler::from_peer(peer.clone())), - session_manager, - service_config, - ); - - let mut router = Router::new().nest_service(&options.preferences.path, mcp_service); - if let Some(token) = bearer_token { - router = router.layer( - AuthLayer::new() - .with_static_token(Some(Arc::::from(token))) - .with_resource_url(Some(Arc::::from(url.as_str()))), - ); - } - - let shutdown = cancellation.clone(); - let server_task = tokio::spawn(async move { - axum::serve(listener, router) - .with_graceful_shutdown(shutdown.cancelled_owned()) - .await - .context("proxy HTTP server failed") - }); - - Ok(Self { - info: LocalProxyInfo { - url, - local_addr, - command: display, - child_pid, - protocol_version, - auth: options.preferences.auth, - }, + Ok(PreparedLocalProxy { + display, connection: Some(connection), - cancellation, - server_task: Some(server_task), + listener: Some(listener), + local_addr, + local_url: url, + path: options.preferences.path, + protocol_version, + child_pid, }) } @@ -209,7 +256,7 @@ impl LocalProxy { } } - pub async fn shutdown(mut self) -> Result<()> { + pub async fn stop_http(&mut self) -> Result<()> { self.cancellation.cancel(); if let Some(server_task) = self.server_task.take() { match tokio::time::timeout(Duration::from_secs(3), server_task).await { @@ -217,13 +264,203 @@ impl LocalProxy { Err(_) => bail!("proxy HTTP server did not stop within 3 seconds"), } } + Ok(()) + } + + pub async fn stop_child(&mut self) { if let Some(connection) = self.connection.take() { connection.shutdown().await; } - Ok(()) + } + + pub async fn shutdown(mut self) -> Result<()> { + let http = self.stop_http().await; + self.stop_child().await; + http + } + + pub async fn rollback_to_prepared(mut self) -> Result { + self.stop_http().await?; + let listener = tokio::net::TcpListener::bind(self.info.local_addr) + .await + .context("failed to rebind prepared proxy loopback listener")?; + let connection = self + .connection + .take() + .context("proxy child is no longer owned during rollback")?; + Ok(PreparedLocalProxy { + display: self.info.command.clone(), + connection: Some(connection), + listener: Some(listener), + local_addr: self.info.local_addr, + local_url: self.info.url.clone(), + path: self.path.clone(), + protocol_version: self.info.protocol_version.clone(), + child_pid: self.info.child_pid, + }) } } +impl PreparedLocalProxy { + #[must_use] + pub const fn local_addr(&self) -> std::net::SocketAddr { + self.local_addr + } + + #[must_use] + pub fn local_url(&self) -> &url::Url { + &self.local_url + } + + pub fn start(mut self, auth: LocalProxyAuthPolicy) -> Result { + let listener = self + .listener + .take() + .context("prepared proxy listener is no longer owned")?; + let connection = self + .connection + .take() + .context("prepared proxy child is no longer owned")?; + let peer = connection.peer().clone(); + + let cancellation = CancellationToken::new(); + let mut allowed_hosts = vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + self.local_addr.to_string(), + ]; + let mut allowed_origins = vec![ + format!("http://127.0.0.1:{}", self.local_addr.port()), + format!("http://localhost:{}", self.local_addr.port()), + ]; + let external_resource = match &auth { + LocalProxyAuthPolicy::None => None, + LocalProxyAuthPolicy::Bearer { resource, .. } + | LocalProxyAuthPolicy::Oauth { resource, .. } => Some(resource), + }; + if let Some(resource) = external_resource { + if let Some(host) = resource.host_str() { + allowed_hosts.push(match resource.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }); + } + let origin = resource.origin().ascii_serialization(); + if origin != "null" { + allowed_origins.push(origin); + } + } + let service_config = StreamableHttpServerConfig::default() + .with_allowed_hosts(allowed_hosts) + .with_allowed_origins(allowed_origins) + .with_legacy_session_mode(false) + .with_json_response(false) + .with_cancellation_token(cancellation.clone()); + let session_manager = Arc::new(NeverSessionManager::default()); + let mcp_service = StreamableHttpService::new( + move || Ok(BridgeServerHandler::from_peer(peer.clone())), + session_manager, + service_config, + ); + + let mut mcp_router = Router::new().nest_service(&self.path, mcp_service); + let auth_mode = match auth { + LocalProxyAuthPolicy::None => ProxyAuthMode::None, + LocalProxyAuthPolicy::Bearer { token, resource } => { + mcp_router = mcp_router.layer( + AuthLayer::new() + .with_static_token(Some(token)) + .with_resource_url(Some(Arc::from(resource.as_str()))), + ); + ProxyAuthMode::Bearer + } + LocalProxyAuthPolicy::Oauth { + auth_state, + resource, + issuer, + required_scopes, + } => { + let configured_issuer = auth_state + .config + .public_url + .as_ref() + .context("OAuth auth state has no stable public issuer")? + .as_str() + .trim_end_matches('/'); + if configured_issuer != issuer.as_str().trim_end_matches('/') { + bail!("OAuth auth state issuer does not match the stable issuer"); + } + let metadata_url = root_metadata_url(&resource)?; + let layer = AuthLayer::from_state(auth_state) + .with_resource_url(Some(Arc::from(resource.as_str()))) + .with_required_scopes(required_scopes.clone()) + .with_protected_resource_metadata_url(Some(Arc::from(metadata_url.as_str()))); + mcp_router = mcp_router.layer(layer); + let metadata = ProtectedResourceMetadata { + resource: resource.to_string(), + authorization_servers: vec![issuer.to_string().trim_end_matches('/').into()], + scopes_supported: required_scopes, + bearer_methods_supported: vec!["header".to_string()], + }; + let router = mcp_router.route( + "/.well-known/oauth-protected-resource", + get(move || async move { Json(metadata.clone()) }), + ); + return self.finish_start( + connection, + cancellation, + listener, + router, + ProxyAuthMode::Oauth, + ); + } + }; + + self.finish_start(connection, cancellation, listener, mcp_router, auth_mode) + } + + fn finish_start( + self, + connection: DirectStdioConnection, + cancellation: CancellationToken, + listener: tokio::net::TcpListener, + router: Router, + auth: ProxyAuthMode, + ) -> Result { + let shutdown = cancellation.clone(); + let server_task = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(shutdown.cancelled_owned()) + .await + .context("proxy HTTP server failed") + }); + + Ok(LocalProxy { + info: LocalProxyInfo { + url: self.local_url.clone(), + local_addr: self.local_addr, + command: self.display.clone(), + child_pid: self.child_pid, + protocol_version: self.protocol_version.clone(), + auth, + }, + path: self.path.clone(), + connection: Some(connection), + cancellation, + server_task: Some(server_task), + }) + } +} + +fn root_metadata_url(resource: &url::Url) -> Result { + let mut metadata = resource.clone(); + metadata.set_path("/.well-known/oauth-protected-resource"); + metadata.set_query(None); + metadata.set_fragment(None); + Ok(metadata) +} + impl Drop for LocalProxy { fn drop(&mut self) { self.cancellation.cancel(); @@ -233,3 +470,11 @@ impl Drop for LocalProxy { // DirectStdioConnection::Drop owns fail-safe process-tree cleanup. } } + +impl Drop for PreparedLocalProxy { + fn drop(&mut self) { + // Dropping the listener closes the unserved socket. DirectStdioConnection::Drop + // owns fail-safe process-tree cleanup when preparation is rolled back. + self.listener.take(); + } +} diff --git a/crates/labby/src/proxy/tailscale.rs b/crates/labby/src/proxy/tailscale.rs index 04fba2fc8..51e5e84d3 100644 --- a/crates/labby/src/proxy/tailscale.rs +++ b/crates/labby/src/proxy/tailscale.rs @@ -200,6 +200,103 @@ pub struct TailscaleServe { readiness_timeout: Duration, } +#[derive(Debug)] +pub struct TailscaleServePlan { + options: TailscaleServeOptions, + dns_name: String, + external_port: u16, + backend: String, + public_url: url::Url, +} + +#[derive(Debug, thiserror::Error)] +pub enum TailscaleClaimError { + #[error("Tailscale Serve port collision: {0:#}")] + Collision(anyhow::Error), + #[error("Tailscale Serve claim failed: {0:#}")] + Failed(anyhow::Error), +} + +impl TailscaleServePlan { + pub async fn prepare(options: TailscaleServeOptions) -> Result { + if options.max_attempts == 0 { + bail!("Tailscale Serve port selection requires at least one attempt"); + } + let version = run_checked(&options.executable, ["version"]).await?; + if version.trim().is_empty() { + bail!("Tailscale CLI returned an empty version"); + } + let status_output = run_checked(&options.executable, ["status", "--json"]).await?; + let identity = TailscaleStatus::parse(&status_output)?.require_online()?; + let dns_name = identity + .dns_name + .strip_suffix('.') + .unwrap_or(&identity.dns_name) + .to_string(); + let serve_output = run_checked(&options.executable, ["serve", "status", "--json"]).await?; + let initial_status = ServeStatus::parse(&serve_output)?; + let candidates = if let Some(port) = options.port.fixed() { + vec![port] + } else if options.candidate_ports.is_empty() { + random_candidates( + options.port_range_start, + options.port_range_end, + options.max_attempts, + )? + } else { + options.candidate_ports.clone() + }; + let external_port = select_port_from_candidates( + options.port, + options.port_range_start, + options.port_range_end, + &initial_status, + candidates, + options.max_attempts, + )?; + let backend = format!("http://127.0.0.1:{}", options.local_addr.port()); + let public_url = build_public_url(&dns_name, external_port, &options.path)?; + Ok(Self { + options, + dns_name, + external_port, + backend, + public_url, + }) + } + + #[must_use] + pub const fn external_port(&self) -> u16 { + self.external_port + } + + #[must_use] + pub fn public_url(&self) -> &url::Url { + &self.public_url + } + + pub async fn claim(self) -> Result { + self.claim_typed().await.map_err(anyhow::Error::from) + } + + pub async fn claim_typed(self) -> std::result::Result { + let result = TailscaleServe::claim( + &self.options, + self.dns_name, + self.external_port, + self.backend, + ) + .await; + result.map_err(|error| { + if is_collision_error(&error.to_string()) { + TailscaleClaimError::Collision(error) + } else { + TailscaleClaimError::Failed(error) + } + }) + } +} + impl TailscaleServe { pub async fn start(options: TailscaleServeOptions) -> Result { if options.max_attempts == 0 { diff --git a/crates/labby/tests/auth_admin_api.rs b/crates/labby/tests/auth_admin_api.rs index d5ad92b1e..f71996245 100644 --- a/crates/labby/tests/auth_admin_api.rs +++ b/crates/labby/tests/auth_admin_api.rs @@ -138,6 +138,7 @@ impl Harness { sub: "jwt-user".to_string(), aud: "https://lab.example.com/mcp".to_string(), exp: now + 3600, + nbf: None, iat: now, jti: "test-jti".to_string(), scope: "lab".to_string(), diff --git a/crates/labby/tests/stdio_proxy_runtime.rs b/crates/labby/tests/stdio_proxy_runtime.rs index b0c5e1a49..e6a7949f2 100644 --- a/crates/labby/tests/stdio_proxy_runtime.rs +++ b/crates/labby/tests/stdio_proxy_runtime.rs @@ -2,11 +2,15 @@ use std::ffi::OsString; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use labby::proxy::command::ProxyCommand; use labby::proxy::config::{ProxyAuthMode, ProxyExposure, ProxyPortPreference, ProxyPreferences}; -use labby::proxy::runtime::{LocalProxy, LocalProxyOptions}; +use labby::proxy::runtime::{LocalProxy, LocalProxyAuthPolicy, LocalProxyOptions}; +use labby_auth::config::{AuthConfig, AuthMode}; +use labby_auth::jwt::AccessClaims; +use labby_auth::state::AuthState; use rmcp::service::{ClientLifecycleMode, ClientServiceExt}; use rmcp::transport::streamable_http_client::{ StreamableHttpClientTransportConfig, StreamableHttpClientWorker, @@ -176,6 +180,259 @@ async fn local_proxy_forwards_tools_list_after_child_discovery_and_bind() { proxy.shutdown().await.expect("clean proxy shutdown"); } +#[tokio::test] +async fn prepared_listener_accepts_no_http_requests_before_router_start() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let temp = tempfile::tempdir().unwrap(); + let pid_file = temp.path().join("prepared-child.pid"); + let prepared = LocalProxy::prepare(LocalProxyOptions { + command: fixture_command(temp.path().to_path_buf(), &pid_file), + preferences: local_preferences(ProxyAuthMode::None), + bearer_token: None, + explicit_env: Vec::new(), + inherit_env: vec![OsString::from("PATH")], + }) + .await + .expect("child discovery and loopback bind complete"); + + let mut stream = tokio::net::TcpStream::connect(prepared.local_addr()) + .await + .expect("kernel listener is bound"); + stream + .write_all(b"GET /custom-mcp HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + let mut byte = [0_u8; 1]; + assert!( + tokio::time::timeout(Duration::from_millis(100), stream.read(&mut byte)) + .await + .is_err(), + "prepared listener must not accept or answer HTTP before auth is finalized" + ); + drop(stream); + + let proxy = prepared + .start(LocalProxyAuthPolicy::None) + .expect("router starts from the already-bound listener"); + let service = connect(proxy.url(), None).await; + assert_eq!(service.peer().list_all_tools().await.unwrap().len(), 1); + proxy.shutdown().await.unwrap(); +} + +async fn oauth_state(temp: &tempfile::TempDir) -> Arc { + let config = AuthConfig { + mode: AuthMode::OAuth, + public_url: Some(url::Url::parse("https://issuer.example.com").unwrap()), + sqlite_path: temp.path().join("auth.db"), + key_path: temp.path().join("auth-jwt.pem"), + scopes_supported: vec!["mcp:read".to_string(), "mcp:write".to_string()], + disable_static_token_with_oauth: true, + ..AuthConfig::default() + }; + Arc::new(AuthState::new(config).await.unwrap()) +} + +fn oauth_claims(resource: &str, issuer: &str, scope: &str) -> AccessClaims { + let now = usize::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ) + .unwrap(); + AccessClaims { + iss: issuer.to_string(), + sub: "subject-must-not-be-logged".to_string(), + aud: resource.to_string(), + exp: now + 300, + nbf: None, + iat: now, + jti: "jwt-id-must-not-be-logged".to_string(), + scope: scope.to_string(), + azp: "proxy-test".to_string(), + } +} + +async fn oauth_request( + proxy: &LocalProxy, + resource: &url::Url, + token: Option<&str>, +) -> reqwest::Response { + let mut request = reqwest::Client::new() + .post(proxy.url().clone()) + .header(reqwest::header::HOST, resource.authority()) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header( + reqwest::header::ACCEPT, + "application/json, text/event-stream", + ) + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/list") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 91, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name":"oauth-test","version":"1"}, + "io.modelcontextprotocol/clientCapabilities": {} + } + } + })); + if let Some(token) = token { + request = request.bearer_auth(token); + } + request.send().await.unwrap() +} + +#[tokio::test] +async fn oauth_proxy_serves_exact_root_metadata_and_enforces_token_contract() { + let temp = tempfile::tempdir().unwrap(); + let state = oauth_state(&temp).await; + let resource = url::Url::parse("https://node.example.ts.net:53147/custom-mcp").unwrap(); + let issuer = url::Url::parse("https://issuer.example.com").unwrap(); + let stable_issuer = issuer.as_str().trim_end_matches('/'); + let prepared = LocalProxy::prepare(LocalProxyOptions { + command: fixture_command( + temp.path().to_path_buf(), + &temp.path().join("oauth-child.pid"), + ), + preferences: local_preferences(ProxyAuthMode::Oauth), + bearer_token: None, + explicit_env: Vec::new(), + inherit_env: vec![OsString::from("PATH")], + }) + .await + .unwrap(); + let proxy = prepared + .start(LocalProxyAuthPolicy::Oauth { + auth_state: Arc::clone(&state), + resource: resource.clone(), + issuer: issuer.clone(), + required_scopes: vec!["mcp:read".to_string(), "mcp:write".to_string()], + }) + .unwrap(); + + let metadata_url = proxy + .url() + .join("/.well-known/oauth-protected-resource") + .unwrap(); + let metadata: serde_json::Value = reqwest::Client::new() + .get(metadata_url) + .header(reqwest::header::HOST, resource.authority()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + metadata, + serde_json::json!({ + "resource": "https://node.example.ts.net:53147/custom-mcp", + "authorization_servers": ["https://issuer.example.com"], + "scopes_supported": ["mcp:read", "mcp:write"], + "bearer_methods_supported": ["header"] + }) + ); + + let missing = oauth_request(&proxy, &resource, None).await; + assert_eq!(missing.status(), reqwest::StatusCode::UNAUTHORIZED); + assert_eq!( + missing.headers()[reqwest::header::WWW_AUTHENTICATE], + "Bearer resource_metadata=\"https://node.example.ts.net:53147/.well-known/oauth-protected-resource\", scope=\"mcp:read mcp:write\"" + ); + + let accepted = state + .signing_keys + .issue_access_token(&oauth_claims( + resource.as_str(), + stable_issuer, + "mcp:read mcp:write", + )) + .unwrap(); + assert_eq!( + oauth_request(&proxy, &resource, Some(&accepted)) + .await + .status(), + reqwest::StatusCode::OK + ); + + let now = usize::try_from( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(), + ) + .unwrap(); + let mut cases = vec![ + ( + "wrong port", + oauth_claims( + "https://node.example.ts.net:53148/custom-mcp", + stable_issuer, + "mcp:read mcp:write", + ), + reqwest::StatusCode::UNAUTHORIZED, + ), + ( + "wrong path", + oauth_claims( + "https://node.example.ts.net:53147/other", + stable_issuer, + "mcp:read mcp:write", + ), + reqwest::StatusCode::UNAUTHORIZED, + ), + ( + "wrong issuer", + oauth_claims( + resource.as_str(), + "https://other-issuer.example.com", + "mcp:read mcp:write", + ), + reqwest::StatusCode::UNAUTHORIZED, + ), + ( + "wrong scope", + oauth_claims(resource.as_str(), stable_issuer, "mcp:read"), + reqwest::StatusCode::FORBIDDEN, + ), + ( + "expired", + { + let mut c = oauth_claims(resource.as_str(), stable_issuer, "mcp:read mcp:write"); + c.exp = now - 300; + c + }, + reqwest::StatusCode::UNAUTHORIZED, + ), + ( + "not before", + { + let mut c = oauth_claims(resource.as_str(), stable_issuer, "mcp:read mcp:write"); + c.nbf = Some(now + 300); + c + }, + reqwest::StatusCode::UNAUTHORIZED, + ), + ]; + for (name, claims, expected) in cases.drain(..) { + let token = state.signing_keys.issue_access_token(&claims).unwrap(); + assert_eq!( + oauth_request(&proxy, &resource, Some(&token)) + .await + .status(), + expected, + "{name}" + ); + } + + proxy.shutdown().await.unwrap(); +} + #[tokio::test] async fn local_proxy_honors_a_fixed_port() { let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -317,6 +574,284 @@ async fn cli_prints_real_url_serves_tools_and_stops_cleanly_on_sigint() { assert!(status.success(), "CLI exited with {status}"); } +#[cfg(unix)] +#[tokio::test] +async fn cli_local_oauth_fails_clearly_when_loopback_leases_are_not_enabled() { + use tokio::process::Command; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + #[derive(Clone)] + struct LeaseResponder; + impl Respond for LeaseResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + match body["action"].as_str().unwrap() { + "gateway.oauth.resource_lease.create" => { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "lease-id-must-not-appear", + "resource": body["params"]["resource"], + "scopes": body["params"]["scopes"], + "expires_at_unix": 4_000_000_000_u64 + })) + } + "gateway.oauth.resource_lease.release" => { + ResponseTemplate::new(200).set_body_json(serde_json::json!({"released": true})) + } + other => ResponseTemplate::new(500).set_body_string(other.to_string()), + } + } + } + + let temp = tempfile::tempdir().unwrap(); + let key_path = temp.path().join("auth-jwt.pem"); + let keys = labby_auth::jwt::SigningKeys::load_or_create(&key_path).unwrap(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/health")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/.well-known/labby.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "paletteCatalogUrl": format!("{}/catalog", server.uri()), + "paletteExecuteUrl": format!("{}/execute", server.uri()) + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/v1/gateway/actions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + {"name":"gateway.oauth.resource_lease.create"}, + {"name":"gateway.oauth.resource_lease.renew"}, + {"name":"gateway.oauth.resource_lease.release"} + ]))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "issuer": server.uri(), + "jwks_uri": format!("{}/jwks", server.uri()) + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(keys.jwks())) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/gateway")) + .respond_with(LeaseResponder) + .mount(&server) + .await; + + let pid_file = temp.path().join("oauth-cli-child.pid"); + let child = Command::new(env!("CARGO_BIN_EXE_labby")) + .args(["--json", "proxy", "--local", "--auth", "oauth"]) + .arg(env!("CARGO_BIN_EXE_stdio-mcp-fixture")) + .arg("--pid-file") + .arg(&pid_file) + .env("LABBY_HOME", temp.path()) + .env("LABBY_PUBLIC_URL", server.uri()) + .env("LABBY_AUTH_MODE", "oauth") + .env("LABBY_AUTH_SQLITE_PATH", temp.path().join("auth.db")) + .env("LABBY_AUTH_KEY_PATH", &key_path) + .env("LABBY_AUTH_ADMIN_EMAIL", "admin@example.com") + .env("LABBY_GOOGLE_CLIENT_ID", "test-client") + .env("LABBY_GOOGLE_CLIENT_SECRET", "test-secret") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + + let output = tokio::time::timeout(Duration::from_secs(5), child.wait_with_output()) + .await + .expect("local OAuth rejection did not stop promptly") + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("local OAuth exposure is not enabled")); + assert!(!stderr.contains("lease-id-must-not-appear")); + assert!(!stderr.contains("subject-must-not-be-logged")); + + let requests = server.received_requests().await.unwrap(); + assert!(!requests.iter().any(|request| { + serde_json::from_slice::(&request.body) + .ok() + .is_some_and(|body| body["action"] == "gateway.oauth.resource_lease.create") + })); +} + +#[cfg(unix)] +#[tokio::test] +async fn cli_tailscale_oauth_collision_releases_old_lease_then_renewal_failure_cleans_all() { + use std::os::unix::fs::PermissionsExt; + use tokio::io::{AsyncBufReadExt, BufReader}; + use tokio::process::Command; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + #[derive(Clone)] + struct LeaseResponder; + impl Respond for LeaseResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let body: serde_json::Value = serde_json::from_slice(&request.body).unwrap(); + match body["action"].as_str().unwrap() { + "gateway.oauth.resource_lease.create" => { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": format!("lease-for-{}", body["params"]["resource"].as_str().unwrap()), + "resource": body["params"]["resource"], + "scopes": body["params"]["scopes"], + "expires_at_unix": 4_000_000_000_u64 + })) + } + "gateway.oauth.resource_lease.release" => { + ResponseTemplate::new(200).set_body_json(serde_json::json!({"released": true})) + } + other => ResponseTemplate::new(500).set_body_string(other.to_string()), + } + } + } + + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + let tailscale = root.join("tailscale"); + std::fs::write( + &tailscale, + format!( + r#"#!/usr/bin/env bash +set -u +root='{root}' +mapping="$root/mapping" +if [[ "${{1:-}}" == "version" ]]; then echo 1.98.10; exit 0; fi +if [[ "${{1:-}} ${{2:-}}" == "status --json" ]]; then + echo '{{"BackendState":"Running","Self":{{"Online":true,"DNSName":"node.example.ts.net."}}}}'; exit 0 +fi +if [[ "${{1:-}} ${{2:-}} ${{3:-}}" == "serve status --json" ]]; then + if [[ -f "$mapping" ]]; then IFS='|' read -r port backend < "$mapping"; printf '{{"Web":{{"node.example.ts.net:%s":{{"Handlers":{{"/":{{"Proxy":"%s"}}}}}}}}}}\n' "$port" "$backend"; else echo '{{}}'; fi + exit 0 +fi +if [[ "${{1:-}}" == "serve" ]]; then + port="${{3#--https=}}" + if [[ "${{4:-}}" == "off" ]]; then rm -f "$mapping"; exit 0; fi + count=0; [[ -f "$root/claims" ]] && count=$(<"$root/claims"); count=$((count+1)); echo "$count" > "$root/claims" + if [[ "$count" == 1 ]]; then echo 'port already configured' >&2; exit 1; fi + printf '%s|%s\n' "$port" "${{4:-}}" > "$mapping" + trap 'rm -f "$mapping"; exit 0' TERM INT + while :; do sleep 0.02; done +fi +exit 2 +"#, + root = root.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&tailscale, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let key_path = root.join("auth-jwt.pem"); + let keys = labby_auth::jwt::SigningKeys::load_or_create(&key_path).unwrap(); + let server = MockServer::start().await; + for endpoint in ["/health", "/.well-known/labby.json"] { + let body = if endpoint == "/health" { + serde_json::json!({"status":"ok"}) + } else { + serde_json::json!({"paletteCatalogUrl":"catalog","paletteExecuteUrl":"execute"}) + }; + Mock::given(method("GET")) + .and(path(endpoint)) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) + .mount(&server) + .await; + } + Mock::given(method("GET")).and(path("/v1/gateway/actions")).respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + {"name":"gateway.oauth.resource_lease.create"},{"name":"gateway.oauth.resource_lease.renew"},{"name":"gateway.oauth.resource_lease.release"} + ]))).mount(&server).await; + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server")) + .respond_with(ResponseTemplate::new(200).set_body_json( + serde_json::json!({"issuer":server.uri(),"jwks_uri":format!("{}/jwks",server.uri())}), + )) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(keys.jwks())) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/gateway")) + .respond_with(LeaseResponder) + .mount(&server) + .await; + + let child_pid_file = root.join("renewal-child.pid"); + let mut child = Command::new(env!("CARGO_BIN_EXE_labby")) + .args(["--json", "proxy", "--auth", "oauth"]) + .arg(env!("CARGO_BIN_EXE_stdio-mcp-fixture")) + .arg("--pid-file") + .arg(&child_pid_file) + .env("LABBY_HOME", root) + .env("LABBY_PUBLIC_URL", server.uri()) + .env("LABBY_AUTH_MODE", "oauth") + .env("LABBY_AUTH_SQLITE_PATH", root.join("auth.db")) + .env("LABBY_AUTH_KEY_PATH", &key_path) + .env("LABBY_AUTH_ADMIN_EMAIL", "admin@example.com") + .env("LABBY_GOOGLE_CLIENT_ID", "test-client") + .env("LABBY_GOOGLE_CLIENT_SECRET", "test-secret") + .env("LABBY_TAILSCALE_BIN", &tailscale) + .env("LABBY_PROXY_TEST_RENEW_MS", "100") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let stdout = child.stdout.take().unwrap(); + let line = tokio::time::timeout( + Duration::from_secs(10), + BufReader::new(stdout).lines().next_line(), + ) + .await + .unwrap() + .unwrap() + .expect("ready output"); + let ready: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(ready["auth"], "oauth"); + assert_eq!(ready["exposure"], "tailscale"); + let output = tokio::time::timeout(Duration::from_secs(5), child.wait_with_output()) + .await + .expect("renewal failure did not terminate proxy") + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("renewal failed")); + + let requests = server.received_requests().await.unwrap(); + let actions = requests + .iter() + .filter_map(|request| serde_json::from_slice::(&request.body).ok()) + .filter(|body| body.get("action").is_some()) + .collect::>(); + let created = actions + .iter() + .filter(|body| body["action"] == "gateway.oauth.resource_lease.create") + .map(|body| body["params"]["resource"].as_str().unwrap()) + .collect::>(); + let released = actions + .iter() + .filter(|body| body["action"] == "gateway.oauth.resource_lease.release") + .count(); + assert_eq!(created.len(), 2); + assert_ne!(created[0], created[1]); + assert_eq!(released, 2); + assert!(!root.join("mapping").exists()); + let child_pid: i32 = std::fs::read_to_string(child_pid_file) + .unwrap() + .parse() + .unwrap(); + assert!(nix::sys::signal::kill(nix::unistd::Pid::from_raw(child_pid), None).is_err()); +} + #[cfg(unix)] #[tokio::test] async fn zero_flag_cli_publishes_verified_tailscale_url_with_fake_cli() { diff --git a/crates/labby/tests/tailscale_serve.rs b/crates/labby/tests/tailscale_serve.rs index 7eb52b2b0..6501b7299 100644 --- a/crates/labby/tests/tailscale_serve.rs +++ b/crates/labby/tests/tailscale_serve.rs @@ -2,8 +2,8 @@ use labby::proxy::config::ProxyPortPreference; use labby::proxy::tailscale::{ - ServeStatus, TailscaleServe, TailscaleServeOptions, TailscaleStatus, build_public_url, - select_port_from_candidates, + ServeStatus, TailscaleClaimError, TailscaleServe, TailscaleServeOptions, TailscaleServePlan, + TailscaleStatus, build_public_url, select_port_from_candidates, }; use std::fs; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; @@ -141,8 +141,10 @@ exit 2 "#, root = root.display() ); - fs::write(&executable, script).unwrap(); - fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap(); + let staged_executable = root.join("tailscale.staged"); + fs::write(&staged_executable, script).unwrap(); + fs::set_permissions(&staged_executable, fs::Permissions::from_mode(0o755)).unwrap(); + fs::rename(&staged_executable, &executable).unwrap(); Self { _temp: temp, executable, @@ -220,6 +222,46 @@ async fn foreground_serve_is_ready_only_after_exact_mapping_and_cleans_normally( ); } +#[cfg(unix)] +#[tokio::test] +async fn plan_exposes_exact_url_before_foreground_claim() { + let fake = FakeTailscale::new(); + let options = fake.options(vec![54_000]); + let plan = TailscaleServePlan::prepare(options).await.unwrap(); + + assert_eq!(plan.external_port(), 54_000); + assert_eq!( + plan.public_url().as_str(), + "https://dookie.example.ts.net:54000/mcp" + ); + assert!( + mapping(&fake.root).is_none(), + "planning must not claim Serve" + ); + + let serve = plan.claim().await.unwrap(); + assert_eq!( + mapping(&fake.root).as_deref(), + Some("54000|http://127.0.0.1:38417\n") + ); + serve.shutdown().await.unwrap(); +} + +#[cfg(unix)] +#[tokio::test] +async fn planned_claim_reports_a_real_collision_as_typed_error() { + let fake = FakeTailscale::new(); + fs::write(fake.root.join("collision_ports"), "54000\n").unwrap(); + let plan = TailscaleServePlan::prepare(fake.options(vec![54_000])) + .await + .unwrap(); + assert!(matches!( + plan.claim_typed().await.unwrap_err(), + TailscaleClaimError::Collision(_) + )); + assert!(mapping(&fake.root).is_none()); +} + #[cfg(unix)] #[tokio::test] async fn real_serve_collision_retries_a_different_candidate() { diff --git a/docs/contracts/stdio-mcp-proxy.md b/docs/contracts/stdio-mcp-proxy.md index 9ec820a7e..b5b4f0acf 100644 --- a/docs/contracts/stdio-mcp-proxy.md +++ b/docs/contracts/stdio-mcp-proxy.md @@ -108,8 +108,7 @@ The endpoint preserves child MCP results and errors. Labby-generated failures us For OAuth mode, the proxy serves: ```text -GET /.well-known/oauth-protected-resource -GET /.well-known/oauth-protected-resource +GET /.well-known/oauth-protected-resource ``` The response shape is: @@ -126,7 +125,7 @@ The response shape is: An unauthenticated request returns HTTP 401 with: ```text -WWW-Authenticate: Bearer resource_metadata="https://node.example.ts.net:53147/.well-known/oauth-protected-resource/mcp", scope="mcp:read mcp:write" +WWW-Authenticate: Bearer resource_metadata="https://node.example.ts.net:53147/.well-known/oauth-protected-resource", scope="mcp:read mcp:write" ``` A token with insufficient scope returns HTTP 403 and an `insufficient_scope` challenge. Issuer and audience mismatches return 401. @@ -184,7 +183,7 @@ Response: HTTP 204. Releasing an unknown lease is idempotent and also returns 20 } ``` -Resource URLs must be absolute HTTPS URLs without credentials, query, or fragment. Loopback HTTP resources are allowed only for explicitly local development mode and are never accepted by the public daemon route. +Resource URLs must be absolute HTTPS URLs without credentials, query, or fragment. The current daemon lease action does not accept loopback HTTP resources, so `labby proxy --local --auth oauth` fails clearly until an explicit local-development lease policy is added; it never downgrades auth. ## Tailscale ownership diff --git a/docs/specs/stdio-mcp-proxy.md b/docs/specs/stdio-mcp-proxy.md index 54ccb3246..253f11425 100644 --- a/docs/specs/stdio-mcp-proxy.md +++ b/docs/specs/stdio-mcp-proxy.md @@ -300,6 +300,9 @@ No application bearer challenge is added. Reachability is controlled by Tailscal - The proxy renews the lease while alive and releases it during normal shutdown. - Expired leases are ignored and pruned. - The proxy serves RFC 9728 Protected Resource Metadata and a matching `WWW-Authenticate` challenge. +- Metadata is served unauthenticated at the proxy origin root, + `/.well-known/oauth-protected-resource`; the challenge points to that exact + root document rather than a path beneath `/mcp`. - Failure to create or renew a lease terminates OAuth startup or the running proxy; there is no downgrade. ## Tailscale exposure From d2e5d3f1fcdd27d5f244fd20b0281569a83c91b2 Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Sat, 1 Aug 2026 12:46:47 -0400 Subject: [PATCH 7/8] feat(setup): configure and diagnose proxy defaults --- crates/labby/src/cli/doctor.rs | 23 +- crates/labby/src/cli/setup.rs | 249 +++++++ crates/labby/src/dispatch/doctor.rs | 1 + crates/labby/src/dispatch/doctor/catalog.rs | 8 + crates/labby/src/dispatch/doctor/dispatch.rs | 5 + crates/labby/src/dispatch/doctor/preflight.rs | 404 ++++++++++++ crates/labby/src/dispatch/setup.rs | 1 + crates/labby/src/dispatch/setup/catalog.rs | 29 +- crates/labby/src/dispatch/setup/dispatch.rs | 12 + crates/labby/src/dispatch/setup/proxy.rs | 283 ++++++++ crates/labby/src/proxy/command.rs | 8 +- crates/labby/src/proxy/config.rs | 2 +- crates/labby/src/proxy/tailscale.rs | 17 +- crates/labby/tests/doctor_proxy_preflight.rs | 619 ++++++++++++++++++ crates/labby/tests/setup_proxy.rs | 213 ++++++ 15 files changed, 1865 insertions(+), 9 deletions(-) create mode 100644 crates/labby/src/dispatch/doctor/preflight.rs create mode 100644 crates/labby/src/dispatch/setup/proxy.rs create mode 100644 crates/labby/tests/doctor_proxy_preflight.rs create mode 100644 crates/labby/tests/setup_proxy.rs diff --git a/crates/labby/src/cli/doctor.rs b/crates/labby/src/cli/doctor.rs index 7a30ec55a..8b18a8c1b 100644 --- a/crates/labby/src/cli/doctor.rs +++ b/crates/labby/src/cli/doctor.rs @@ -47,7 +47,7 @@ pub struct DoctorProxyArgs { pub mcp_url: Option, /// Protected MCP public route path, e.g. /telemetry #[arg(long)] - pub route: String, + pub route: Option, /// Optional private backend origin for backend-leak probe, e.g. http://mcp-backend:3100 #[arg(long)] pub backend_url: Option, @@ -212,6 +212,25 @@ async fn load_optional_public_relay_manager() } async fn run_proxy(args: DoctorProxyArgs, format: OutputFormat) -> Result { + let Some(route) = args.route else { + let value = crate::dispatch::doctor::dispatch("proxy.preflight", serde_json::json!({})) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + let report: Report = serde_json::from_value(value)?; + + if format.is_json() { + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(exit_code(&report)); + } + + let theme = CliTheme::from_context(format.render_context()); + print_section(theme, "Stdio proxy preflight"); + for finding in &report.findings { + print_finding_indented(theme, finding); + } + println!(); + return Ok(exit_code(&report)); + }; let app_url = args .app_url .or_else(|| { @@ -231,7 +250,7 @@ async fn run_proxy(args: DoctorProxyArgs, format: OutputFormat) -> Result, + /// Authentication mode to persist. + #[arg(long, value_enum)] + pub auth: Option, + /// MCP HTTP path to persist. + #[arg(long)] + pub path: Option, + /// External Tailscale port, or `random`. + #[arg(long)] + pub port: Option, + /// First candidate in the random external-port range. + #[arg(long)] + pub port_range_start: Option, + /// Last candidate in the random external-port range. + #[arg(long)] + pub port_range_end: Option, + /// Environment key used for the proxy bearer secret. + #[arg(long)] + pub bearer_token_env: Option, + /// OAuth scope to require; repeatable and replaces the configured list. + #[arg(long = "oauth-scope")] + pub oauth_scopes: Vec, + /// Ambient environment variable inherited by child servers; repeatable. + #[arg(long = "inherit-env")] + pub inherit_env: Vec, + /// Grace period before forced child shutdown. + #[arg(long)] + pub shutdown_grace_ms: Option, + /// Read a bearer secret from stdin without echoing or persisting it in TOML. + #[arg(long)] + pub bearer_token_stdin: bool, + /// Accept existing values and built-in defaults without prompting. + #[arg(short = 'y', long, alias = "no-confirm")] + pub yes: bool, + /// Preview exact file changes without mutating config or secret files. + #[arg(long)] + pub dry_run: bool, +} + #[derive(Debug, Args)] pub struct IncusBackupArgs { #[command(subcommand)] @@ -616,6 +661,9 @@ async fn run_command(command: SetupCommand, format: OutputFormat) -> Result { + run_setup_proxy(args, format).await?; + } SetupCommand::Incusbackup(args) => { run_incus_backup_command(args, format).await?; } @@ -636,6 +684,191 @@ async fn run_command(command: SetupCommand, format: OutputFormat) -> Result Result<()> { + if !args.yes && !args.dry_run && !io::stdin().is_terminal() { + anyhow::bail!("setup proxy requires --yes when stdin is not a TTY"); + } + + let home = crate::dispatch::helpers::lab_home(); + let config_path = home.join("config.toml"); + let mut preferences = crate::config::load_toml(&[config_path])?.proxy; + apply_proxy_setup_overrides(&mut preferences, &args)?; + if !args.yes && !args.dry_run { + let stdin = io::stdin(); + let mut input = stdin.lock(); + let mut output = io::stderr().lock(); + preferences = prompt_proxy_preferences(preferences, &mut input, &mut output)?; + } + + let bearer_token = if args.bearer_token_stdin { + let mut token = String::new(); + io::stdin().read_line(&mut token)?; + let token = token.trim().to_string(); + if token.is_empty() { + anyhow::bail!("--bearer-token-stdin received an empty token"); + } + Some(token) + } else { + None + }; + let params = json!({ + "preferences": preferences, + "bearer_token": bearer_token, + "dry_run": args.dry_run, + }); + let value = crate::dispatch::setup::dispatch("proxy.configure", params).await?; + print(&value, format)?; + Ok(()) +} + +fn apply_proxy_setup_overrides( + preferences: &mut crate::proxy::config::ProxyPreferences, + args: &SetupProxyArgs, +) -> Result<()> { + if let Some(exposure) = args.exposure { + preferences.exposure = exposure; + } + if let Some(auth) = args.auth { + preferences.auth = auth; + } + if args.bearer_token_stdin { + preferences.auth = crate::proxy::config::ProxyAuthMode::Bearer; + } + if let Some(path) = &args.path { + preferences.path.clone_from(path); + } + if let Some(port) = &args.port { + preferences.port = parse_proxy_setup_port(port)?; + } + if let Some(start) = args.port_range_start { + preferences.port_range_start = start; + } + if let Some(end) = args.port_range_end { + preferences.port_range_end = end; + } + if let Some(key) = &args.bearer_token_env { + preferences.bearer_token_env.clone_from(key); + } + if !args.oauth_scopes.is_empty() { + preferences.oauth_scopes.clone_from(&args.oauth_scopes); + } + if !args.inherit_env.is_empty() { + preferences.inherit_env.clone_from(&args.inherit_env); + } + if let Some(grace) = args.shutdown_grace_ms { + preferences.shutdown_grace_ms = grace; + } + preferences.validate().map_err(anyhow::Error::from) +} + +fn parse_proxy_setup_port(value: &str) -> Result { + if value.eq_ignore_ascii_case("random") { + return Ok(crate::proxy::config::ProxyPortPreference::default()); + } + let port = value + .parse::() + .map_err(|_| anyhow::anyhow!("--port must be `random` or an integer from 1 to 65535"))?; + if port == 0 { + anyhow::bail!("--port must not be zero"); + } + Ok(crate::proxy::config::ProxyPortPreference::Fixed(port)) +} + +fn prompt_proxy_preferences( + mut preferences: crate::proxy::config::ProxyPreferences, + input: &mut impl io::BufRead, + output: &mut impl Write, +) -> Result { + use crate::proxy::config::{ProxyAuthMode, ProxyExposure}; + + let exposure = prompt_value( + input, + output, + "Exposure (tailscale/local)", + match preferences.exposure { + ProxyExposure::Tailscale => "tailscale", + ProxyExposure::Local => "local", + }, + )?; + preferences.exposure = match exposure.to_ascii_lowercase().as_str() { + "tailscale" => ProxyExposure::Tailscale, + "local" => ProxyExposure::Local, + _ => anyhow::bail!("exposure must be `tailscale` or `local`"), + }; + let auth = prompt_value( + input, + output, + "Auth (tailnet/bearer/oauth/none)", + match preferences.auth { + ProxyAuthMode::Tailnet => "tailnet", + ProxyAuthMode::Bearer => "bearer", + ProxyAuthMode::Oauth => "oauth", + ProxyAuthMode::None => "none", + }, + )?; + preferences.auth = match auth.to_ascii_lowercase().as_str() { + "tailnet" => ProxyAuthMode::Tailnet, + "bearer" => ProxyAuthMode::Bearer, + "oauth" => ProxyAuthMode::Oauth, + "none" => ProxyAuthMode::None, + _ => anyhow::bail!("auth must be `tailnet`, `bearer`, `oauth`, or `none`"), + }; + preferences.path = prompt_value(input, output, "MCP path", &preferences.path)?; + let default_port = preferences + .port + .fixed() + .map_or_else(|| "random".to_string(), |port| port.to_string()); + preferences.port = parse_proxy_setup_port(&prompt_value( + input, + output, + "External port", + &default_port, + )?)?; + if preferences.port.fixed().is_none() { + preferences.port_range_start = prompt_value( + input, + output, + "Random port range start", + &preferences.port_range_start.to_string(), + )? + .parse()?; + preferences.port_range_end = prompt_value( + input, + output, + "Random port range end", + &preferences.port_range_end.to_string(), + )? + .parse()?; + } + preferences.validate().map_err(anyhow::Error::from)?; + writeln!( + output, + "Proxy defaults selected; secrets will not be displayed." + )?; + Ok(preferences) +} + +fn prompt_value( + input: &mut impl io::BufRead, + output: &mut impl Write, + label: &str, + default: &str, +) -> Result { + write!(output, "{label} [{default}]: ")?; + output.flush()?; + let mut answer = String::new(); + let read = input.read_line(&mut answer)?; + if read == 0 { + anyhow::bail!("setup proxy input ended before configuration was complete"); + } + let answer = answer.trim(); + Ok(if answer.is_empty() { + default.to_string() + } else { + answer.to_string() + }) +} + async fn run_incus_ssh_command(args: IncusSshArgs, format: OutputFormat) -> Result<()> { match args.command { IncusSshCommand::Bootstrap { @@ -1094,6 +1327,22 @@ mod tests { use super::*; use clap::Parser as _; + #[test] + fn interactive_proxy_setup_preserves_case_sensitive_path() { + let mut input = io::Cursor::new("local\nnone\n/McpServer\nrandom\n50000\n51000\n"); + let mut output = Vec::new(); + let preferences = prompt_proxy_preferences( + crate::proxy::config::ProxyPreferences::default(), + &mut input, + &mut output, + ) + .expect("interactive proxy preferences"); + + assert_eq!(preferences.path, "/McpServer"); + assert_eq!(preferences.port_range_start, 50_000); + assert_eq!(preferences.port_range_end, 51_000); + } + #[tokio::test] async fn no_setup_flag_exits_cleanly() { let code = run( diff --git a/crates/labby/src/dispatch/doctor.rs b/crates/labby/src/dispatch/doctor.rs index 836c67e49..dae5554b1 100644 --- a/crates/labby/src/dispatch/doctor.rs +++ b/crates/labby/src/dispatch/doctor.rs @@ -9,6 +9,7 @@ mod client; mod dispatch; pub mod gateway; mod params; +mod preflight; pub mod proxy; mod relay; pub mod service; diff --git a/crates/labby/src/dispatch/doctor/catalog.rs b/crates/labby/src/dispatch/doctor/catalog.rs index fc16762a5..859d9fe86 100644 --- a/crates/labby/src/dispatch/doctor/catalog.rs +++ b/crates/labby/src/dispatch/doctor/catalog.rs @@ -46,6 +46,14 @@ pub const ACTIONS: &[ActionSpec] = &[ returns: "DoctorReport", params: &[], }, + ActionSpec { + name: "proxy.preflight", + description: "Validate persisted stdio-proxy configuration and read-only local dependencies", + destructive: false, + requires_admin: false, + returns: "DoctorReport", + params: &[], + }, ActionSpec { name: "proxy.check", description: "Check public Lab and protected MCP proxy endpoints from caller-visible URLs. \ diff --git a/crates/labby/src/dispatch/doctor/dispatch.rs b/crates/labby/src/dispatch/doctor/dispatch.rs index 73d5ebe0e..0a5bcdc0c 100644 --- a/crates/labby/src/dispatch/doctor/dispatch.rs +++ b/crates/labby/src/dispatch/doctor/dispatch.rs @@ -11,6 +11,7 @@ use crate::dispatch::helpers::{action_schema, help_payload, to_json}; use super::catalog::ACTIONS; use super::gateway; use super::params::{parse_proxy_check, parse_relay_check}; +use super::preflight; use super::proxy; use super::service; use super::system; @@ -49,6 +50,9 @@ pub async fn dispatch(action: &str, params: Value) -> Result { let p = parse_proxy_check(¶ms)?; return to_json(proxy::check_proxy(p).await?); } + "proxy.preflight" => { + return to_json(preflight::check_proxy_preflight().await); + } // "oauth.relay.check" intentionally has no early-return arm here: // it falls through to `dispatch_with_clients_and_relay` below (which // already handles it, passing the same `current_public_relay_manager()` @@ -123,6 +127,7 @@ pub async fn dispatch_with_clients_and_relay( let p = parse_proxy_check(¶ms)?; to_json(proxy::check_proxy(p).await?) } + "proxy.preflight" => to_json(preflight::check_proxy_preflight().await), "oauth.relay.check" => { let p = parse_relay_check(¶ms)?; to_json(super::relay::check_public_relay(public_relay.clone(), p.probe_targets).await) diff --git a/crates/labby/src/dispatch/doctor/preflight.rs b/crates/labby/src/dispatch/doctor/preflight.rs new file mode 100644 index 000000000..14669dba2 --- /dev/null +++ b/crates/labby/src/dispatch/doctor/preflight.rs @@ -0,0 +1,404 @@ +//! Read-only preflight for persisted ephemeral stdio-proxy preferences. + +use std::ffi::OsStr; +use std::path::PathBuf; + +use crate::proxy::config::{ProxyAuthMode, ProxyExposure, ProxyPreferences}; +use crate::proxy::tailscale::{ServeStatus, TailscaleStatus}; + +use super::types::{Finding, Report, Severity}; + +fn finding(check: &str, severity: Severity, message: impl Into) -> Finding { + Finding { + service: "proxy".to_string(), + check: check.to_string(), + severity, + message: message.into(), + } +} + +fn dependency_failure(check: &str, dependency: &str) -> Finding { + finding( + check, + Severity::Fail, + format!("check unavailable because {dependency} failed"), + ) +} + +pub async fn check_proxy_preflight() -> Report { + let config = match crate::config::load() { + Ok(config) => config, + Err(error) => { + return Report { + findings: vec![finding( + "proxy:config", + Severity::Fail, + format!("persisted proxy configuration is invalid: {error:#}"), + )], + }; + } + }; + let preferences = &config.proxy; + let mut findings = vec![config_finding(preferences)]; + findings.extend(launcher_findings()); + findings.extend(auth_findings(&config, preferences).await); + if matches!(preferences.exposure, ProxyExposure::Local) { + findings.push(finding( + "proxy:tailscale-skipped", + Severity::Ok, + "local exposure does not require Tailscale", + )); + } else { + findings.extend(tailscale_findings().await); + } + Report { findings } +} + +fn config_finding(preferences: &ProxyPreferences) -> Finding { + match preferences.validate() { + Ok(()) => finding( + "proxy:config", + Severity::Ok, + format!( + "persisted proxy path and port selection are valid ({}, {}..={})", + preferences.path, preferences.port_range_start, preferences.port_range_end + ), + ), + Err(error) => finding( + "proxy:config", + Severity::Fail, + format!("persisted proxy configuration is invalid: {error}"), + ), + } +} + +fn launcher_findings() -> Vec { + let path = std::env::var_os("PATH"); + [ + ("proxy:launcher-node", "node"), + ("proxy:launcher-python3", "python3"), + ] + .into_iter() + .map(|(check, launcher)| { + let available = + crate::proxy::command::executable_on_path(OsStr::new(launcher), path.as_deref()) + .is_some(); + finding( + check, + if available { + Severity::Ok + } else { + Severity::Fail + }, + if available { + format!("`{launcher}` launcher is available") + } else { + format!("`{launcher}` launcher is not available on PATH") + }, + ) + }) + .collect() +} + +async fn auth_findings( + config: &crate::config::LabConfig, + preferences: &ProxyPreferences, +) -> Vec { + match preferences.auth { + ProxyAuthMode::None => vec![finding( + "proxy:auth-none", + Severity::Warn, + "proxy authentication is disabled", + )], + ProxyAuthMode::Bearer => { + let present = std::env::var_os(&preferences.bearer_token_env) + .is_some_and(|value| !value.is_empty()); + vec![finding( + "proxy:bearer-secret", + if present { + Severity::Ok + } else { + Severity::Fail + }, + if present { + format!( + "bearer secret is present in `{}`", + preferences.bearer_token_env + ) + } else { + format!( + "bearer secret is missing from `{}`", + preferences.bearer_token_env + ) + }, + )] + } + ProxyAuthMode::Tailnet => vec![finding( + "proxy:auth-tailnet", + Severity::Ok, + "tailnet identity authentication is selected", + )], + ProxyAuthMode::Oauth => oauth_findings(config).await, + } +} + +async fn oauth_findings(config: &crate::config::LabConfig) -> Vec { + let mut findings = Vec::new(); + let issuer = match crate::config::resolve_auth_for_config(config) { + Ok(auth) if matches!(auth.mode, labby_auth::config::AuthMode::OAuth) => auth.public_url, + Ok(_) => None, + Err(error) => { + findings.push(finding( + "proxy:oauth-stable-issuer", + Severity::Fail, + format!("OAuth configuration is invalid: {error}"), + )); + None + } + }; + let issuer = match issuer { + Some(issuer) => { + findings.push(finding( + "proxy:oauth-stable-issuer", + Severity::Ok, + format!("stable OAuth issuer is configured at {issuer}"), + )); + Some(issuer) + } + None => { + if !findings + .iter() + .any(|item| item.check == "proxy:oauth-stable-issuer") + { + findings.push(finding( + "proxy:oauth-stable-issuer", + Severity::Fail, + "proxy OAuth requires auth mode oauth and a stable public issuer", + )); + } + None + } + }; + + let Some(gateway) = crate::live_gateway::detect(config).await else { + findings.push(finding( + "proxy:oauth-daemon", + Severity::Fail, + "no live Labby daemon is reachable", + )); + for check in [ + "proxy:oauth-lease-create", + "proxy:oauth-lease-renew", + "proxy:oauth-lease-release", + "proxy:oauth-issuer-metadata", + "proxy:oauth-jwks", + ] { + findings.push(dependency_failure(check, "live daemon discovery")); + } + return findings; + }; + findings.push(finding( + "proxy:oauth-daemon", + Severity::Ok, + "live Labby daemon is reachable", + )); + + for (check, action) in [ + ( + "proxy:oauth-lease-create", + "gateway.oauth.resource_lease.create", + ), + ( + "proxy:oauth-lease-renew", + "gateway.oauth.resource_lease.renew", + ), + ( + "proxy:oauth-lease-release", + "gateway.oauth.resource_lease.release", + ), + ] { + match gateway.supports_action(action).await { + Ok(true) => findings.push(finding( + check, + Severity::Ok, + format!("live daemon supports `{action}`"), + )), + Ok(false) => findings.push(finding( + check, + Severity::Fail, + format!("live daemon does not support `{action}`"), + )), + Err(error) => findings.push(finding( + check, + Severity::Fail, + format!("could not inspect live daemon action catalog: {error}"), + )), + } + } + + let Some(issuer) = issuer else { + findings.push(dependency_failure( + "proxy:oauth-issuer-metadata", + "stable issuer configuration", + )); + findings.push(dependency_failure( + "proxy:oauth-jwks", + "stable issuer configuration", + )); + return findings; + }; + match gateway.verify_oauth_issuer(&issuer).await { + Ok(_) => { + findings.push(finding( + "proxy:oauth-issuer-metadata", + Severity::Ok, + "authorization-server metadata exactly matches the stable issuer", + )); + findings.push(finding( + "proxy:oauth-jwks", + Severity::Ok, + "issuer JWKS is reachable and valid", + )); + } + Err(error) => { + let message = error.to_string(); + let jwks_failure = message.contains("JWKS"); + findings.push(finding( + "proxy:oauth-issuer-metadata", + if jwks_failure { + Severity::Ok + } else { + Severity::Fail + }, + if jwks_failure { + "authorization-server metadata exactly matches the stable issuer".to_string() + } else { + format!("issuer metadata verification failed: {message}") + }, + )); + findings.push(finding( + "proxy:oauth-jwks", + Severity::Fail, + if jwks_failure { + format!("issuer JWKS verification failed: {message}") + } else { + "check unavailable because issuer metadata verification failed".to_string() + }, + )); + } + } + findings +} + +async fn tailscale_findings() -> Vec { + let executable = std::env::var_os("LABBY_TAILSCALE_BIN") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("tailscale")); + let mut findings = Vec::new(); + match crate::proxy::tailscale::run_checked(&executable, ["version"]).await { + Ok(version) if !version.trim().is_empty() => findings.push(finding( + "proxy:tailscale-version", + Severity::Ok, + format!("Tailscale CLI version {} is available", version.trim()), + )), + Ok(_) => findings.push(finding( + "proxy:tailscale-version", + Severity::Fail, + "Tailscale CLI returned an empty version", + )), + Err(error) => { + findings.push(finding( + "proxy:tailscale-version", + Severity::Fail, + format!("Tailscale CLI is unavailable: {error:#}"), + )); + for check in [ + "proxy:tailscale-running", + "proxy:tailscale-online", + "proxy:tailscale-dns", + "proxy:tailscale-https-serve", + ] { + findings.push(dependency_failure(check, "Tailscale executable/version")); + } + return findings; + } + } + + let status = match crate::proxy::tailscale::run_checked(&executable, ["status", "--json"]) + .await + .and_then(|raw| TailscaleStatus::parse(&raw)) + { + Ok(status) => status, + Err(error) => { + findings.push(finding( + "proxy:tailscale-running", + Severity::Fail, + format!("Tailscale status is unavailable or invalid: {error:#}"), + )); + for check in [ + "proxy:tailscale-online", + "proxy:tailscale-dns", + "proxy:tailscale-https-serve", + ] { + findings.push(dependency_failure(check, "Tailscale status")); + } + return findings; + } + }; + findings.push(finding( + "proxy:tailscale-running", + if status.backend_running() { + Severity::Ok + } else { + Severity::Fail + }, + if status.backend_running() { + "Tailscale backend state is Running" + } else { + "Tailscale backend state is not Running" + }, + )); + findings.push(finding( + "proxy:tailscale-online", + if status.online() { + Severity::Ok + } else { + Severity::Fail + }, + if status.online() { + "local Tailscale node is online" + } else { + "local Tailscale node is offline" + }, + )); + findings.push(finding( + "proxy:tailscale-dns", + if status.dns_name().is_empty() { + Severity::Fail + } else { + Severity::Ok + }, + if status.dns_name().is_empty() { + "local Tailscale node has no DNS name".to_string() + } else { + format!("local Tailscale DNS name is {}", status.dns_name()) + }, + )); + + match crate::proxy::tailscale::run_checked(&executable, ["serve", "status", "--json"]) + .await + .and_then(|raw| ServeStatus::parse(&raw)) + { + Ok(_) => findings.push(finding( + "proxy:tailscale-https-serve", + Severity::Ok, + "Tailscale HTTPS Serve status is readable without mutation", + )), + Err(error) => findings.push(finding( + "proxy:tailscale-https-serve", + Severity::Fail, + format!("Tailscale HTTPS Serve capability is unavailable: {error:#}"), + )), + } + findings +} diff --git a/crates/labby/src/dispatch/setup.rs b/crates/labby/src/dispatch/setup.rs index 220dbae20..21874fd9e 100644 --- a/crates/labby/src/dispatch/setup.rs +++ b/crates/labby/src/dispatch/setup.rs @@ -17,6 +17,7 @@ pub(crate) mod incus; mod params; mod plugin_hook; pub(crate) mod provision; +pub(crate) mod proxy; mod secret_mask; mod settings; mod state; diff --git a/crates/labby/src/dispatch/setup/catalog.rs b/crates/labby/src/dispatch/setup/catalog.rs index cdb19af47..8b4fa429e 100644 --- a/crates/labby/src/dispatch/setup/catalog.rs +++ b/crates/labby/src/dispatch/setup/catalog.rs @@ -28,7 +28,7 @@ pub const PLUGIN_LIFECYCLE_ACTIONS: &[&str] = &[ /// Setup actions that may only run from a trusted local transport. These /// either mint first-run credentials or initiate an outbound connectivity /// probe from the host, so an admin bearer alone is not sufficient. -pub const LOCAL_ONLY_ACTIONS: &[&str] = &["bootstrap", "plugin_connectivity"]; +pub const LOCAL_ONLY_ACTIONS: &[&str] = &["bootstrap", "plugin_connectivity", "proxy.configure"]; pub const ACTIONS: &[ActionSpec] = &[ ActionSpec { @@ -267,6 +267,33 @@ pub const ACTIONS: &[ActionSpec] = &[ returns: "SetupReport", params: &[], }, + ActionSpec { + name: "proxy.configure", + description: "Persist local stdio-proxy defaults and securely store a bearer secret when required", + destructive: true, + requires_admin: true, + returns: "ProxySetupOutcome", + params: &[ + ParamSpec { + name: "preferences", + ty: "ProxyPreferences", + required: true, + description: "Validated non-secret proxy preferences to persist in config.toml", + }, + ParamSpec { + name: "bearer_token", + ty: "string", + required: false, + description: "Write-only bearer token read from stdin; never returned or logged", + }, + ParamSpec { + name: "dry_run", + ty: "boolean", + required: false, + description: "Preview whether config or secret files would change without mutation", + }, + ], + }, // -- Plugin-lifecycle actions ------------------------------------------ // // These actions are HTTP loopback-gated in diff --git a/crates/labby/src/dispatch/setup/dispatch.rs b/crates/labby/src/dispatch/setup/dispatch.rs index cef49605d..5a2046430 100644 --- a/crates/labby/src/dispatch/setup/dispatch.rs +++ b/crates/labby/src/dispatch/setup/dispatch.rs @@ -39,6 +39,7 @@ const REDACTED_LOG_ACTIONS: &[&str] = &[ "settings.update", "settings.env.update", "settings.config.update", + "proxy.configure", ]; use crate::dispatch::error::ToolError; use crate::dispatch::helpers::{action_schema, help_payload, to_json}; @@ -116,6 +117,17 @@ async fn dispatch_inner(action: &str, params: &Value) -> Result plugin_connectivity_action(params).await, "check" => run_blocking_setup("check", setup_check_action).await, "repair" => run_blocking_setup("repair", setup_repair_action).await, + "proxy.configure" => { + let request = serde_json::from_value::(params.clone()) + .map_err(|error| ToolError::InvalidParam { + message: format!("invalid proxy setup request: {error}"), + param: "preferences".to_string(), + })?; + run_blocking_setup("proxy.configure", move || { + to_json(super::proxy::configure(request)?) + }) + .await + } // Plugin-lifecycle actions. The dotted `.` forms are // the canonical names; the snake_case arms beside them are deprecated // aliases retained for backward compatibility. Every name routed here diff --git a/crates/labby/src/dispatch/setup/proxy.rs b/crates/labby/src/dispatch/setup/proxy.rs new file mode 100644 index 000000000..f379de31d --- /dev/null +++ b/crates/labby/src/dispatch/setup/proxy.rs @@ -0,0 +1,283 @@ +//! Local stdio-proxy preference and bearer-secret setup. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::config::{ConfigScalarPatch, ConfigScalarValue}; +use crate::dispatch::error::ToolError; +use crate::proxy::config::{ProxyAuthMode, ProxyPortPreference, ProxyPreferences}; + +#[derive(Clone, Deserialize)] +pub struct ProxySetupRequest { + pub preferences: ProxyPreferences, + #[serde(default)] + pub bearer_token: Option, + #[serde(default)] + pub dry_run: bool, +} + +impl std::fmt::Debug for ProxySetupRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProxySetupRequest") + .field("preferences", &self.preferences) + .field( + "bearer_token", + &self.bearer_token.as_ref().map(|_| ""), + ) + .field("dry_run", &self.dry_run) + .finish() + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ProxySetupOutcome { + pub dry_run: bool, + pub changed: bool, + pub config_changed: bool, + pub secret_changed: bool, + pub config_path: PathBuf, + pub env_path: PathBuf, + pub exposure: crate::proxy::config::ProxyExposure, + pub auth: ProxyAuthMode, +} + +pub fn configure(request: ProxySetupRequest) -> Result { + let home = super::client::lab_home(); + configure_at(&home.join("config.toml"), &home.join(".env"), request) +} + +pub fn configure_at( + config_path: &Path, + env_path: &Path, + request: ProxySetupRequest, +) -> Result { + request + .preferences + .validate() + .map_err(|error| invalid_proxy_config(error.to_string()))?; + if request + .bearer_token + .as_deref() + .is_some_and(|token| token.trim().is_empty()) + { + return Err(invalid_proxy_config( + "bearer token supplied on stdin must not be empty".to_string(), + )); + } + + if request.dry_run { + return preview_at(config_path, env_path, request); + } + + apply_at(config_path, env_path, request) +} + +fn preview_at( + config_path: &Path, + env_path: &Path, + mut request: ProxySetupRequest, +) -> Result { + let temp = tempfile::tempdir().map_err(io_error)?; + let temp_config = temp.path().join("config.toml"); + let temp_env = temp.path().join(".env"); + copy_if_present(config_path, &temp_config)?; + copy_if_present(env_path, &temp_env)?; + request.dry_run = false; + let mut outcome = apply_at(&temp_config, &temp_env, request)?; + outcome.dry_run = true; + outcome.config_path = config_path.to_path_buf(); + outcome.env_path = env_path.to_path_buf(); + Ok(outcome) +} + +fn apply_at( + config_path: &Path, + env_path: &Path, + request: ProxySetupRequest, +) -> Result { + let before_config = read_optional(config_path)?; + let before_env = read_optional(env_path)?; + let mut secret_permissions_changed = ensure_secret_parent_permissions(env_path)?; + + if request.preferences.auth == ProxyAuthMode::Bearer { + let key = request.preferences.bearer_token_env.clone(); + let existing = env_value(env_path, &key)?; + let token = request + .bearer_token + .or(existing) + .unwrap_or_else(super::token::generate_mcp_token); + crate::config::env_merge::merge( + env_path, + crate::config::env_merge::MergeRequest { + entries: vec![crate::config::env_merge::EnvEntry::new(key, token)], + force: true, + expected_mtime: None, + }, + ) + .map_err(|error| ToolError::Sdk { + sdk_kind: error.kind().to_string(), + message: "failed to store proxy bearer secret".to_string(), + })?; + secret_permissions_changed |= ensure_secret_file_permissions(env_path)?; + } + + crate::config::patch_config_scalars(config_path, &preference_patches(&request.preferences)) + .map_err(|error| { + invalid_proxy_config(format!("failed to persist proxy config: {error:#}")) + })?; + + let config_changed = before_config != read_optional(config_path)?; + let secret_changed = before_env != read_optional(env_path)? || secret_permissions_changed; + Ok(ProxySetupOutcome { + dry_run: false, + changed: config_changed || secret_changed, + config_changed, + secret_changed, + config_path: config_path.to_path_buf(), + env_path: env_path.to_path_buf(), + exposure: request.preferences.exposure, + auth: request.preferences.auth, + }) +} + +#[cfg(unix)] +fn ensure_secret_parent_permissions(path: &Path) -> Result { + use std::os::unix::fs::PermissionsExt as _; + + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent).map_err(io_error)?; + let metadata = std::fs::metadata(parent).map_err(io_error)?; + let current = metadata.permissions().mode() & 0o777; + if current == 0o700 { + return Ok(false); + } + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(io_error)?; + Ok(true) +} + +#[cfg(not(unix))] +fn ensure_secret_parent_permissions(path: &Path) -> Result { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(io_error)?; + } + Ok(false) +} + +#[cfg(unix)] +fn ensure_secret_file_permissions(path: &Path) -> Result { + use std::os::unix::fs::PermissionsExt as _; + + let metadata = std::fs::metadata(path).map_err(io_error)?; + let current = metadata.permissions().mode() & 0o777; + if current == 0o600 { + return Ok(false); + } + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(io_error)?; + Ok(true) +} + +#[cfg(not(unix))] +fn ensure_secret_file_permissions(_path: &Path) -> Result { + Ok(false) +} + +fn preference_patches(preferences: &ProxyPreferences) -> Vec { + let exposure = match preferences.exposure { + crate::proxy::config::ProxyExposure::Tailscale => "tailscale", + crate::proxy::config::ProxyExposure::Local => "local", + }; + let auth = match preferences.auth { + ProxyAuthMode::Tailnet => "tailnet", + ProxyAuthMode::Bearer => "bearer", + ProxyAuthMode::Oauth => "oauth", + ProxyAuthMode::None => "none", + }; + let port = match preferences.port { + ProxyPortPreference::Fixed(port) => ConfigScalarValue::I64(i64::from(port)), + ProxyPortPreference::Mode(_) => ConfigScalarValue::String("random".to_string()), + }; + vec![ + ConfigScalarPatch::new("proxy.exposure", ConfigScalarValue::String(exposure.into())), + ConfigScalarPatch::new("proxy.auth", ConfigScalarValue::String(auth.into())), + ConfigScalarPatch::new( + "proxy.path", + ConfigScalarValue::String(preferences.path.clone()), + ), + ConfigScalarPatch::new("proxy.port", port), + ConfigScalarPatch::new( + "proxy.port_range_start", + ConfigScalarValue::I64(i64::from(preferences.port_range_start)), + ), + ConfigScalarPatch::new( + "proxy.port_range_end", + ConfigScalarValue::I64(i64::from(preferences.port_range_end)), + ), + ConfigScalarPatch::new( + "proxy.bearer_token_env", + ConfigScalarValue::String(preferences.bearer_token_env.clone()), + ), + ConfigScalarPatch::new( + "proxy.oauth_scopes", + ConfigScalarValue::StringList(preferences.oauth_scopes.clone()), + ), + ConfigScalarPatch::new( + "proxy.inherit_env", + ConfigScalarValue::StringList(preferences.inherit_env.clone()), + ), + ConfigScalarPatch::new( + "proxy.shutdown_grace_ms", + ConfigScalarValue::I64(preferences.shutdown_grace_ms as i64), + ), + ] +} + +fn env_value(path: &Path, key: &str) -> Result, ToolError> { + if !path.exists() { + return Ok(None); + } + let iter = dotenvy::from_path_iter(path).map_err(|error| ToolError::Sdk { + sdk_kind: "invalid_config".to_string(), + message: format!("failed to parse proxy secret file: {error}"), + })?; + for item in iter { + let (candidate, value) = item.map_err(|error| ToolError::Sdk { + sdk_kind: "invalid_config".to_string(), + message: format!("failed to parse proxy secret file: {error}"), + })?; + if candidate == key && !value.is_empty() { + return Ok(Some(value)); + } + } + Ok(None) +} + +fn read_optional(path: &Path) -> Result>, ToolError> { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(io_error(error)), + } +} + +fn copy_if_present(source: &Path, destination: &Path) -> Result<(), ToolError> { + if source.exists() { + std::fs::copy(source, destination).map_err(io_error)?; + } + Ok(()) +} + +fn io_error(error: std::io::Error) -> ToolError { + ToolError::Sdk { + sdk_kind: "internal_error".to_string(), + message: error.to_string(), + } +} + +fn invalid_proxy_config(message: String) -> ToolError { + ToolError::InvalidParam { + message, + param: "proxy".to_string(), + } +} diff --git a/crates/labby/src/proxy/command.rs b/crates/labby/src/proxy/command.rs index 58c5f5e28..016ff9e57 100644 --- a/crates/labby/src/proxy/command.rs +++ b/crates/labby/src/proxy/command.rs @@ -32,7 +32,7 @@ pub fn resolve_proxy_command( }); } else { let program = - resolve_on_path(target, path_env).ok_or_else(|| ProxyCommandError::NotFound { + executable_on_path(target, path_env).ok_or_else(|| ProxyCommandError::NotFound { target: target.to_string_lossy().into_owned(), })?; (program.into_os_string(), Vec::new()) @@ -56,7 +56,7 @@ fn resolve_file_target( return Ok((path.as_os_str().to_os_string(), Vec::new())); } if let Some((interpreter, optional_arg)) = parse_shebang(path)? { - let program = resolve_on_path(OsStr::new(&interpreter), path_env).ok_or_else(|| { + let program = executable_on_path(OsStr::new(&interpreter), path_env).ok_or_else(|| { ProxyCommandError::RuntimeNotFound { target: original.to_string_lossy().into_owned(), runtime: interpreter.clone(), @@ -85,7 +85,7 @@ fn resolve_file_target( target: original.to_string_lossy().into_owned(), }); }; - let program = resolve_on_path(OsStr::new(runtime), path_env).ok_or_else(|| { + let program = executable_on_path(OsStr::new(runtime), path_env).ok_or_else(|| { ProxyCommandError::RuntimeNotFound { target: original.to_string_lossy().into_owned(), runtime: runtime.to_string(), @@ -149,7 +149,7 @@ fn parse_shebang(path: &Path) -> Result)>, ProxyC ))) } -fn resolve_on_path(program: &OsStr, path_env: Option<&OsStr>) -> Option { +pub(crate) fn executable_on_path(program: &OsStr, path_env: Option<&OsStr>) -> Option { let path_env = path_env?; std::env::split_paths(path_env) .map(|dir| dir.join(program)) diff --git a/crates/labby/src/proxy/config.rs b/crates/labby/src/proxy/config.rs index 1521daf42..b5d4b0dea 100644 --- a/crates/labby/src/proxy/config.rs +++ b/crates/labby/src/proxy/config.rs @@ -5,7 +5,7 @@ pub const DEFAULT_PROXY_PORT_RANGE_END: u16 = 65_535; pub const DEFAULT_PROXY_SHUTDOWN_GRACE_MS: u64 = 3_000; pub const DEFAULT_PROXY_BEARER_TOKEN_ENV: &str = "LABBY_PROXY_BEARER_TOKEN"; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum, Default)] #[serde(rename_all = "snake_case")] pub enum ProxyExposure { #[default] diff --git a/crates/labby/src/proxy/tailscale.rs b/crates/labby/src/proxy/tailscale.rs index 51e5e84d3..ce05cb555 100644 --- a/crates/labby/src/proxy/tailscale.rs +++ b/crates/labby/src/proxy/tailscale.rs @@ -59,6 +59,21 @@ impl TailscaleStatus { dns_name: self.self_node.dns_name.clone(), }) } + + #[must_use] + pub fn backend_running(&self) -> bool { + self.backend_state == "Running" + } + + #[must_use] + pub fn online(&self) -> bool { + self.self_node.online + } + + #[must_use] + pub fn dns_name(&self) -> &str { + &self.self_node.dns_name + } } /// Relevant fields from `tailscale serve status --json`. @@ -557,7 +572,7 @@ async fn join_output(task: Option>>>) -> Vec< } } -async fn run_checked(executable: &PathBuf, args: I) -> Result +pub(crate) async fn run_checked(executable: &PathBuf, args: I) -> Result where I: IntoIterator, S: AsRef, diff --git a/crates/labby/tests/doctor_proxy_preflight.rs b/crates/labby/tests/doctor_proxy_preflight.rs new file mode 100644 index 000000000..171ca8010 --- /dev/null +++ b/crates/labby/tests/doctor_proxy_preflight.rs @@ -0,0 +1,619 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::{Value, json}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn command(home: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_labby")); + command + .env("HOME", home) + .env("LABBY_HOME", home.join(".labby")) + .env("LABBY_LOG_DIR", home.join("logs")); + command +} + +fn write_config(home: &Path, body: &str) { + let lab_home = home.join(".labby"); + std::fs::create_dir_all(&lab_home).unwrap(); + std::fs::write(lab_home.join("config.toml"), body).unwrap(); +} + +fn run_json(command: &mut Command) -> (Output, Value) { + let output = command.output().expect("run labby doctor proxy"); + let report = serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "doctor output was not JSON: {error}; stdout={}; stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }); + (output, report) +} + +fn finding<'a>(report: &'a Value, check: &str) -> &'a Value { + report["findings"] + .as_array() + .unwrap() + .iter() + .find(|finding| finding["check"] == check) + .unwrap_or_else(|| panic!("missing finding {check}: {report}")) +} + +fn assert_severity(report: &Value, check: &str, severity: &str) { + assert_eq!(finding(report, check)["severity"], severity, "{report}"); +} + +#[tokio::test] +async fn doctor_catalog_advertises_preflight_without_changing_proxy_check_schema() { + let catalog = labby::dispatch::doctor::dispatch("help", json!({})) + .await + .unwrap(); + assert!(catalog["actions"].as_array().unwrap().iter().any(|action| { + action["name"] == "proxy.preflight" + && action["destructive"] == false + && action["params"].as_array().unwrap().is_empty() + })); + + let schema = labby::dispatch::doctor::dispatch("schema", json!({"action": "proxy.check"})) + .await + .unwrap(); + assert!( + schema["params"] + .as_array() + .unwrap() + .iter() + .any(|param| { param["name"] == "route" && param["required"] == true }) + ); +} + +#[cfg(unix)] +fn executable(path: &Path, body: &str) { + use std::os::unix::fs::PermissionsExt as _; + + std::fs::write(path, body).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[cfg(unix)] +fn launcher_path(root: &Path, include_python: bool) -> PathBuf { + let bin = root.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + executable(&bin.join("node"), "#!/bin/sh\nexit 0\n"); + if include_python { + executable(&bin.join("python3"), "#!/bin/sh\nexit 0\n"); + } + bin +} + +#[cfg(unix)] +fn fake_tailscale(root: &Path, version: &str, status: &str, serve_status: &str) -> PathBuf { + let executable_path = root.join("tailscale"); + let calls = root.join("tailscale.calls"); + let script = format!( + r#"#!/bin/sh +printf '%s\n' "$*" >> '{calls}' +case "$*" in + version) printf '%s\n' '{version}'; exit 0 ;; + 'status --json') printf '%s\n' '{status}'; exit 0 ;; + 'serve status --json') printf '%s\n' '{serve_status}'; exit 0 ;; + *) printf '%s\n' 'mutation attempted' >&2; exit 97 ;; +esac +"#, + calls = calls.display(), + ); + executable(&executable_path, &script); + executable_path +} + +#[cfg(unix)] +#[test] +fn doctor_proxy_without_route_runs_local_preflight_and_skips_tailscale_for_local_exposure() { + let home = tempfile::tempdir().expect("temp home"); + write_config( + home.path(), + "[proxy]\nexposure = \"local\"\nauth = \"none\"\npath = \"/mcp\"\nport = \"random\"\nport_range_start = 50000\nport_range_end = 51000\n", + ); + + let (output, report) = run_json( + command(home.path()) + .args(["--json", "doctor", "proxy"]) + .env("PATH", launcher_path(home.path(), true)) + .env("LABBY_TAILSCALE_BIN", "/definitely/must/not/run/tailscale"), + ); + + assert_eq!( + output.status.code(), + Some(1), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_severity(&report, "proxy:config", "ok"); + assert_severity(&report, "proxy:launcher-node", "ok"); + assert_severity(&report, "proxy:launcher-python3", "ok"); + assert_severity(&report, "proxy:auth-none", "warn"); + assert_severity(&report, "proxy:tailscale-skipped", "ok"); + assert!( + !report["findings"] + .as_array() + .unwrap() + .iter() + .any(|finding| { + finding["check"] + .as_str() + .is_some_and(|check| check.starts_with("proxy:tailscale-")) + && finding["check"] != "proxy:tailscale-skipped" + }) + ); +} + +#[cfg(unix)] +#[test] +fn proxy_preflight_reports_invalid_persisted_config_before_dependencies() { + let home = tempfile::tempdir().unwrap(); + write_config( + home.path(), + "[proxy]\nexposure = \"local\"\nauth = \"none\"\npath = \"relative\"\nport_range_start = 51000\nport_range_end = 50000\n", + ); + + let output = command(home.path()) + .args(["--json", "doctor", "proxy"]) + .output() + .unwrap(); + + assert_eq!(output.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&output.stderr).contains("invalid config")); + assert!(!String::from_utf8_lossy(&output.stderr).contains("tailscale")); +} + +#[cfg(unix)] +#[test] +fn proxy_preflight_reports_each_child_launcher_deterministically() { + let home = tempfile::tempdir().unwrap(); + write_config( + home.path(), + "[proxy]\nexposure = \"local\"\nauth = \"bearer\"\n", + ); + let secret = "doctor-secret-must-not-be-rendered"; + let (output, report) = run_json( + command(home.path()) + .args(["--json", "doctor", "proxy"]) + .env("PATH", launcher_path(home.path(), false)) + .env("LABBY_PROXY_BEARER_TOKEN", secret), + ); + + assert_eq!(output.status.code(), Some(2)); + assert_severity(&report, "proxy:launcher-node", "ok"); + assert_severity(&report, "proxy:launcher-python3", "fail"); + assert_severity(&report, "proxy:bearer-secret", "ok"); + assert!(!String::from_utf8_lossy(&output.stdout).contains(secret)); + assert!(!String::from_utf8_lossy(&output.stderr).contains(secret)); +} + +#[cfg(unix)] +#[test] +fn bearer_preflight_loads_secret_from_persisted_dotenv_without_rendering_it() { + let home = tempfile::tempdir().unwrap(); + write_config( + home.path(), + "[proxy]\nexposure = \"local\"\nauth = \"bearer\"\nbearer_token_env = \"CUSTOM_PROXY_TOKEN\"\n", + ); + let secret = "persisted-secret-must-not-be-rendered"; + std::fs::write( + home.path().join(".labby/.env"), + format!("CUSTOM_PROXY_TOKEN={secret}\n"), + ) + .unwrap(); + + let (output, report) = run_json( + command(home.path()) + .args(["--json", "doctor", "proxy"]) + .env("PATH", launcher_path(home.path(), true)), + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_severity(&report, "proxy:bearer-secret", "ok"); + assert!(!String::from_utf8_lossy(&output.stdout).contains(secret)); + assert!(!String::from_utf8_lossy(&output.stderr).contains(secret)); + + let (missing_output, missing_report) = run_json( + command(home.path()) + .args(["--json", "doctor", "proxy"]) + .env("PATH", launcher_path(home.path(), true)) + .env_remove("CUSTOM_PROXY_TOKEN"), + ); + assert!(missing_output.status.success()); + assert_severity(&missing_report, "proxy:bearer-secret", "ok"); + + let absent_home = tempfile::tempdir().unwrap(); + write_config( + absent_home.path(), + "[proxy]\nexposure = \"local\"\nauth = \"bearer\"\nbearer_token_env = \"ABSENT_PROXY_TOKEN\"\n", + ); + let (absent_output, absent_report) = run_json( + command(absent_home.path()) + .args(["--json", "doctor", "proxy"]) + .env("PATH", launcher_path(absent_home.path(), true)) + .env_remove("ABSENT_PROXY_TOKEN"), + ); + assert_eq!(absent_output.status.code(), Some(2)); + assert_severity(&absent_report, "proxy:bearer-secret", "fail"); +} + +#[cfg(unix)] +#[test] +fn tailscale_preflight_checks_version_identity_dns_and_https_without_mutation() { + let home = tempfile::tempdir().unwrap(); + write_config(home.path(), "[proxy]\nport = 54000\n"); + let tailscale = fake_tailscale( + home.path(), + "1.98.10", + r#"{"BackendState":"Running","Self":{"Online":true,"DNSName":"node.example.ts.net.","TailscaleIPs":["100.64.0.1"]},"Peer":{}}"#, + r#"{"TCP":{"443":{"HTTPS":true}},"Web":{"node.example.ts.net:443":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:8765"}}}},"AllowFunnel":{}}"#, + ); + + let (output, report) = run_json( + command(home.path()) + .args(["--json", "doctor", "proxy"]) + .env("PATH", launcher_path(home.path(), true)) + .env("LABBY_TAILSCALE_BIN", &tailscale), + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + for check in [ + "proxy:tailscale-version", + "proxy:tailscale-running", + "proxy:tailscale-online", + "proxy:tailscale-dns", + "proxy:tailscale-https-serve", + ] { + assert_severity(&report, check, "ok"); + } + let calls = std::fs::read_to_string(home.path().join("tailscale.calls")).unwrap(); + assert_eq!(calls, "version\nstatus --json\nserve status --json\n"); + assert!(!calls.contains("--yes")); + assert!(!calls.contains("off")); + assert!(!calls.contains("reset")); +} + +#[cfg(unix)] +#[test] +fn tailscale_preflight_attributes_status_failures_to_the_exact_category() { + let cases = [ + ( + "stopped", + r#"{"BackendState":"Stopped","Self":{"Online":true,"DNSName":"node.example.ts.net."}}"#, + "proxy:tailscale-running", + ), + ( + "offline", + r#"{"BackendState":"Running","Self":{"Online":false,"DNSName":"node.example.ts.net."}}"#, + "proxy:tailscale-online", + ), + ( + "no-dns", + r#"{"BackendState":"Running","Self":{"Online":true,"DNSName":""}}"#, + "proxy:tailscale-dns", + ), + ]; + for (name, status, expected_check) in cases { + let home = tempfile::tempdir().unwrap(); + write_config(home.path(), "[proxy]\n"); + let tailscale = fake_tailscale(home.path(), "1.98.10", status, r#"{"TCP":{},"Web":{}}"#); + let (output, report) = run_json( + command(home.path()) + .args(["--json", "doctor", "proxy"]) + .env("PATH", launcher_path(home.path(), true)) + .env("LABBY_TAILSCALE_BIN", tailscale), + ); + assert_eq!(output.status.code(), Some(2), "case {name}"); + assert_severity(&report, expected_check, "fail"); + } +} + +#[cfg(unix)] +#[test] +fn tailscale_preflight_reports_executable_version_and_https_serve_failures() { + let missing_home = tempfile::tempdir().unwrap(); + write_config(missing_home.path(), "[proxy]\n"); + let (missing_output, missing_report) = run_json( + command(missing_home.path()) + .args(["--json", "doctor", "proxy"]) + .env("PATH", launcher_path(missing_home.path(), true)) + .env( + "LABBY_TAILSCALE_BIN", + missing_home.path().join("missing-tailscale"), + ), + ); + assert_eq!(missing_output.status.code(), Some(2)); + assert_severity(&missing_report, "proxy:tailscale-version", "fail"); + assert!( + finding(&missing_report, "proxy:tailscale-running")["message"] + .as_str() + .unwrap() + .contains("executable/version") + ); + + let empty_version_home = tempfile::tempdir().unwrap(); + write_config(empty_version_home.path(), "[proxy]\n"); + let tailscale = fake_tailscale( + empty_version_home.path(), + "", + r#"{"BackendState":"Running","Self":{"Online":true,"DNSName":"node.example.ts.net."}}"#, + r#"{"TCP":{},"Web":{}}"#, + ); + let (empty_output, empty_report) = run_json( + command(empty_version_home.path()) + .args(["--json", "doctor", "proxy"]) + .env("PATH", launcher_path(empty_version_home.path(), true)) + .env("LABBY_TAILSCALE_BIN", tailscale), + ); + assert_eq!(empty_output.status.code(), Some(2)); + assert_severity(&empty_report, "proxy:tailscale-version", "fail"); + + let bad_serve_home = tempfile::tempdir().unwrap(); + write_config(bad_serve_home.path(), "[proxy]\n"); + let tailscale = fake_tailscale( + bad_serve_home.path(), + "1.98.10", + r#"{"BackendState":"Running","Self":{"Online":true,"DNSName":"node.example.ts.net."}}"#, + "not-json", + ); + let (serve_output, serve_report) = run_json( + command(bad_serve_home.path()) + .args(["--json", "doctor", "proxy"]) + .env("PATH", launcher_path(bad_serve_home.path(), true)) + .env("LABBY_TAILSCALE_BIN", tailscale), + ); + assert_eq!(serve_output.status.code(), Some(2)); + assert_severity(&serve_report, "proxy:tailscale-running", "ok"); + assert_severity(&serve_report, "proxy:tailscale-online", "ok"); + assert_severity(&serve_report, "proxy:tailscale-dns", "ok"); + assert_severity(&serve_report, "proxy:tailscale-https-serve", "fail"); +} + +async fn mount_daemon(server: &MockServer, actions: Value, metadata: Value, jwks: Value) { + Mock::given(method("GET")) + .and(path("/health")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"status": "ok"}))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/.well-known/labby.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "paletteCatalogUrl": "catalog", + "paletteExecuteUrl": "execute" + }))) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/v1/gateway/actions")) + .respond_with(ResponseTemplate::new(200).set_body_json(actions)) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/.well-known/oauth-authorization-server")) + .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(jwks)) + .mount(server) + .await; +} + +#[cfg(unix)] +async fn oauth_doctor(home: &Path, server: &MockServer) -> (Output, Value) { + let url = url::Url::parse(&server.uri()).unwrap(); + write_config( + home, + &format!( + "[proxy]\nexposure = \"local\"\nauth = \"oauth\"\n\n[auth]\nmode = \"oauth\"\npublic_url = \"{}\"\nsqlite_path = \"{}\"\nkey_path = \"{}\"\nadmin_email = \"admin@example.com\"\ngoogle_client_id = \"test-client\"\ngoogle_client_secret = \"test-secret\"\n", + server.uri(), + home.join("auth.db").display(), + home.join("auth-jwt.pem").display(), + ), + ); + let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_labby")) + .args(["--json", "doctor", "proxy"]) + .env("HOME", home) + .env("LABBY_HOME", home.join(".labby")) + .env("LABBY_LOG_DIR", home.join("logs")) + .env("PATH", launcher_path(home, true)) + .env("LABBY_MCP_HTTP_HOST", url.host_str().unwrap()) + .env("LABBY_MCP_HTTP_PORT", url.port().unwrap().to_string()) + .output() + .await + .unwrap(); + let report = serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "oauth doctor output was not JSON: {error}; stdout={}; stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }); + (output, report) +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn oauth_preflight_checks_stable_issuer_daemon_lease_actions_metadata_and_jwks() { + let home = tempfile::tempdir().unwrap(); + let server = MockServer::start().await; + mount_daemon( + &server, + json!([ + {"name": "gateway.oauth.resource_lease.create"}, + {"name": "gateway.oauth.resource_lease.renew"}, + {"name": "gateway.oauth.resource_lease.release"} + ]), + json!({"issuer": server.uri(), "jwks_uri": format!("{}/jwks", server.uri())}), + json!({"keys": []}), + ) + .await; + + let (output, report) = oauth_doctor(home.path(), &server).await; + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + for check in [ + "proxy:oauth-stable-issuer", + "proxy:oauth-daemon", + "proxy:oauth-lease-create", + "proxy:oauth-lease-renew", + "proxy:oauth-lease-release", + "proxy:oauth-issuer-metadata", + "proxy:oauth-jwks", + ] { + assert_severity(&report, check, "ok"); + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn oauth_preflight_reports_missing_lease_action_and_bad_issuer_metadata_deterministically() { + let home = tempfile::tempdir().unwrap(); + let server = MockServer::start().await; + mount_daemon( + &server, + json!([ + {"name": "gateway.oauth.resource_lease.create"}, + {"name": "gateway.oauth.resource_lease.renew"} + ]), + json!({"issuer": "https://wrong.example", "jwks_uri": format!("{}/jwks", server.uri())}), + json!({"keys": []}), + ) + .await; + + let (output, report) = oauth_doctor(home.path(), &server).await; + assert_eq!(output.status.code(), Some(2)); + assert_severity(&report, "proxy:oauth-lease-create", "ok"); + assert_severity(&report, "proxy:oauth-lease-renew", "ok"); + assert_severity(&report, "proxy:oauth-lease-release", "fail"); + assert_severity(&report, "proxy:oauth-issuer-metadata", "fail"); + assert_severity(&report, "proxy:oauth-jwks", "fail"); + assert!( + finding(&report, "proxy:oauth-jwks")["message"] + .as_str() + .unwrap() + .contains("issuer metadata") + ); +} + +#[cfg(unix)] +#[test] +fn oauth_preflight_reports_missing_stable_issuer_and_unreachable_daemon_dependencies() { + let home = tempfile::tempdir().unwrap(); + write_config( + home.path(), + "[proxy]\nexposure = \"local\"\nauth = \"oauth\"\n\n[auth]\nmode = \"oauth\"\n", + ); + let (output, report) = run_json( + command(home.path()) + .args(["--json", "doctor", "proxy"]) + .env("PATH", launcher_path(home.path(), true)) + .env("LABBY_MCP_HTTP_HOST", "127.0.0.1") + .env("LABBY_MCP_HTTP_PORT", "9"), + ); + assert_eq!(output.status.code(), Some(2)); + assert_severity(&report, "proxy:oauth-stable-issuer", "fail"); + assert_severity(&report, "proxy:oauth-daemon", "fail"); + for check in [ + "proxy:oauth-lease-create", + "proxy:oauth-lease-renew", + "proxy:oauth-lease-release", + "proxy:oauth-issuer-metadata", + "proxy:oauth-jwks", + ] { + assert_severity(&report, check, "fail"); + assert!( + finding(&report, check)["message"] + .as_str() + .unwrap() + .contains("live daemon discovery") + ); + } +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn oauth_preflight_attributes_invalid_jwks_after_valid_issuer_metadata() { + let home = tempfile::tempdir().unwrap(); + let server = MockServer::start().await; + mount_daemon( + &server, + json!([ + {"name": "gateway.oauth.resource_lease.create"}, + {"name": "gateway.oauth.resource_lease.renew"}, + {"name": "gateway.oauth.resource_lease.release"} + ]), + json!({"issuer": server.uri(), "jwks_uri": format!("{}/jwks", server.uri())}), + json!({"not_keys": []}), + ) + .await; + + let (output, report) = oauth_doctor(home.path(), &server).await; + assert_eq!(output.status.code(), Some(2)); + assert_severity(&report, "proxy:oauth-issuer-metadata", "ok"); + assert_severity(&report, "proxy:oauth-jwks", "fail"); + assert!( + finding(&report, "proxy:oauth-jwks")["message"] + .as_str() + .unwrap() + .contains("JWKS") + ); +} + +#[cfg(unix)] +#[test] +fn routed_proxy_probe_remains_compatible_and_does_not_run_local_preflight() { + let home = tempfile::tempdir().unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_labby")) + .args([ + "--json", + "doctor", + "proxy", + "--app-url", + "http://doctor-app.invalid", + "--mcp-url", + "http://doctor-mcp.invalid", + "--route", + "/telemetry", + ]) + .env("HOME", home.path()) + .env("LABBY_HOME", home.path().join(".labby")) + .env("LABBY_LOG_DIR", home.path().join("logs")) + .output() + .unwrap(); + assert_eq!( + output.status.code(), + Some(2), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_severity(&report, "proxy:app-health", "fail"); + assert_eq!( + finding(&report, "proxy:oauth-challenge")["severity"], + "fail" + ); + assert!( + !report["findings"] + .as_array() + .unwrap() + .iter() + .any(|finding| { + finding["check"] == "proxy:config" || finding["check"] == "proxy:tailscale-skipped" + }) + ); +} diff --git a/crates/labby/tests/setup_proxy.rs b/crates/labby/tests/setup_proxy.rs new file mode 100644 index 000000000..3d3b0a8fd --- /dev/null +++ b/crates/labby/tests/setup_proxy.rs @@ -0,0 +1,213 @@ +use std::process::Command; + +fn command(home: &std::path::Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_labby")); + command + .env("HOME", home) + .env("LABBY_HOME", home.join(".labby")) + .env("LABBY_LOG_DIR", home.join("logs")); + command +} + +#[test] +fn setup_proxy_noninteractive_dry_run_is_supported() { + let home = tempfile::tempdir().expect("temp home"); + let output = command(home.path()) + .args(["--json", "setup", "proxy", "--yes", "--dry-run"]) + .output() + .expect("run labby setup proxy dry-run"); + + assert!( + output.status.success(), + "setup proxy dry-run failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let value: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("dry-run JSON output"); + assert_eq!(value["dry_run"], true); + assert_eq!(value["changed"], true); + assert!(!home.path().join(".labby/config.toml").exists()); + assert!(!home.path().join(".labby/.env").exists()); +} + +#[test] +fn setup_proxy_non_tty_without_yes_fails_without_reading_stdin() { + let home = tempfile::tempdir().expect("temp home"); + let output = command(home.path()) + .args(["setup", "proxy"]) + .stdin(std::process::Stdio::null()) + .output() + .expect("run noninteractive setup proxy"); + + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("setup proxy requires --yes when stdin is not a TTY") + ); +} + +#[test] +fn bearer_setup_preserves_comments_hardens_secret_and_is_byte_idempotent() { + use std::os::unix::fs::PermissionsExt as _; + + let home = tempfile::tempdir().expect("temp home"); + let lab_home = home.path().join(".labby"); + std::fs::create_dir_all(&lab_home).unwrap(); + let config = lab_home.join("config.toml"); + let env = lab_home.join(".env"); + std::fs::write(&config, "# operator config\n[foreign]\nkeep = true\n").unwrap(); + std::fs::write(&env, "# operator env\nUNRELATED=value\n").unwrap(); + + let first = command(home.path()) + .args(["--json", "setup", "proxy", "--yes", "--auth", "bearer"]) + .output() + .expect("first bearer setup"); + assert!( + first.status.success(), + "first setup failed: {}", + String::from_utf8_lossy(&first.stderr) + ); + let first_json: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap(); + assert_eq!(first_json["changed"], true); + assert_eq!(first_json["secret_changed"], true); + + let config_once = std::fs::read(&config).unwrap(); + let env_once = std::fs::read(&env).unwrap(); + let config_text = String::from_utf8(config_once.clone()).unwrap(); + let env_text = String::from_utf8(env_once.clone()).unwrap(); + assert!(config_text.contains("# operator config")); + assert!(config_text.contains("[foreign]")); + assert!(config_text.contains("auth = \"bearer\"")); + assert!(!config_text.contains("LABBY_PROXY_BEARER_TOKEN=")); + assert!(env_text.contains("# operator env")); + assert!(env_text.contains("UNRELATED=value")); + let token = env_text + .lines() + .find_map(|line| line.strip_prefix("LABBY_PROXY_BEARER_TOKEN=")) + .expect("generated proxy bearer token"); + assert_eq!(token.len(), 64); + assert!(token.chars().all(|character| character.is_ascii_hexdigit())); + assert_eq!( + std::fs::metadata(&env).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert!(!String::from_utf8_lossy(&first.stdout).contains(token)); + assert!(!String::from_utf8_lossy(&first.stderr).contains(token)); + + let second = command(home.path()) + .args(["--json", "setup", "proxy", "--yes", "--auth", "bearer"]) + .output() + .expect("second bearer setup"); + assert!(second.status.success()); + let second_json: serde_json::Value = serde_json::from_slice(&second.stdout).unwrap(); + assert_eq!(second_json["changed"], false); + assert_eq!(second_json["config_changed"], false); + assert_eq!(second_json["secret_changed"], false); + assert_eq!(std::fs::read(&config).unwrap(), config_once); + assert_eq!(std::fs::read(&env).unwrap(), env_once); +} + +#[cfg(unix)] +#[test] +fn bearer_setup_repairs_existing_secret_permissions_without_rewriting_value() { + use std::os::unix::fs::PermissionsExt as _; + + let home = tempfile::tempdir().unwrap(); + let labby_home = home.path().join(".labby"); + std::fs::create_dir_all(&labby_home).unwrap(); + std::fs::set_permissions(&labby_home, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write( + labby_home.join("config.toml"), + r#"[proxy] +exposure = "local" +auth = "bearer" +bearer_token_env = "LABBY_PROXY_TOKEN" +"#, + ) + .unwrap(); + std::fs::write( + labby_home.join(".env"), + r#"LABBY_PROXY_TOKEN=existing-secret +"#, + ) + .unwrap(); + std::fs::set_permissions( + labby_home.join(".env"), + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + + let output = command(home.path()) + .args([ + "--json", + "setup", + "proxy", + "--yes", + "--exposure", + "local", + "--auth", + "bearer", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["secret_changed"], true); + assert_eq!( + std::fs::metadata(&labby_home).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(labby_home.join(".env")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + assert_eq!( + std::fs::read_to_string(labby_home.join(".env")).unwrap(), + "LABBY_PROXY_TOKEN=existing-secret +" + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("existing-secret")); +} + +#[test] +fn bearer_token_stdin_is_stored_but_never_printed() { + use std::io::Write as _; + + let home = tempfile::tempdir().expect("temp home"); + let secret = "stdin-secret-with-spaces # not output"; + let mut child = command(home.path()) + .args(["--json", "setup", "proxy", "--yes", "--bearer-token-stdin"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn stdin setup"); + child + .stdin + .take() + .unwrap() + .write_all(format!("{secret}\n").as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + + assert!( + output.status.success(), + "stdin setup failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let env = std::fs::read_to_string(home.path().join(".labby/.env")).unwrap(); + assert!(env.contains("LABBY_PROXY_BEARER_TOKEN=")); + assert!(env.contains("stdin-secret-with-spaces")); + assert!(!String::from_utf8_lossy(&output.stdout).contains(secret)); + assert!(!String::from_utf8_lossy(&output.stderr).contains(secret)); + let config = std::fs::read_to_string(home.path().join(".labby/config.toml")).unwrap(); + assert!(!config.contains(secret)); +} From 623ce3167c9ed6651433c32cbba33e3fdeb273a2 Mon Sep 17 00:00:00 2001 From: Jake Magar Date: Sat, 1 Aug 2026 13:17:54 -0400 Subject: [PATCH 8/8] docs(proxy): publish operations and generated references --- CHANGELOG.md | 20 + Justfile | 4 +- README.md | 36 +- config/config.example.toml | 26 +- crates/labby/src/api/openapi.rs | 29 +- crates/labby/src/docs/artifacts.rs | 8 + crates/labby/src/docs/projection.rs | 245 +++++++++++- crates/labby/src/docs/render.rs | 26 +- crates/labby/src/docs/types.rs | 13 + docs/ARCH.md | 31 +- docs/README.md | 26 +- docs/contracts/stdio-mcp-proxy.md | 87 ++-- docs/generated/README.md | 1 + docs/generated/action-catalog.json | 172 ++++++++ docs/generated/action-catalog.md | 5 + docs/generated/cli-help.md | 121 +++++- docs/generated/env-reference.json | 39 +- docs/generated/env-reference.md | 4 + docs/generated/feature-matrix.json | 10 + docs/generated/feature-matrix.md | 1 + docs/generated/mcp-help.json | 117 ++++++ docs/generated/mcp-help.md | 5 + docs/generated/openapi.json | 315 ++++++++++++++- docs/generated/proxy-config-reference.json | 102 +++++ docs/generated/proxy-config-reference.md | 18 + docs/generated/service-catalog.json | 21 + docs/generated/service-catalog.md | 1 + docs/guides/STDIO_MCP_PROXY.md | 375 ++++++++++++++++++ .../2026-07-31-stdio-mcp-proxy-research.md | 32 +- docs/runtime/CONFIG.md | 26 +- docs/runtime/ENV.md | 34 +- docs/runtime/OAUTH.md | 38 +- docs/specs/stdio-mcp-proxy.md | 21 +- ...26-07-31-stdio-mcp-proxy-implementation.md | 25 +- docs/surfaces/CLI.md | 12 +- docs/surfaces/TRANSPORT.md | 17 +- 36 files changed, 1973 insertions(+), 90 deletions(-) create mode 100644 docs/generated/proxy-config-reference.json create mode 100644 docs/generated/proxy-config-reference.md create mode 100644 docs/guides/STDIO_MCP_PROXY.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ba68df94..4ee409cd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. Historical entries use distinct `example*` pseudonyms where retired service identifiers were removed. Commit links remain the authoritative historical record. +## Unreleased + +### Added + +- **proxy:** `labby proxy ` now exposes one stdio MCP server + faithfully over loopback or an owned Tailscale Serve HTTPS port. Zero-flag + defaults support tailnet policy, while setup can persist separate bearer or + stable-issuer OAuth policy, random or fixed ports, endpoint path, child + environment inheritance, and cleanup preferences. +- **setup/doctor:** `labby setup proxy` stores non-secrets in `config.toml`, + generates or accepts bearer secrets in the hardened `.env`, and remains + idempotent. Zero-route `labby doctor proxy` validates local proxy + prerequisites without removing the existing routed reverse-proxy doctor. +- **oauth:** ephemeral exact-resource leases register each OAuth proxy URL, + including port and path, through admin gateway actions; the proxy renews and + releases leases while the daemon expires crash leftovers by TTL. +- **docs:** added the stdio MCP proxy operator guide plus generated proxy + configuration, environment, service, action, CLI, API, and OpenAPI inventory + coverage with drift tests. + ## [1.8.5](https://github.com/dinglebear-ai/labby/compare/v1.8.4...v1.8.5) (2026-07-30) diff --git a/Justfile b/Justfile index c5490b3a9..446a4142c 100644 --- a/Justfile +++ b/Justfile @@ -19,11 +19,11 @@ test-cargo-wrapper: # Regenerate code-owned documentation inventories docs-generate: - cargo run --package labby --all-features -- docs generate + cargo run --package labby --bin labby --all-features -- docs generate # Verify generated documentation inventories are fresh docs-check: - cargo run --package labby --all-features -- docs check + cargo run --package labby --bin labby --all-features -- docs check # Run integration tests (requires running services) test-integration: diff --git a/README.md b/README.md index d8f4285f1..979ed1b13 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ Labby is centered on the current gateway/operator surface: tools/resources/prompts, apply exposure filters, publish protected MCP routes, and optionally collapse the upstream catalog into Code Mode `search` and `execute`. +- **Direct stdio proxy** - launch one stdio MCP server with + `labby proxy /path/to/dist.js` and expose its unmodified MCP surface over + loopback or an owned Tailscale Serve HTTPS port with tailnet, bearer, OAuth, + or explicit no-auth policy. - **Authentication and protected routes** - run bearer or OAuth authentication, manage route-scoped access, authorize upstream OAuth connections, and publish protected MCP endpoints. @@ -41,7 +45,8 @@ Labby is centered on the current gateway/operator surface: - **Incus and bare-metal setup** - provision and operate a dedicated Labby gateway host without introducing a separate fleet or deployment product. - **Generated discovery** - publish code-owned service, action, environment, - API route, OpenAPI, MCP help, CLI help, and feature-matrix artifacts under + proxy configuration, API route, OpenAPI, MCP help, CLI help, and + feature-matrix artifacts under [docs/generated](./docs/generated/README.md). The registered services on this branch are exactly `doctor`, `fs`, `gateway`, @@ -54,6 +59,31 @@ instead of copying command or action lists by hand. ## Quick Start +### Proxy One Stdio MCP Server + +After installing Labby, configure proxy defaults once and launch a JavaScript +stdio server without proxy flags: + +```bash +labby setup proxy +labby doctor proxy +labby proxy /path/to/dist.js +``` + +The built-in zero-flag policy is Tailscale Serve plus tailnet authorization on +a random high port. Child flags follow the first child token unchanged, and an +explicit separator is available for unusual commands: + +```bash +labby proxy /path/to/dist.js --workspace /srv/data --read-only +labby proxy -- npx -y @modelcontextprotocol/server-filesystem /srv/data +``` + +Use `labby proxy --local --auth none ...` for explicit loopback-only +development. Bearer and OAuth setup, exact-port resource audiences, safe Serve +ownership, configuration precedence, output modes, and recovery are covered in +the [stdio MCP proxy guide](./docs/guides/STDIO_MCP_PROXY.md). + ### Install A Release Linux/macOS: @@ -244,7 +274,9 @@ without the `gateway` feature. labby doctor # audit every configured service labby doctor system # local env vars, Docker, disk, toolchain labby doctor auth # auth/OAuth env vars, files, permissions -labby doctor proxy # public Lab and protected MCP proxy endpoints +labby doctor proxy # zero-route stdio-proxy config/dependency preflight +labby doctor proxy --app-url URL --mcp-url URL --route /path + # routed public reverse-proxy checks remain available labby doctor oauth-relay labby health # lightweight liveness/readiness probe labby logs # tail the active deployment's service journal diff --git a/config/config.example.toml b/config/config.example.toml index b7d1574d0..fa43b4a93 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -7,8 +7,9 @@ # Docker and local `labby serve` share the same config. # # Secrets (API keys, tokens, passwords, URLs) belong in ~/.labby/.env. -# Everything else belongs here. All values below can still be overridden -# by environment variables — env always wins over config.toml. +# Everything else belongs here. Only settings with a documented environment +# alias can be overridden that way; proxy preferences intentionally do not +# invent one-to-one aliases. Where an alias exists, env wins over config.toml. # # Config search order (first found wins): # 1. ./config.toml (repo/CWD override) @@ -58,6 +59,27 @@ # Override with LAB_MCP_ALLOWED_HOSTS env var (comma-separated). # allowed_hosts = [] # default: [] +# ── Direct stdio MCP proxy ────────────────────────────────────────────────── + +[proxy] +# Publish through one owned Tailscale Serve HTTPS port, or keep loopback-only. +# exposure = "tailscale" # tailscale | local + +# tailnet uses Tailscale reachability/grants without an application token. +# auth = "tailnet" # tailnet | bearer | oauth | none +# path = "/mcp" # absolute, non-root, no query/fragment +# port = "random" # random or fixed integer +# port_range_start = 49152 +# port_range_end = 65535 + +# The secret value belongs in ~/.labby/.env, never here. +# bearer_token_env = "LABBY_PROXY_BEARER_TOKEN" +# oauth_scopes = ["mcp:read", "mcp:write"] + +# Ambient child variables beyond the runtime-essential scrubbed allowlist. +# inherit_env = [] +# shutdown_grace_ms = 3000 # valid range: 1..=60000 + # ── HTTP API ───────────────────────────────────────────────────────────────── [api] diff --git a/crates/labby/src/api/openapi.rs b/crates/labby/src/api/openapi.rs index 042014a72..76d79972a 100644 --- a/crates/labby/src/api/openapi.rs +++ b/crates/labby/src/api/openapi.rs @@ -247,6 +247,28 @@ pub fn build_action_schemas(services: &[RegisteredService]) -> Vec<(String, RefO let mut schemas = Vec::new(); for svc in services { let svc_pascal = to_pascal_case(svc.name); + let action_names = svc + .actions + .iter() + .map(|action| serde_json::Value::String(action.name.to_string())) + .collect::>(); + let request = ObjectBuilder::new() + .property( + "action", + ObjectBuilder::new() + .schema_type(SchemaType::Type(Type::String)) + .enum_values(Some(action_names)), + ) + .required("action") + .property( + "params", + ObjectBuilder::new().schema_type(SchemaType::Type(Type::Object)), + ) + .build(); + schemas.push(( + format!("{svc_pascal}ActionRequest"), + RefOr::T(request.into()), + )); for action in svc.actions { if action.params.is_empty() { continue; @@ -407,9 +429,10 @@ pub fn build_service_paths(service_names: &[String]) -> Vec<(String, PathItem)> .content( "application/json", ContentBuilder::new() - .schema(Some(RefOr::Ref(utoipa::openapi::Ref::new( - "#/components/schemas/ActionRequest", - )))) + .schema(Some(RefOr::Ref(utoipa::openapi::Ref::new(format!( + "#/components/schemas/{}ActionRequest", + to_pascal_case(svc) + ))))) .build(), ) .required(Some(Required::True)) diff --git a/crates/labby/src/docs/artifacts.rs b/crates/labby/src/docs/artifacts.rs index ee0675513..77d0335f8 100644 --- a/crates/labby/src/docs/artifacts.rs +++ b/crates/labby/src/docs/artifacts.rs @@ -86,6 +86,14 @@ fn build_artifacts(projection: &DocsProjection) -> Result> { "docs/generated/env-reference.json", render::json(&projection.env_reference)?, ), + artifact( + "docs/generated/proxy-config-reference.md", + render::proxy_config_reference(&projection.proxy_config_reference), + ), + artifact( + "docs/generated/proxy-config-reference.json", + render::json(&projection.proxy_config_reference)?, + ), artifact( "docs/generated/action-catalog.md", render::action_catalog(&projection.action_catalog), diff --git a/crates/labby/src/docs/projection.rs b/crates/labby/src/docs/projection.rs index 0cef13cde..6daddaf9e 100644 --- a/crates/labby/src/docs/projection.rs +++ b/crates/labby/src/docs/projection.rs @@ -9,8 +9,8 @@ use serde::Deserialize; use super::routes::{build_route_docs, service_has_action_api_route}; use super::types::{ - DocsProjection, EnvDoc, FeatureClass, FeatureDoc, FeatureMatrix, FeatureMismatch, ServiceDoc, - ServiceExposure, SurfaceAvailability, + ConfigDoc, DocsProjection, EnvDoc, FeatureClass, FeatureDoc, FeatureMatrix, FeatureMismatch, + ServiceDoc, ServiceExposure, SurfaceAvailability, }; use crate::catalog::build_catalog; use crate::registry::{RegisteredService, build_docs_registry}; @@ -35,6 +35,7 @@ pub fn build_docs_projection(repo_root: &Path) -> Result { let services = registry.services(); let feature_matrix = build_feature_matrix(repo_root)?; let service_catalog = build_service_catalog(services, &feature_matrix, repo_root); + let proxy_config_reference = build_proxy_config_reference(); let env_reference = build_env_reference(&service_catalog); let action_catalog = super::action_catalog::build_action_catalog(services); let api_route_services = service_catalog @@ -51,6 +52,7 @@ pub fn build_docs_projection(repo_root: &Path) -> Result { Ok(DocsProjection { mcp_help, service_catalog, + proxy_config_reference, env_reference, action_catalog, feature_matrix, @@ -91,6 +93,32 @@ fn build_service_catalog( }); } + if !docs.iter().any(|service| service.name == "proxy") { + docs.push(ServiceDoc { + name: "proxy".to_string(), + display_name: "Stdio MCP Proxy".to_string(), + description: + "Expose one explicitly selected stdio MCP server as faithful Streamable HTTP" + .to_string(), + category: "bootstrap".to_string(), + status: "available".to_string(), + feature: Some("gateway".to_string()), + exposure: ServiceExposure::FeatureGated, + surfaces: SurfaceAvailability { + cli: true, + mcp: false, + api: false, + web_ui: false, + }, + default_port: None, + docs_url: Some("docs/guides/STDIO_MCP_PROXY.md".to_string()), + coverage_doc: None, + upstream_doc: None, + supports_multi_instance: false, + metadata_source: "CLI runtime + ProxyPreferences".to_string(), + }); + } + docs.sort_by(|a, b| a.name.cmp(&b.name)); docs } @@ -161,12 +189,142 @@ fn build_env_reference(services: &[ServiceDoc]) -> Vec { meta.default_port, )); } + vars.extend([ + EnvDoc { + service: "proxy".to_string(), + env_var: "LABBY_PROXY_BEARER_TOKEN".to_string(), + required: false, + secret: true, + description: "Default static bearer secret; the key name may be changed with proxy.bearer_token_env" + .to_string(), + example: "".to_string(), + default_port: None, + }, + EnvDoc { + service: "proxy".to_string(), + env_var: "LABBY_TAILSCALE_BIN".to_string(), + required: false, + secret: false, + description: "Override the Tailscale CLI executable used by proxy publication and preflight" + .to_string(), + example: "tailscale".to_string(), + default_port: None, + }, + EnvDoc { + service: "proxy".to_string(), + env_var: "LABBY_GW_UPSTREAM_STDERR".to_string(), + required: false, + secret: false, + description: "Set forwarding level for the proxied stdio child's redacted stderr; default debug" + .to_string(), + example: "debug".to_string(), + default_port: None, + }, + EnvDoc { + service: "proxy".to_string(), + env_var: "LABBY_PROXY_TEST_RENEW_MS".to_string(), + required: false, + secret: false, + description: "Test-only OAuth lease renewal override, compiled only with proxy-testkit" + .to_string(), + example: "100".to_string(), + default_port: None, + }, + ]); vars.sort_by(|a, b| { (a.service.as_str(), a.env_var.as_str()).cmp(&(b.service.as_str(), b.env_var.as_str())) }); vars } +fn build_proxy_config_reference() -> Vec { + let entries = [ + ( + "exposure", + "tailscale|local", + "tailscale", + None, + "Publication controller", + ), + ( + "auth", + "tailnet|bearer|oauth|none", + "tailnet", + None, + "Authentication policy", + ), + ( + "path", + "absolute non-root path", + "/mcp", + None, + "Public MCP endpoint path", + ), + ( + "port", + "random|u16", + "random", + None, + "External Tailscale HTTPS port selection", + ), + ( + "port_range_start", + "u16", + "49152", + None, + "First random external-port candidate", + ), + ( + "port_range_end", + "u16", + "65535", + None, + "Last random external-port candidate", + ), + ( + "bearer_token_env", + "environment variable name", + "LABBY_PROXY_BEARER_TOKEN", + Some("LABBY_PROXY_BEARER_TOKEN"), + "Environment key containing the static bearer secret", + ), + ( + "oauth_scopes", + "string[]", + "[mcp:read, mcp:write]", + None, + "Scopes required by the exact OAuth resource lease", + ), + ( + "inherit_env", + "environment variable name[]", + "[]", + None, + "Additional ambient variables inherited by the scrubbed child", + ), + ( + "shutdown_grace_ms", + "u64 (1..=60000)", + "3000", + None, + "Grace period preference for supervised shutdown", + ), + ]; + entries + .into_iter() + .map(|(key, ty, default, env_override, description)| ConfigDoc { + section: "proxy".to_string(), + key: key.to_string(), + toml_path: format!("proxy.{key}"), + ty: ty.to_string(), + default: default.to_string(), + secret: false, + env_override: env_override.map(str::to_string), + description: description.to_string(), + }) + .collect() +} + fn env_docs( service: &ServiceDoc, envs: &[EnvVar], @@ -714,4 +872,87 @@ mod tests { .collect::>(); assert_eq!(help_actions, projected_mcp_actions); } + + #[test] + fn proxy_projection_covers_cli_config_env_actions_and_service_inventory() { + let projection = build_docs_projection(&workspace_root().unwrap()).unwrap(); + let service = projection + .service_catalog + .iter() + .find(|service| service.name == "proxy") + .expect("proxy service inventory"); + assert!(service.surfaces.cli); + assert!(!service.surfaces.mcp); + assert!(!service.surfaces.api); + + let config_keys = projection + .proxy_config_reference + .iter() + .map(|entry| entry.key.as_str()) + .collect::>(); + assert_eq!( + config_keys, + BTreeSet::from([ + "auth", + "bearer_token_env", + "exposure", + "inherit_env", + "oauth_scopes", + "path", + "port", + "port_range_end", + "port_range_start", + "shutdown_grace_ms", + ]) + ); + assert!(projection.env_reference.iter().any(|entry| { + entry.service == "proxy" && entry.env_var == "LABBY_PROXY_BEARER_TOKEN" && entry.secret + })); + + for (service, action) in [ + ("setup", "proxy.configure"), + ("doctor", "proxy.preflight"), + ("gateway", "gateway.oauth.resource_lease.create"), + ("gateway", "gateway.oauth.resource_lease.renew"), + ("gateway", "gateway.oauth.resource_lease.release"), + ] { + assert!( + projection + .action_catalog + .iter() + .any(|entry| entry.service == service && entry.action == action), + "missing generated action {service}:{action}" + ); + } + + let help = super::super::render::cli_help(); + for heading in [ + "## `labby proxy`", + "## `labby setup proxy`", + "## `labby doctor proxy`", + ] { + assert!( + help.contains(heading), + "missing generated CLI heading {heading}" + ); + } + + assert!( + projection + .api_routes + .iter() + .any(|route| route.method == "POST" && route.path == "/v1/gateway") + ); + #[cfg(feature = "api-docs")] + for action in [ + "gateway.oauth.resource_lease.create", + "gateway.oauth.resource_lease.renew", + "gateway.oauth.resource_lease.release", + ] { + assert!( + projection.openapi_json.contains(action), + "OpenAPI omits {action}" + ); + } + } } diff --git a/crates/labby/src/docs/render.rs b/crates/labby/src/docs/render.rs index e0f1afc79..e5805cb0f 100644 --- a/crates/labby/src/docs/render.rs +++ b/crates/labby/src/docs/render.rs @@ -6,7 +6,7 @@ use serde::Serialize; use crate::catalog::ParamEntry; use super::types::{ - ActionDoc, EnvDoc, FeatureDoc, FeatureMatrix, ParamDoc, RouteDoc, ServiceDoc, + ActionDoc, ConfigDoc, EnvDoc, FeatureDoc, FeatureMatrix, ParamDoc, RouteDoc, ServiceDoc, SurfaceAvailability, }; @@ -91,6 +91,29 @@ pub fn env_reference(vars: &[EnvDoc]) -> String { out } +pub fn proxy_config_reference(entries: &[ConfigDoc]) -> String { + let mut out = header("proxy-config-reference", "labby docs generate"); + out.push_str( + "Values are non-secret unless marked otherwise. Literal bearer tokens belong in the environment file, never TOML.\n\n", + ); + out.push_str("| TOML path | Type | Default | Secret | Environment override | Description |\n"); + out.push_str("| --- | --- | --- | --- | --- | --- |\n"); + for entry in entries { + writeln!( + out, + "| {} | {} | {} | {} | {} | {} |", + code(&entry.toml_path), + code(&entry.ty), + code(&entry.default), + entry.secret, + cell(&opt(entry.env_override.as_deref())), + cell(&entry.description), + ) + .ok(); + } + out +} + pub fn action_catalog(actions: &[ActionDoc]) -> String { let mut out = header("action-catalog", "labby docs generate"); out.push_str( @@ -177,6 +200,7 @@ pub fn generated_readme() -> String { "service-catalog.md/json", "action-catalog.md/json", "env-reference.md/json", + "proxy-config-reference.md/json", "api-routes.md/json", "openapi.json", "feature-matrix.md/json", diff --git a/crates/labby/src/docs/types.rs b/crates/labby/src/docs/types.rs index 3a83330bc..d41687f81 100644 --- a/crates/labby/src/docs/types.rs +++ b/crates/labby/src/docs/types.rs @@ -4,6 +4,7 @@ use serde::Serialize; pub struct DocsProjection { pub mcp_help: crate::catalog::Catalog, pub service_catalog: Vec, + pub proxy_config_reference: Vec, pub env_reference: Vec, pub action_catalog: Vec, pub feature_matrix: FeatureMatrix, @@ -11,6 +12,18 @@ pub struct DocsProjection { pub openapi_json: String, } +#[derive(Debug, Clone, Serialize)] +pub struct ConfigDoc { + pub section: String, + pub key: String, + pub toml_path: String, + pub ty: String, + pub default: String, + pub secret: bool, + pub env_override: Option, + pub description: String, +} + #[derive(Debug, Clone, Serialize)] pub struct ServiceDoc { pub name: String, diff --git a/docs/ARCH.md b/docs/ARCH.md index 56c0af73a..4ceb26882 100644 --- a/docs/ARCH.md +++ b/docs/ARCH.md @@ -6,7 +6,7 @@ updated: "2026-07-30" # Architecture -`lab` is a Rust MCP gateway implemented as a workspace split between reusable gateway/auth/runtime crates and product-facing dispatch and surface adapters. The supported product boundary is gateway, Code Mode, authentication, protected routes, setup, doctor, server logs, snippets, and the optional filesystem browser. +`lab` is a Rust MCP gateway implemented as a workspace split between reusable gateway/auth/runtime crates and product-facing dispatch and surface adapters. The supported product boundary is gateway, Code Mode, authentication, protected routes, the direct stdio MCP proxy, setup, doctor, server logs, snippets, and the optional filesystem browser. ## Core Shape @@ -88,8 +88,11 @@ and TypeScript descriptor generation. Hosts inject tools through `CodeModeHost`. pools, discovery/import orchestration, virtual servers, protected routes, gateway OAuth lifecycle, manager state, the Code Mode host adapter, its own `action`/`params` dispatch helpers, and the stdio spawn-guard/SSRF security -checks. It does not own product config rendering or `.env` writes; those are -injected by the host through `GatewayConfigStore`. +checks. Its public direct-stdio connector gives the product proxy the same +environment scrubbing, stderr draining, lifecycle negotiation, Unix process +group, and Windows Job Object ownership without routing through the aggregate +gateway catalog. It does not own product config rendering or `.env` writes; +those are injected by the host through `GatewayConfigStore`. ### `crates/labby-web` @@ -113,6 +116,8 @@ can keep `unsafe_code = "forbid"` elsewhere. - output rendering - install/uninstall flows - doctor and operator workflows +- foreground direct stdio proxy orchestration, loopback HTTP, Tailscale Serve, + and ephemeral OAuth lease supervision - product-local dispatch and config-store adapters It must stay thin at the surface boundary. Reusable gateway, Code Mode, auth, @@ -212,6 +217,12 @@ opts into: - MCP HTTP: `labby serve` - HTTP API and Labby web UI: `labby serve` +`labby proxy` is deliberately different: it is a CLI-only foreground product +runtime for one explicitly selected child. Its HTTP endpoint exposes the +child's MCP surface directly and does not register a `proxy` MCP tool or +`/v1/proxy` action route. OAuth lease management goes through the existing +admin-authenticated `gateway` action surface on a live daemon. + All three consume the same service metadata and service clients. The canonical ownership and dependency rules between `labby-apis`, extracted runtime crates, the shared dispatch layer, and the product surfaces live in [DISPATCH.md](./dev/DISPATCH.md). @@ -247,6 +258,20 @@ Normal request flow: 5. Return typed or surface-neutral data to the caller surface 6. Render via CLI, MCP envelope, API envelope, or web view +Direct proxy flow: + +1. Resolve and spawn one child through the reusable direct-stdio connector. +2. Bind a Streamable HTTP router to loopback with exact Host/Origin policy. +3. Apply tailnet, bearer, OAuth, or explicit no-auth policy. +4. For OAuth, lease the exact public resource through the live daemon. +5. Publish and supervise one exact Tailscale Serve mapping when selected. +6. On Ctrl+C or component failure, clean owned HTTP, Serve, lease, and process + resources without touching aggregate gateway state. + +See [guides/STDIO_MCP_PROXY.md](./guides/STDIO_MCP_PROXY.md) for the operator +contract and [contracts/stdio-mcp-proxy.md](./contracts/stdio-mcp-proxy.md) for +the stable wire and CLI vocabulary. + ## Config Boundary `labby-apis` never reads config files or ambient env on its own. Config loading lives in `labby`. diff --git a/docs/README.md b/docs/README.md index eb90c65f6..203aeddd6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,8 @@ The docs are split by topic so contributors do not have to recover architecture, - Use [design/CLI_DESIGN_SYSTEM.md](./design/CLI_DESIGN_SYSTEM.md) for the human-readable CLI output language and shared color policy. - Use [design/component-development.md](./design/component-development.md) and [design/design-system-contract.md](./design/design-system-contract.md) when building or revising Labby web UI components. - Use [CONFIG.md](./runtime/CONFIG.md), [INCUS.md](./runtime/INCUS.md), [HOST_GATEWAY.md](./runtime/HOST_GATEWAY.md), and [OPERATIONS.md](./OPERATIONS.md) for setup, recommended Incus deployment, gateway runtime choices, and operator workflows. +- Use [guides/STDIO_MCP_PROXY.md](./guides/STDIO_MCP_PROXY.md) to expose one + stdio MCP server directly over loopback or an owned Tailscale Serve port. - Refer to [OAUTH.md](./runtime/OAUTH.md) for bearer vs OAuth mode selection, Google-backed authorization flow, lab-issued JWT behavior, and callback-forwarding constraints. - Use [CALLBACK_RELAY.md](./runtime/CALLBACK_RELAY.md) for the public OAuth callback relay cutover and rollback runbook. - Use [GATEWAY.md](./services/GATEWAY.md) when managing upstream MCP gateways over CLI, MCP, `/v1/gateway`, or Gateway-managed OAuth protected MCP routes. @@ -66,16 +68,17 @@ The docs are split by topic so contributors do not have to recover architecture, ### If You Are Operating the Project 1. [CONFIG.md](./runtime/CONFIG.md) -2. [INCUS.md](./runtime/INCUS.md) -3. [HOST_GATEWAY.md](./runtime/HOST_GATEWAY.md) -4. [TRANSPORT.md](./surfaces/TRANSPORT.md) -5. [OAUTH.md](./runtime/OAUTH.md) (if deploying with OAuth) -6. [GATEWAY.md](./services/GATEWAY.md) (if managing upstream MCP gateways) -7. [UPSTREAM.md](./services/UPSTREAM.md) (if proxying upstream MCP servers) -8. [CALLBACK_RELAY.md](./runtime/CALLBACK_RELAY.md) (when operating the public OAuth relay) -9. [REVERSE_PROXY.md](./runtime/REVERSE_PROXY.md) -10. [OPERATIONS.md](./OPERATIONS.md) -11. [CLI.md](./surfaces/CLI.md) +2. [guides/STDIO_MCP_PROXY.md](./guides/STDIO_MCP_PROXY.md) (if directly exposing a stdio server) +3. [INCUS.md](./runtime/INCUS.md) +4. [HOST_GATEWAY.md](./runtime/HOST_GATEWAY.md) +5. [TRANSPORT.md](./surfaces/TRANSPORT.md) +6. [OAUTH.md](./runtime/OAUTH.md) (if deploying with OAuth) +7. [GATEWAY.md](./services/GATEWAY.md) (if managing upstream MCP gateways) +8. [UPSTREAM.md](./services/UPSTREAM.md) (if proxying upstream MCP servers) +9. [CALLBACK_RELAY.md](./runtime/CALLBACK_RELAY.md) (when operating the public OAuth relay) +10. [REVERSE_PROXY.md](./runtime/REVERSE_PROXY.md) +11. [OPERATIONS.md](./OPERATIONS.md) +12. [CLI.md](./surfaces/CLI.md) ## Topic Map @@ -103,6 +106,9 @@ The docs are split by topic so contributors do not have to recover architecture, Upstream MCP proxy gateway: config, discovery, tool collision handling, circuit breaker, resource proxying. - [TRANSPORT.md](./surfaces/TRANSPORT.md) Stdio and streamable HTTP transport: middleware stack, stateless discovery, subscriptions, DNS rebinding protection, CORS. +- [guides/STDIO_MCP_PROXY.md](./guides/STDIO_MCP_PROXY.md) + Direct stdio-to-Streamable-HTTP proxy quickstart, configuration, auth, + Tailscale ownership, security, cleanup, and troubleshooting. - `apps/gateway-admin/README.md` Labby admin UI: local frontend workflow, static export, and same-origin deployment model. - [design/component-development.md](./design/component-development.md) diff --git a/docs/contracts/stdio-mcp-proxy.md b/docs/contracts/stdio-mcp-proxy.md index b5b4f0acf..3afe9fadc 100644 --- a/docs/contracts/stdio-mcp-proxy.md +++ b/docs/contracts/stdio-mcp-proxy.md @@ -1,14 +1,14 @@ --- title: "Contract: Stdio MCP Proxy" created: "2026-07-31" -updated: "2026-07-31" +updated: "2026-08-01" --- # Contract: Stdio MCP Proxy -Status: implementation +Status: implemented Surfaces: CLI, Streamable HTTP, internal HTTP API -Related: [spec](../specs/stdio-mcp-proxy.md), `docs/dev/ERRORS.md`, `docs/design/SERIALIZATION.md` +Related: [guide](../guides/STDIO_MCP_PROXY.md), [spec](../specs/stdio-mcp-proxy.md), [research](../reports/2026-07-31-stdio-mcp-proxy-research.md), [implementation plan](../superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md), `docs/dev/ERRORS.md`, `docs/design/SERIALIZATION.md` This contract pins the stable CLI grammar, configuration vocabulary, output shape, HTTP discovery behavior, auth challenges, and internal OAuth lease API for `labby proxy`. @@ -134,13 +134,21 @@ A token with insufficient scope returns HTTP 403 and an `insufficient_scope` cha Missing or invalid static bearer credentials return HTTP 401. Static token comparison is constant-time. The bearer challenge may advertise the MCP resource URL but does not expose the configured token source. -## Internal resource lease API +## Internal resource lease actions -These routes are under existing authenticated `/v1/*` middleware and require `lab:admin`: +Lease operations use the existing authenticated generic gateway route: + +```http +POST /v1/gateway +``` + +The request envelope is `{ "action": "...", "params": { ... } }`. The route +and each action require `lab:admin`; there are no dedicated +`/v1/internal/proxy-resource-leases` routes. ### Create -`POST /v1/internal/proxy-resource-leases` +`gateway.oauth.resource_lease.create` Request: @@ -148,35 +156,45 @@ Request: { "resource": "https://node.example.ts.net:53147/mcp", "scopes": ["mcp:read", "mcp:write"], - "ttl_secs": 120 + "ttl_secs": 120, + "owner": "bounded-redacted-owner-label" } ``` -Response: HTTP 201 with a `ResourceLease` document. +Response: HTTP 200 with a `ResourceLease` result. ### Renew -`PUT /v1/internal/proxy-resource-leases/` +`gateway.oauth.resource_lease.renew` Request: ```json -{ "ttl_secs": 120 } +{ "id": "opaque-lease-id", "ttl_secs": 120 } ``` -Response: HTTP 200 with the renewed lease. Unknown or expired lease returns 404. +Response: HTTP 200 with the renewed lease. Unknown or expired IDs return the +gateway's structured `invalid_param` error. ### Release -`DELETE /v1/internal/proxy-resource-leases/` +`gateway.oauth.resource_lease.release` -Response: HTTP 204. Releasing an unknown lease is idempotent and also returns 204. +Request: + +```json +{ "id": "opaque-lease-id" } +``` + +Response: HTTP 200 with `ResourceLeaseReleaseView`. Releasing an unknown or +expired ID returns `invalid_param`; the proxy guard itself avoids a second +release after a successful release. ### Lease document ```json { - "id": "uuid", + "id": "opaque-lease-id", "resource": "https://node.example.ts.net:53147/mcp", "scopes": ["mcp:read", "mcp:write"], "expires_at_unix": 1785555600 @@ -185,31 +203,40 @@ Response: HTTP 204. Releasing an unknown lease is idempotent and also returns 20 Resource URLs must be absolute HTTPS URLs without credentials, query, or fragment. The current daemon lease action does not accept loopback HTTP resources, so `labby proxy --local --auth oauth` fails clearly until an explicit local-development lease policy is added; it never downgrades auth. +The proxy creates a 120-second lease, renews it every 40 seconds plus bounded +jitter, and releases it on normal shutdown. The daemon prunes expired leases +every 30 seconds, which is the forced-termination recovery path. The lease ID +must be treated as secret diagnostic material and never appears in readiness +output or logs. + ## Tailscale ownership Labby records the external port and exact loopback target it requested. Cleanup may issue an `off` command only if the current mapping still matches that ownership record. It must not call `tailscale serve reset`. -## Error kinds +The exact owned command is +`tailscale serve --yes --https= http://127.0.0.1:`. Startup +and runtime status checks require that exact backend. Ctrl+C performs normal +cleanup; after an uncatchable crash, an operator may use exact-port `off` only +after verifying ownership in `tailscale serve status --json`. -Stable proxy-specific error kinds used in JSON or API envelopes: +## Setup and doctor -| Kind | Meaning | -|---|---| -| `proxy_invalid_config` | Proxy preference validation failed. | -| `proxy_command_not_found` | Program or inferred runtime could not be resolved. | -| `proxy_child_start_failed` | Child spawn or MCP lifecycle failed. | -| `proxy_auth_unavailable` | Selected auth policy cannot be established. | -| `proxy_oauth_lease_failed` | Lease create or renewal failed. | -| `proxy_tailscale_unavailable` | Tailscale is missing, disconnected, or unsupported. | -| `proxy_port_unavailable` | Fixed port is occupied or random attempts were exhausted. | -| `proxy_mapping_conflict` | Mapping changed and ownership-safe cleanup refused. | -| `proxy_runtime_failed` | A supervised component exited unexpectedly. | - -Messages may improve; kinds are stable. +`labby setup proxy` owns comment-preserving preference writes and secret-safe +`.env` storage. On Unix the Labby home and secret file are mode `0700` and +`0600`. `labby doctor proxy` with no route parameters runs local proxy +preflight. Supplying app/MCP URLs and a route retains the routed public +reverse-proxy doctor contract. + +Proxy startup and supervisor failures are CLI failures with contextual stderr; +the current CLI does not claim a complete stable proxy-specific JSON error-kind +vocabulary. Gateway lease calls retain the shared structured action error +contract, including `proxy_auth_unavailable` when issuer or lease support is +missing. ## Compatibility - The public endpoint supports the modern stateless revision targeted by the pinned RMCP SDK. - Legacy stdio children are adapted internally. - The CLI may gain new options without breaking this contract. -- Removing or renaming the listed options, JSON fields, config keys, API routes, auth modes, or error kinds is breaking. +- Removing or renaming the listed options, JSON fields, config keys, gateway + lease actions, or auth modes is breaking. diff --git a/docs/generated/README.md b/docs/generated/README.md index aba75dcc6..2f9f317cc 100644 --- a/docs/generated/README.md +++ b/docs/generated/README.md @@ -21,6 +21,7 @@ just docs-check | `service-catalog.md/json` | `labby docs generate` | | `action-catalog.md/json` | `labby docs generate` | | `env-reference.md/json` | `labby docs generate` | +| `proxy-config-reference.md/json` | `labby docs generate` | | `api-routes.md/json` | `labby docs generate` | | `openapi.json` | `labby docs generate` | | `feature-matrix.md/json` | `labby docs generate` | diff --git a/docs/generated/action-catalog.json b/docs/generated/action-catalog.json index 8a61f170e..0e510f1e1 100644 --- a/docs/generated/action-catalog.json +++ b/docs/generated/action-catalog.json @@ -133,6 +133,26 @@ "inventory_scope": "global_inventory_not_active_runtime_exposure", "builtin": false }, + { + "service": "doctor", + "action": "proxy.preflight", + "description": "Validate persisted stdio-proxy configuration and read-only local dependencies", + "destructive": false, + "requires_admin": false, + "required_scopes": [], + "params": [], + "returns": "DoctorReport", + "surface_availability": { + "cli": true, + "mcp": true, + "api": true, + "web_ui": false + }, + "requires_http_subject": false, + "auth_posture": "uses the selected transport auth and gateway visibility policy", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": false + }, { "service": "doctor", "action": "schema", @@ -1185,6 +1205,117 @@ "inventory_scope": "global_inventory_not_active_runtime_exposure", "builtin": false }, + { + "service": "gateway", + "action": "gateway.oauth.resource_lease.create", + "description": "Create an in-memory OAuth protected-resource lease", + "destructive": false, + "requires_admin": true, + "required_scopes": [ + "lab:admin" + ], + "params": [ + { + "name": "resource", + "ty": "string", + "required": true, + "description": "Exact absolute HTTPS OAuth resource audience" + }, + { + "name": "scopes", + "ty": "string[]", + "required": true, + "description": "Scopes accepted for the resource" + }, + { + "name": "ttl_secs", + "ty": "integer", + "required": true, + "description": "Lease lifetime in seconds (1 through 86400)" + }, + { + "name": "owner", + "ty": "string", + "required": true, + "description": "Bounded diagnostic owner label" + } + ], + "returns": "ResourceLease", + "surface_availability": { + "cli": true, + "mcp": true, + "api": true, + "web_ui": true + }, + "requires_http_subject": false, + "auth_posture": "requires lab:admin in addition to the selected transport authentication", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": false + }, + { + "service": "gateway", + "action": "gateway.oauth.resource_lease.release", + "description": "Release an active in-memory OAuth protected-resource lease", + "destructive": true, + "requires_admin": true, + "required_scopes": [ + "lab:admin" + ], + "params": [ + { + "name": "id", + "ty": "string", + "required": true, + "description": "Opaque resource lease identifier" + } + ], + "returns": "ResourceLeaseReleaseView", + "surface_availability": { + "cli": true, + "mcp": true, + "api": true, + "web_ui": true + }, + "requires_http_subject": false, + "auth_posture": "requires lab:admin in addition to the selected transport authentication", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": false + }, + { + "service": "gateway", + "action": "gateway.oauth.resource_lease.renew", + "description": "Renew an active in-memory OAuth protected-resource lease", + "destructive": false, + "requires_admin": true, + "required_scopes": [ + "lab:admin" + ], + "params": [ + { + "name": "id", + "ty": "string", + "required": true, + "description": "Opaque resource lease identifier" + }, + { + "name": "ttl_secs", + "ty": "integer", + "required": true, + "description": "Replacement lease lifetime in seconds" + } + ], + "returns": "ResourceLease", + "surface_availability": { + "cli": true, + "mcp": true, + "api": true, + "web_ui": true + }, + "requires_http_subject": false, + "auth_posture": "requires lab:admin in addition to the selected transport authentication", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": false + }, { "service": "gateway", "action": "gateway.oauth.start", @@ -2874,6 +3005,47 @@ "inventory_scope": "global_inventory_not_active_runtime_exposure", "builtin": false }, + { + "service": "setup", + "action": "proxy.configure", + "description": "Persist local stdio-proxy defaults and securely store a bearer secret when required", + "destructive": true, + "requires_admin": true, + "required_scopes": [ + "lab:admin" + ], + "params": [ + { + "name": "preferences", + "ty": "ProxyPreferences", + "required": true, + "description": "Validated non-secret proxy preferences to persist in config.toml" + }, + { + "name": "bearer_token", + "ty": "string", + "required": false, + "description": "Write-only bearer token read from stdin; never returned or logged" + }, + { + "name": "dry_run", + "ty": "boolean", + "required": false, + "description": "Preview whether config or secret files would change without mutation" + } + ], + "returns": "ProxySetupOutcome", + "surface_availability": { + "cli": true, + "mcp": true, + "api": true, + "web_ui": true + }, + "requires_http_subject": false, + "auth_posture": "requires lab:admin in addition to the selected transport authentication", + "inventory_scope": "global_inventory_not_active_runtime_exposure", + "builtin": false + }, { "service": "setup", "action": "repair", diff --git a/docs/generated/action-catalog.md b/docs/generated/action-catalog.md index 5b11c7c4e..b922008b3 100644 --- a/docs/generated/action-catalog.md +++ b/docs/generated/action-catalog.md @@ -11,6 +11,7 @@ This is a global inventory, not the active runtime exposure or authorization pol | `doctor` | `help` | false | false | false | | | `Catalog` | cli, mcp, api | | `doctor` | `oauth.relay.check` | false | false | true | lab:admin | `probe_targets: boolean` | `DoctorReport` | cli, mcp, api | | `doctor` | `proxy.check` | false | false | false | | `app_url*: string`
`mcp_url*: string`
`route*: string`
`backend_url: string` | `DoctorReport` | cli, mcp, api | +| `doctor` | `proxy.preflight` | false | false | false | | | `DoctorReport` | cli, mcp, api | | `doctor` | `schema` | false | false | false | | `action*: string` | `Schema` | cli, mcp, api | | `doctor` | `system.checks` | false | false | false | | | `DoctorReport` | cli, mcp, api | | `fs` | `fs.list` | false | false | false | | `path: string` | `{entries: [{name, path, kind, size, modified, accessible}], truncated: bool}` | mcp, api, web | @@ -43,6 +44,9 @@ This is a global inventory, not the active runtime exposure or authorization pol | `gateway` | `gateway.mcp.list` | false | false | true | lab:admin | | `GatewayMcpRuntimeView[]` | cli, mcp, api, web | | `gateway` | `gateway.oauth.clear` | false | false | true | lab:admin | `upstream*: string`
`subject: string` | `ok` | cli, mcp, api, web | | `gateway` | `gateway.oauth.probe` | false | false | true | lab:admin | `url*: string` | `ProbeResult` | cli, mcp, api, web | +| `gateway` | `gateway.oauth.resource_lease.create` | false | false | true | lab:admin | `resource*: string`
`scopes*: string[]`
`ttl_secs*: integer`
`owner*: string` | `ResourceLease` | cli, mcp, api, web | +| `gateway` | `gateway.oauth.resource_lease.release` | false | true | true | lab:admin | `id*: string` | `ResourceLeaseReleaseView` | cli, mcp, api, web | +| `gateway` | `gateway.oauth.resource_lease.renew` | false | false | true | lab:admin | `id*: string`
`ttl_secs*: integer` | `ResourceLease` | cli, mcp, api, web | | `gateway` | `gateway.oauth.start` | false | false | true | lab:admin | `upstream*: string`
`subject: string` | `BeginAuthorization` | cli, mcp, api, web | | `gateway` | `gateway.oauth.status` | false | false | true | lab:admin | `upstream*: string`
`subject: string` | `UpstreamOauthStatusView` | cli, mcp, api, web | | `gateway` | `gateway.oauth.wait` | false | false | true | lab:admin | `upstream*: string`
`subject: string`
`timeout_secs: integer` | `{authenticated: bool, timed_out: bool}` | cli, mcp, api, web | @@ -100,6 +104,7 @@ This is a global inventory, not the active runtime exposure or authorization pol | `setup` | `plugin_hook` | false | true | true | lab:admin | `repair: boolean` | `PluginHookReport` | cli, mcp, api, web | | `setup` | `plugin_sync` | false | true | true | lab:admin | | `PluginSyncOutcome` | cli, mcp, api, web | | `setup` | `plugins.installed` | false | false | true | lab:admin | `force: boolean` | `InstalledPlugin[]` | cli, mcp, api, web | +| `setup` | `proxy.configure` | false | true | true | lab:admin | `preferences*: ProxyPreferences`
`bearer_token: string`
`dry_run: boolean` | `ProxySetupOutcome` | cli, mcp, api, web | | `setup` | `repair` | false | true | true | lab:admin | | `SetupReport` | cli, mcp, api, web | | `setup` | `schema` | false | false | false | | `action*: string` | `Schema` | cli, mcp, api, web | | `setup` | `schema.get` | false | false | false | | `services: string[]` | `ServiceSchemaMap` | cli, mcp, api, web | diff --git a/docs/generated/cli-help.md b/docs/generated/cli-help.md index 69e602e2d..e56c6e10c 100644 --- a/docs/generated/cli-help.md +++ b/docs/generated/cli-help.md @@ -23,6 +23,7 @@ Commands: gateway Manage proxied upstream MCP gateways snippets Manage executable Code Mode snippets oauth Run local OAuth callback relay helpers + proxy Proxy a stdio MCP server to Streamable HTTP help Print this message or the help of the given subcommand(s) Options: @@ -220,7 +221,7 @@ Options: ```text Check public Lab and protected MCP proxy endpoints from caller-visible URLs -Usage: proxy [OPTIONS] --route +Usage: proxy [OPTIONS] Options: --app-url @@ -433,6 +434,7 @@ Commands: plugin-connectivity Validate connectivity to the lab MCP server check Check local setup prerequisites without mutating the filesystem repair Repair missing local setup prerequisites without contacting external services + proxy Configure defaults for the ephemeral stdio MCP proxy incusbackup Validate or apply local Incus backup policy incus-ssh Bootstrap container SSH trust from the host ~/.ssh/config install Copy the labby binary into ~/.local/bin so it is callable in your own terminal @@ -910,6 +912,70 @@ Options: Print help ``` +## `labby setup proxy` + +```text +Configure defaults for the ephemeral stdio MCP proxy + +Usage: proxy [OPTIONS] + +Options: + --exposure + Exposure mode to persist + + [possible values: tailscale, local] + + --json + Emit JSON instead of human-readable tables + + --auth + Authentication mode to persist + + [possible values: tailnet, bearer, oauth, none] + + --color + Control human-readable CLI styling + + [default: auto] + [possible values: auto, plain, color] + + --path + MCP HTTP path to persist + + --port + External Tailscale port, or `random` + + --port-range-start + First candidate in the random external-port range + + --port-range-end + Last candidate in the random external-port range + + --bearer-token-env + Environment key used for the proxy bearer secret + + --oauth-scope + OAuth scope to require; repeatable and replaces the configured list + + --inherit-env + Ambient environment variable inherited by child servers; repeatable + + --shutdown-grace-ms + Grace period before forced child shutdown + + --bearer-token-stdin + Read a bearer secret from stdin without echoing or persisting it in TOML + + -y, --yes + Accept existing values and built-in defaults without prompting + + --dry-run + Preview exact file changes without mutating config or secret files + + -h, --help + Print help +``` + ## `labby setup incusbackup` ```text @@ -3587,6 +3653,59 @@ Arguments: Print help for the subcommand(s) ``` +## `labby proxy` + +```text +Proxy a stdio MCP server to Streamable HTTP + +Usage: proxy [OPTIONS] ... + +Arguments: + ... + Child program or script followed by its arguments + +Options: + --json + Emit JSON instead of human-readable tables + + --port + Override the external port for this invocation + + --auth + Override the configured auth policy + + [possible values: tailnet, bearer, oauth, none] + + --color + Control human-readable CLI styling + + [default: auto] + [possible values: auto, plain, color] + + --bearer-token + One-run static bearer token; implies bearer auth + + [env: LABBY_PROXY_BEARER_TOKEN] + + --bearer-token-stdin + Read a one-run static bearer token from stdin; implies bearer auth + + --local + Override exposure to a local loopback URL + + --cwd + Child working directory + + --env + Explicit child environment entry; repeatable + + --inherit-env + Inherit one ambient environment variable; repeatable + + -h, --help + Print help +``` + ## `labby help` ```text diff --git a/docs/generated/env-reference.json b/docs/generated/env-reference.json index fe51488c7..dfaa1f717 100644 --- a/docs/generated/env-reference.json +++ b/docs/generated/env-reference.json @@ -1 +1,38 @@ -[] +[ + { + "service": "proxy", + "env_var": "LABBY_GW_UPSTREAM_STDERR", + "required": false, + "secret": false, + "description": "Set forwarding level for the proxied stdio child's redacted stderr; default debug", + "example": "debug", + "default_port": null + }, + { + "service": "proxy", + "env_var": "LABBY_PROXY_BEARER_TOKEN", + "required": false, + "secret": true, + "description": "Default static bearer secret; the key name may be changed with proxy.bearer_token_env", + "example": "", + "default_port": null + }, + { + "service": "proxy", + "env_var": "LABBY_PROXY_TEST_RENEW_MS", + "required": false, + "secret": false, + "description": "Test-only OAuth lease renewal override, compiled only with proxy-testkit", + "example": "100", + "default_port": null + }, + { + "service": "proxy", + "env_var": "LABBY_TAILSCALE_BIN", + "required": false, + "secret": false, + "description": "Override the Tailscale CLI executable used by proxy publication and preflight", + "example": "tailscale", + "default_port": null + } +] diff --git a/docs/generated/env-reference.md b/docs/generated/env-reference.md index 861a78454..2ef2d8def 100644 --- a/docs/generated/env-reference.md +++ b/docs/generated/env-reference.md @@ -4,3 +4,7 @@ Generated by `labby docs generate`. Do not edit by hand. | Service | Env Var | Required | Secret | Example | Description | | --- | --- | --- | --- | --- | --- | +| `proxy` | `LABBY_GW_UPSTREAM_STDERR` | false | false | `debug` | Set forwarding level for the proxied stdio child's redacted stderr; default debug | +| `proxy` | `LABBY_PROXY_BEARER_TOKEN` | false | true | `<labby_proxy_bearer_token>` | Default static bearer secret; the key name may be changed with proxy.bearer_token_env | +| `proxy` | `LABBY_PROXY_TEST_RENEW_MS` | false | false | `100` | Test-only OAuth lease renewal override, compiled only with proxy-testkit | +| `proxy` | `LABBY_TAILSCALE_BIN` | false | false | `tailscale` | Override the Tailscale CLI executable used by proxy publication and preflight | diff --git a/docs/generated/feature-matrix.json b/docs/generated/feature-matrix.json index 086221358..324226381 100644 --- a/docs/generated/feature-matrix.json +++ b/docs/generated/feature-matrix.json @@ -92,6 +92,16 @@ "mapped_crate_feature": null, "exception_reason": "standalone product slice" }, + { + "crate_name": "labby", + "feature": "proxy-testkit", + "dependencies": [], + "included_in_default": false, + "included_in_all": false, + "classification": "intentional_exception", + "mapped_crate_feature": null, + "exception_reason": "intentional crate-local exception" + }, { "crate_name": "labby", "feature": "systemd", diff --git a/docs/generated/feature-matrix.md b/docs/generated/feature-matrix.md index ce59fd9f1..1ae6b39d3 100644 --- a/docs/generated/feature-matrix.md +++ b/docs/generated/feature-matrix.md @@ -13,6 +13,7 @@ Feature invariant status: clean. | labby | `gateway` | ProductSlice | true | true | - | `dep:labby-codemode`
`dep:labby-gateway`
`dep:labby-openapi`
`web-ui` | | labby | `gateway-host` | IntentionalException | true | true | - | `gateway` | | labby | `lab-admin` | ProductSlice | false | true | - | | +| labby | `proxy-testkit` | IntentionalException | false | false | - | | | labby | `systemd` | HelperInternal | false | true | - | `dep:sd-notify` | | labby | `web-ui` | HelperInternal | true | true | - | `dep:labby-web` | | labby-apis | `all` | AggregateDefault | false | false | labby/all | | diff --git a/docs/generated/mcp-help.json b/docs/generated/mcp-help.json index 066a9f3ed..2591d1170 100644 --- a/docs/generated/mcp-help.json +++ b/docs/generated/mcp-help.json @@ -1059,6 +1059,84 @@ ], "returns": "GatewayServerSchema" }, + { + "name": "gateway.oauth.resource_lease.create", + "description": "Create an in-memory OAuth protected-resource lease", + "destructive": false, + "requires_admin": true, + "required_scopes": [ + "lab:admin" + ], + "params": [ + { + "name": "resource", + "ty": "string", + "required": true, + "description": "Exact absolute HTTPS OAuth resource audience" + }, + { + "name": "scopes", + "ty": "string[]", + "required": true, + "description": "Scopes accepted for the resource" + }, + { + "name": "ttl_secs", + "ty": "integer", + "required": true, + "description": "Lease lifetime in seconds (1 through 86400)" + }, + { + "name": "owner", + "ty": "string", + "required": true, + "description": "Bounded diagnostic owner label" + } + ], + "returns": "ResourceLease" + }, + { + "name": "gateway.oauth.resource_lease.renew", + "description": "Renew an active in-memory OAuth protected-resource lease", + "destructive": false, + "requires_admin": true, + "required_scopes": [ + "lab:admin" + ], + "params": [ + { + "name": "id", + "ty": "string", + "required": true, + "description": "Opaque resource lease identifier" + }, + { + "name": "ttl_secs", + "ty": "integer", + "required": true, + "description": "Replacement lease lifetime in seconds" + } + ], + "returns": "ResourceLease" + }, + { + "name": "gateway.oauth.resource_lease.release", + "description": "Release an active in-memory OAuth protected-resource lease", + "destructive": true, + "requires_admin": true, + "required_scopes": [ + "lab:admin" + ], + "params": [ + { + "name": "id", + "ty": "string", + "required": true, + "description": "Opaque resource lease identifier" + } + ], + "returns": "ResourceLeaseReleaseView" + }, { "name": "gateway.oauth.probe", "description": "Probe a URL for OAuth support via RFC 8414 AS metadata discovery. Rejects userinfo, query strings, and fragments. Registers a transient OAuth manager keyed by URL host, port, and path; it is persisted only after a successful callback updates gateway config.", @@ -1351,6 +1429,15 @@ "params": [], "returns": "DoctorReport" }, + { + "name": "proxy.preflight", + "description": "Validate persisted stdio-proxy configuration and read-only local dependencies", + "destructive": false, + "requires_admin": false, + "required_scopes": [], + "params": [], + "returns": "DoctorReport" + }, { "name": "proxy.check", "description": "Check public Lab and protected MCP proxy endpoints from caller-visible URLs. Probes: app health, protected-resource metadata, OAuth bearer challenge, wrong-path 404 behavior, and (when backend_url is provided) backend-leak redaction.", @@ -1718,6 +1805,36 @@ "params": [], "returns": "SetupReport" }, + { + "name": "proxy.configure", + "description": "Persist local stdio-proxy defaults and securely store a bearer secret when required", + "destructive": true, + "requires_admin": true, + "required_scopes": [ + "lab:admin" + ], + "params": [ + { + "name": "preferences", + "ty": "ProxyPreferences", + "required": true, + "description": "Validated non-secret proxy preferences to persist in config.toml" + }, + { + "name": "bearer_token", + "ty": "string", + "required": false, + "description": "Write-only bearer token read from stdin; never returned or logged" + }, + { + "name": "dry_run", + "ty": "boolean", + "required": false, + "description": "Preview whether config or secret files would change without mutation" + } + ], + "returns": "ProxySetupOutcome" + }, { "name": "plugins.installed", "description": "List installed Claude Code lab plugins", diff --git a/docs/generated/mcp-help.md b/docs/generated/mcp-help.md index 820e8ed8f..77f6b722d 100644 --- a/docs/generated/mcp-help.md +++ b/docs/generated/mcp-help.md @@ -53,6 +53,9 @@ Generated by `labby docs generate`. Do not edit by hand. | `gateway` | bootstrap | available | `gateway.discovered_prompts` | false | `name*: string` | `string[]` | | `gateway` | bootstrap | available | `gateway.servers` | false | | `GatewayServersDoc` | | `gateway` | bootstrap | available | `gateway.schema` | false | `name*: string` | `GatewayServerSchema` | +| `gateway` | bootstrap | available | `gateway.oauth.resource_lease.create` | false | `resource*: string`
`scopes*: string[]`
`ttl_secs*: integer`
`owner*: string` | `ResourceLease` | +| `gateway` | bootstrap | available | `gateway.oauth.resource_lease.renew` | false | `id*: string`
`ttl_secs*: integer` | `ResourceLease` | +| `gateway` | bootstrap | available | `gateway.oauth.resource_lease.release` | true | `id*: string` | `ResourceLeaseReleaseView` | | `gateway` | bootstrap | available | `gateway.oauth.probe` | false | `url*: string` | `ProbeResult` | | `gateway` | bootstrap | available | `gateway.oauth.start` | false | `upstream*: string`
`subject: string` | `BeginAuthorization` | | `gateway` | bootstrap | available | `gateway.oauth.status` | false | `upstream*: string`
`subject: string` | `UpstreamOauthStatusView` | @@ -69,6 +72,7 @@ Generated by `labby docs generate`. Do not edit by hand. | `doctor` | bootstrap | available | `system.checks` | false | | `DoctorReport` | | `doctor` | bootstrap | available | `audit.full` | false | | `stream<Finding>` | | `doctor` | bootstrap | available | `auth.check` | false | | `DoctorReport` | +| `doctor` | bootstrap | available | `proxy.preflight` | false | | `DoctorReport` | | `doctor` | bootstrap | available | `proxy.check` | false | `app_url*: string`
`mcp_url*: string`
`route*: string`
`backend_url: string` | `DoctorReport` | | `doctor` | bootstrap | available | `oauth.relay.check` | false | `probe_targets: boolean` | `DoctorReport` | | `setup` | bootstrap | available | `help` | false | | `Catalog` | @@ -93,6 +97,7 @@ Generated by `labby docs generate`. Do not edit by hand. | `setup` | bootstrap | available | `plugin_connectivity` | false | `server_url: string` | `ConnectivityOutcome` | | `setup` | bootstrap | available | `check` | false | | `SetupReport` | | `setup` | bootstrap | available | `repair` | true | | `SetupReport` | +| `setup` | bootstrap | available | `proxy.configure` | true | `preferences*: ProxyPreferences`
`bearer_token: string`
`dry_run: boolean` | `ProxySetupOutcome` | | `setup` | bootstrap | available | `plugins.installed` | false | `force: boolean` | `InstalledPlugin[]` | | `setup` | bootstrap | available | `installed_plugins` | false | `force: boolean` | `InstalledPlugin[]` | | `setup` | bootstrap | available | `services.status` | false | | `ServiceStatus[]` | diff --git a/docs/generated/openapi.json b/docs/generated/openapi.json index fc55d8dbf..474edecd5 100644 --- a/docs/generated/openapi.json +++ b/docs/generated/openapi.json @@ -105,7 +105,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ActionRequest" + "$ref": "#/components/schemas/DoctorActionRequest" } } }, @@ -181,7 +181,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ActionRequest" + "$ref": "#/components/schemas/FsActionRequest" } } }, @@ -257,7 +257,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ActionRequest" + "$ref": "#/components/schemas/GatewayActionRequest" } } }, @@ -333,7 +333,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ActionRequest" + "$ref": "#/components/schemas/Lab_adminActionRequest" } } }, @@ -538,7 +538,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ActionRequest" + "$ref": "#/components/schemas/Server_logsActionRequest" } } }, @@ -614,7 +614,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ActionRequest" + "$ref": "#/components/schemas/SetupActionRequest" } } }, @@ -690,7 +690,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ActionRequest" + "$ref": "#/components/schemas/SnippetsActionRequest" } } }, @@ -771,6 +771,30 @@ "params": {} } }, + "DoctorActionRequest": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "help", + "schema", + "system.checks", + "audit.full", + "auth.check", + "proxy.preflight", + "proxy.check", + "oauth.relay.check" + ] + }, + "params": { + "type": "object" + } + } + }, "DoctorOauthRelayCheckParams": { "type": "object", "properties": { @@ -927,6 +951,23 @@ } } }, + "FsActionRequest": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "fs.list" + ] + }, + "params": { + "type": "object" + } + } + }, "FsFsListParams": { "type": "object", "properties": { @@ -935,6 +976,85 @@ } } }, + "GatewayActionRequest": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "help", + "schema", + "gateway.list", + "gateway.code_mode.get", + "gateway.code_mode.set", + "gateway.server.get", + "gateway.enrich.preview", + "gateway.enrich.apply", + "gateway.usage.metrics", + "gateway.usage.calls", + "gateway.supported_services", + "gateway.protected_route.list", + "gateway.protected_route.get", + "gateway.protected_route.add", + "gateway.protected_route.update", + "gateway.protected_route.remove", + "gateway.protected_route.test", + "gateway.virtual_server.enable", + "gateway.virtual_server.disable", + "gateway.virtual_server.remove", + "gateway.virtual_server.quarantine.list", + "gateway.virtual_server.quarantine.restore", + "gateway.virtual_server.set_surface", + "gateway.virtual_server.get_mcp_policy", + "gateway.virtual_server.set_mcp_policy", + "gateway.service_config.get", + "gateway.service_actions", + "gateway.service_config.set", + "gateway.get", + "gateway.client_config.get", + "gateway.test", + "gateway.discover", + "gateway.import", + "gateway.import_pending.list", + "gateway.import_pending.approve", + "gateway.import_pending.reject", + "gateway.import_tombstones.list", + "gateway.import_tombstones.clear", + "gateway.import_tombstones.restore", + "gateway.add", + "gateway.update", + "gateway.remove", + "gateway.reload", + "gateway.status", + "gateway.discovered_tools", + "gateway.discovered_resources", + "gateway.discovered_prompts", + "gateway.servers", + "gateway.schema", + "gateway.oauth.resource_lease.create", + "gateway.oauth.resource_lease.renew", + "gateway.oauth.resource_lease.release", + "gateway.oauth.probe", + "gateway.oauth.start", + "gateway.oauth.status", + "gateway.oauth.clear", + "gateway.oauth.wait", + "gateway.mcp.enable", + "gateway.mcp.list", + "gateway.clients.list", + "gateway.mcp.disable", + "gateway.mcp.cleanup", + "gateway.public_urls.get" + ] + }, + "params": { + "type": "object" + } + } + }, "GatewayGatewayAddParams": { "type": "object", "required": [ @@ -1250,6 +1370,58 @@ } } }, + "GatewayGatewayOauthResource_leaseCreateParams": { + "type": "object", + "required": [ + "resource", + "scopes", + "ttl_secs", + "owner" + ], + "properties": { + "owner": { + "type": "string" + }, + "resource": { + "type": "string" + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "ttl_secs": { + "type": "integer" + } + } + }, + "GatewayGatewayOauthResource_leaseReleaseParams": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, + "GatewayGatewayOauthResource_leaseRenewParams": { + "type": "object", + "required": [ + "id", + "ttl_secs" + ], + "properties": { + "id": { + "type": "string" + }, + "ttl_secs": { + "type": "integer" + } + } + }, "GatewayGatewayOauthStartParams": { "type": "object", "required": [ @@ -1649,6 +1821,25 @@ } } }, + "Lab_adminActionRequest": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "help", + "schema", + "onboarding.audit" + ] + }, + "params": { + "type": "object" + } + } + }, "Lab_adminOnboardingAuditParams": { "type": "object", "required": [ @@ -1674,6 +1865,25 @@ } } }, + "Server_logsActionRequest": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "help", + "schema", + "server_logs.query" + ] + }, + "params": { + "type": "object" + } + } + }, "Server_logsSchemaParams": { "type": "object", "required": [ @@ -1717,6 +1927,54 @@ } } }, + "SetupActionRequest": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "help", + "schema", + "state", + "bootstrap", + "schema.get", + "draft.get", + "draft.set", + "draft.discard", + "draft.commit", + "settings.state", + "settings.schema", + "settings.env_schema", + "settings.advanced_state", + "settings.update", + "settings.config.update", + "settings.env.update", + "plugin_hook", + "plugin_sync", + "plugin_export", + "plugin_connectivity", + "check", + "repair", + "proxy.configure", + "plugins.installed", + "installed_plugins", + "services.status", + "services_status", + "plugin.install", + "install_plugin", + "plugin.uninstall", + "uninstall_plugin", + "finalize" + ] + }, + "params": { + "type": "object" + } + } + }, "SetupDraftCommitParams": { "type": "object", "properties": { @@ -1812,6 +2070,23 @@ } } }, + "SetupProxyConfigureParams": { + "type": "object", + "required": [ + "preferences" + ], + "properties": { + "bearer_token": { + "type": "string" + }, + "dry_run": { + "type": "boolean" + }, + "preferences": { + "type": "string" + } + } + }, "SetupSchemaGetParams": { "type": "object", "properties": { @@ -2018,6 +2293,32 @@ } } }, + "SnippetsActionRequest": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "help", + "schema", + "snippets.list", + "snippets.get", + "snippets.exec", + "snippets.create", + "snippets.promote", + "snippets.validate", + "snippets.remove", + "snippets.test" + ] + }, + "params": { + "type": "object" + } + } + }, "SnippetsSchemaParams": { "type": "object", "required": [ diff --git a/docs/generated/proxy-config-reference.json b/docs/generated/proxy-config-reference.json new file mode 100644 index 000000000..8493e8be9 --- /dev/null +++ b/docs/generated/proxy-config-reference.json @@ -0,0 +1,102 @@ +[ + { + "section": "proxy", + "key": "exposure", + "toml_path": "proxy.exposure", + "ty": "tailscale|local", + "default": "tailscale", + "secret": false, + "env_override": null, + "description": "Publication controller" + }, + { + "section": "proxy", + "key": "auth", + "toml_path": "proxy.auth", + "ty": "tailnet|bearer|oauth|none", + "default": "tailnet", + "secret": false, + "env_override": null, + "description": "Authentication policy" + }, + { + "section": "proxy", + "key": "path", + "toml_path": "proxy.path", + "ty": "absolute non-root path", + "default": "/mcp", + "secret": false, + "env_override": null, + "description": "Public MCP endpoint path" + }, + { + "section": "proxy", + "key": "port", + "toml_path": "proxy.port", + "ty": "random|u16", + "default": "random", + "secret": false, + "env_override": null, + "description": "External Tailscale HTTPS port selection" + }, + { + "section": "proxy", + "key": "port_range_start", + "toml_path": "proxy.port_range_start", + "ty": "u16", + "default": "49152", + "secret": false, + "env_override": null, + "description": "First random external-port candidate" + }, + { + "section": "proxy", + "key": "port_range_end", + "toml_path": "proxy.port_range_end", + "ty": "u16", + "default": "65535", + "secret": false, + "env_override": null, + "description": "Last random external-port candidate" + }, + { + "section": "proxy", + "key": "bearer_token_env", + "toml_path": "proxy.bearer_token_env", + "ty": "environment variable name", + "default": "LABBY_PROXY_BEARER_TOKEN", + "secret": false, + "env_override": "LABBY_PROXY_BEARER_TOKEN", + "description": "Environment key containing the static bearer secret" + }, + { + "section": "proxy", + "key": "oauth_scopes", + "toml_path": "proxy.oauth_scopes", + "ty": "string[]", + "default": "[mcp:read, mcp:write]", + "secret": false, + "env_override": null, + "description": "Scopes required by the exact OAuth resource lease" + }, + { + "section": "proxy", + "key": "inherit_env", + "toml_path": "proxy.inherit_env", + "ty": "environment variable name[]", + "default": "[]", + "secret": false, + "env_override": null, + "description": "Additional ambient variables inherited by the scrubbed child" + }, + { + "section": "proxy", + "key": "shutdown_grace_ms", + "toml_path": "proxy.shutdown_grace_ms", + "ty": "u64 (1..=60000)", + "default": "3000", + "secret": false, + "env_override": null, + "description": "Grace period preference for supervised shutdown" + } +] diff --git a/docs/generated/proxy-config-reference.md b/docs/generated/proxy-config-reference.md new file mode 100644 index 000000000..0bd97162c --- /dev/null +++ b/docs/generated/proxy-config-reference.md @@ -0,0 +1,18 @@ +# proxy-config-reference + +Generated by `labby docs generate`. Do not edit by hand. + +Values are non-secret unless marked otherwise. Literal bearer tokens belong in the environment file, never TOML. + +| TOML path | Type | Default | Secret | Environment override | Description | +| --- | --- | --- | --- | --- | --- | +| `proxy.exposure` | `tailscale\|local` | `tailscale` | false | - | Publication controller | +| `proxy.auth` | `tailnet\|bearer\|oauth\|none` | `tailnet` | false | - | Authentication policy | +| `proxy.path` | `absolute non-root path` | `/mcp` | false | - | Public MCP endpoint path | +| `proxy.port` | `random\|u16` | `random` | false | - | External Tailscale HTTPS port selection | +| `proxy.port_range_start` | `u16` | `49152` | false | - | First random external-port candidate | +| `proxy.port_range_end` | `u16` | `65535` | false | - | Last random external-port candidate | +| `proxy.bearer_token_env` | `environment variable name` | `LABBY_PROXY_BEARER_TOKEN` | false | LABBY_PROXY_BEARER_TOKEN | Environment key containing the static bearer secret | +| `proxy.oauth_scopes` | `string[]` | `[mcp:read, mcp:write]` | false | - | Scopes required by the exact OAuth resource lease | +| `proxy.inherit_env` | `environment variable name[]` | `[]` | false | - | Additional ambient variables inherited by the scrubbed child | +| `proxy.shutdown_grace_ms` | `u64 (1..=60000)` | `3000` | false | - | Grace period preference for supervised shutdown | diff --git a/docs/generated/service-catalog.json b/docs/generated/service-catalog.json index 2f6f9c70a..e333adef7 100644 --- a/docs/generated/service-catalog.json +++ b/docs/generated/service-catalog.json @@ -83,6 +83,27 @@ "supports_multi_instance": false, "metadata_source": "registry synthetic metadata" }, + { + "name": "proxy", + "display_name": "Stdio MCP Proxy", + "description": "Expose one explicitly selected stdio MCP server as faithful Streamable HTTP", + "category": "bootstrap", + "status": "available", + "feature": "gateway", + "exposure": "feature_gated", + "surfaces": { + "cli": true, + "mcp": false, + "api": false, + "web_ui": false + }, + "default_port": null, + "docs_url": "docs/guides/STDIO_MCP_PROXY.md", + "coverage_doc": null, + "upstream_doc": null, + "supports_multi_instance": false, + "metadata_source": "CLI runtime + ProxyPreferences" + }, { "name": "server_logs", "display_name": "server_logs", diff --git a/docs/generated/service-catalog.md b/docs/generated/service-catalog.md index b49ed78c1..c72e65a09 100644 --- a/docs/generated/service-catalog.md +++ b/docs/generated/service-catalog.md @@ -8,6 +8,7 @@ Generated by `labby docs generate`. Do not edit by hand. | `fs` | available | FeatureGated | fs | bootstrap | mcp, api, web | registry synthetic metadata | | `gateway` | available | FeatureGated | gateway | bootstrap | cli, mcp, api, web | registry synthetic metadata | | `lab_admin` | available | RuntimeConditional | - | bootstrap | cli, mcp | registry synthetic metadata | +| `proxy` | available | FeatureGated | gateway | bootstrap | cli | CLI runtime + ProxyPreferences | | `server_logs` | available | AlwaysOn | - | bootstrap | cli, mcp, api | registry synthetic metadata | | `setup` | available | AlwaysOn | - | bootstrap | cli, mcp, api, web | registry + PluginMeta | | `snippets` | available | AlwaysOn | - | bootstrap | cli, mcp, api | registry synthetic metadata | diff --git a/docs/guides/STDIO_MCP_PROXY.md b/docs/guides/STDIO_MCP_PROXY.md new file mode 100644 index 000000000..adfb41e71 --- /dev/null +++ b/docs/guides/STDIO_MCP_PROXY.md @@ -0,0 +1,375 @@ +--- +title: "Stdio MCP Proxy Guide" +created: "2026-08-01" +updated: "2026-08-01" +--- + +# Stdio MCP Proxy + +`labby proxy` runs one stdio MCP server in the foreground and exposes that +server, unchanged, as a Streamable HTTP endpoint. It is a direct bridge, not the +aggregate Labby gateway: it does not add Labby tools, Code Mode, prefixes, +filters, or catalog normalization. + +## Zero-flag quickstart + +Run the one-time setup, check the host, then launch a JavaScript server: + +```console +labby setup proxy +labby doctor proxy +labby proxy /path/to/dist.js +``` + +The built-in defaults are Tailscale Serve exposure, tailnet authentication, +`/mcp`, and a random port from 49152 through 65535. After setup, the normal run +needs no proxy flags. Startup prints the resolved child command, public URL, +exposure, and auth policy, then waits for Ctrl+C. + +```text +MCP proxy ready + + Server node /path/to/dist.js + URL https://node.example.ts.net:53147/mcp + Exposure Tailscale Serve + Auth Tailnet + +Press Ctrl+C to stop. +``` + +Use `--json` for one machine-readable readiness object. It contains `url`, +`exposure`, `auth`, `external_port`, `local_addr`, `command`, `child_pid`, and +`protocol_version`. It never contains a bearer token, OAuth token, lease ID, +authorization header, or child environment value. + +## Command and launcher resolution + +Labby parses its options only before the first child token. Everything after +that token belongs to the child, including values beginning with `-`: + +```console +labby proxy /path/to/dist.js --workspace /srv/data --read-only +labby proxy --port 52177 /path/to/dist.js --child-flag +``` + +An explicit separator is accepted but is not normally required: + +```console +labby proxy -- npx -y @modelcontextprotocol/server-filesystem /srv/data +``` + +The first child token resolves in this order: + +1. An existing executable file runs directly. +2. An existing file with a valid shebang runs directly when executable; on + Unix, a non-executable file may use a standards-compliant interpreter plus + one optional shebang argument. +3. `.js`, `.mjs`, and `.cjs` files use `node` from `PATH`. +4. `.py` files use `python3` from `PATH`. +5. A bare command is resolved through `PATH`. +6. An unknown, non-executable file fails with an explicit-command suggestion. + +Labby never invokes a shell. TypeScript has no inferred launcher; use a +shebang or an explicit command such as `labby proxy -- npx tsx server.ts`. +Arguments are retained as `OsString`, including non-UTF-8 Unix arguments. +`--cwd` changes the child working directory; otherwise the caller's current +directory is used. + +## Exposure, authentication, ports, and output + +| Setting | Behavior | +| --- | --- | +| `tailscale` exposure | Binds HTTP to loopback, publishes one HTTPS port with Tailscale Serve, and prints the tailnet URL. This is the default. | +| `local` exposure | Binds and prints a loopback HTTP URL only. Use `--local`; it never binds a LAN wildcard. | +| `tailnet` auth | Adds no application token. Reachability and grants are owned by Tailscale. Valid only with Tailscale exposure. This is the default. | +| `bearer` auth | Requires the separate proxy bearer token on every MCP request and SSE stream. | +| `oauth` auth | Validates Labby-issued JWTs for the exact proxy resource URL and configured scopes. Requires Tailscale exposure and a live OAuth daemon. | +| `none` auth | Adds no application authentication. Intended for explicit loopback use such as `--local --auth none`. | +| random port | Chooses an unused external Serve port from the configured range, with collision retries. | +| fixed port | Uses the numeric `proxy.port` or one-run `--port`; startup fails rather than replacing an existing mapping. | + +One-run examples: + +```console +labby proxy --port 52177 /path/to/dist.js +labby proxy --auth oauth /path/to/dist.js +printf '%s\n' "$TOKEN" | labby proxy --auth bearer --bearer-token-stdin /path/to/dist.js +labby proxy --local --auth none /path/to/dist.js +``` + +`--bearer-token` also implies bearer mode, but shell history and process +inspection can expose literal command-line secrets. Prefer setup-generated +storage, the environment, or `--bearer-token-stdin`. + +There is no silent fallback. A Tailscale, bearer, OAuth, port, child, or +publication failure stops startup; a runtime failure begins owned cleanup. + +## Configuration and precedence + +The effective order is: + +1. one-run CLI options; +2. existing process environment, then values loaded from + `$LABBY_HOME/.env` (normally `~/.labby/.env`), then a current-directory + `.env` for unset names; +3. the first `config.toml` found at `./config.toml`, + `$LABBY_HOME/config.toml`, or `~/.config/labby/config.toml`; +4. built-in defaults. + +Only secret and executable/logging controls use environment variables. Proxy +preference keys do not have implicit one-to-one environment aliases. A +current-directory `config.toml` can therefore mask values written by +`labby setup proxy` to `$LABBY_HOME/config.toml`. + +Complete `[proxy]` table: + +```toml +[proxy] +exposure = "tailscale" +auth = "tailnet" +path = "/mcp" +port = "random" +port_range_start = 49152 +port_range_end = 65535 +bearer_token_env = "LABBY_PROXY_BEARER_TOKEN" +oauth_scopes = ["mcp:read", "mcp:write"] +inherit_env = [] +shutdown_grace_ms = 3000 +``` + +| Key | Accepted values and validation | +| --- | --- | +| `proxy.exposure` | `tailscale` or `local`; default `tailscale`. | +| `proxy.auth` | `tailnet`, `bearer`, `oauth`, or `none`; default `tailnet`. `local` plus `tailnet` is rejected. | +| `proxy.path` | Absolute, non-root path without query, fragment, `.` segments, or `..` segments; default `/mcp`. | +| `proxy.port` | `"random"` or a nonzero integer. It is the external HTTPS port for Tailscale publication. | +| `proxy.port_range_start` | First random candidate; default `49152`, minimum `1024`. | +| `proxy.port_range_end` | Last random candidate; default `65535`, and not below the start. | +| `proxy.bearer_token_env` | Valid environment-variable name containing the bearer secret; default `LABBY_PROXY_BEARER_TOKEN`. | +| `proxy.oauth_scopes` | Non-empty, whitespace-free scope tokens; default `mcp:read` and `mcp:write`. | +| `proxy.inherit_env` | Extra valid ambient variable names copied into the otherwise scrubbed child environment. | +| `proxy.shutdown_grace_ms` | Validated range `1..=60000`; default `3000`. | + +Generated machine-readable and Markdown inventories are in +[`proxy-config-reference`](../generated/proxy-config-reference.md) and the +[`environment reference`](../generated/env-reference.md). + +Proxy-relevant environment variables: + +| Variable | Purpose | +| --- | --- | +| `LABBY_PROXY_BEARER_TOKEN` | Default secret source. If `bearer_token_env` names another key, that configured key is used instead. | +| `LABBY_TAILSCALE_BIN` | Overrides the `tailscale` executable used by publication and proxy preflight. | +| `LABBY_GW_UPSTREAM_STDERR` | Controls the redacted child-stderr forwarding level; default `debug`, and `off` discards it. | +| `LABBY_HOME` | Relocates Labby's config and secret root. | +| `LABBY_MCP_HTTP_HOST`, `LABBY_MCP_HTTP_PORT` | First live-daemon candidate for OAuth lease actions. | +| `LABBY_MCP_HTTP_TOKEN` | Authenticates the proxy CLI to the daemon's admin gateway action route; it is not accepted by the proxied OAuth resource. | +| `LABBY_PUBLIC_URL`, `LABBY_MCP_GATEWAY_URL` | Public live-daemon fallback candidates and stable OAuth issuer configuration, as described in the OAuth guide. | +| `LABBY_PROXY_TEST_RENEW_MS` | Test-only renewal interval available with `proxy-testkit`; never use it as production configuration. | + +`PATH` is consulted for launcher inference. Runtime-essential variables are +copied by the stdio spawn policy; other ambient variables require +`proxy.inherit_env`, `--inherit-env NAME`, or an explicit `--env NAME=VALUE`. +The latter two are child-process inputs, not persisted proxy preferences. + +## Setup, secrets, and doctor + +`labby setup proxy` is interactive on a terminal. For automation use `--yes` +and explicit flags; `--dry-run` previews without mutation. The setup action +preserves unrelated TOML keys, comments, and `.env` entries and is byte-stable +on a second identical run. + +Bearer setup has two safe paths: + +```console +# Generate a new 64-character random hex token when none exists. +labby setup proxy --yes --auth bearer + +# Store a supplied token from stdin; the literal is never written to TOML. +printf '%s\n' "$TOKEN" | labby setup proxy --yes --bearer-token-stdin +``` + +The non-secret preferences go to `$LABBY_HOME/config.toml`; the bearer secret +goes to the environment key named by `proxy.bearer_token_env` in +`$LABBY_HOME/.env`. Existing secrets are reused unless stdin supplies a +replacement. On Unix, setup creates or repairs `$LABBY_HOME` to mode `0700` +and `.env` to mode `0600`. Output, debug formatting, and JSON report only that +a secret changed, never its value. + +`labby doctor proxy` with no route arguments performs the zero-route local +preflight: proxy config validation, Node/Python launcher presence, selected +auth prerequisites, Tailscale version/connectivity/DNS/Serve capability, and +OAuth issuer/daemon/create-renew-release action checks where applicable. + +The older routed reverse-proxy doctor remains available and unchanged: + +```console +labby doctor proxy \ + --app-url https://lab.example.com \ + --mcp-url https://mcp.example.com \ + --route /telemetry +``` + +Supplying route arguments selects that public Lab/protected-route check; it +does not run the local stdio-proxy preflight. + +## OAuth resource lifecycle + +The OAuth authorization server has a stable issuer such as +`https://lab.example.com`. The ephemeral proxy is a separate protected +resource. Its identity is the exact public URL, including port and path: + +```text +issuer: https://lab.example.com +resource: https://node.example.ts.net:53147/mcp +``` + +Changing either `53147` or `/mcp` changes the JWT audience. Each random-port +run therefore creates a distinct resource URL. Use a fixed `proxy.port` for a +long-lived connector configuration; otherwise update the connector to the URL +printed by every run. + +OAuth startup requires `LABBY_AUTH_MODE=oauth`, a stable +`LABBY_PUBLIC_URL` (or equivalent `[auth]`/`[public_urls]` configuration), the +same-host signing keys, and a reachable `labby serve` daemon. The CLI verifies +the daemon's authorization-server metadata and JWKS, checks the three lease +actions, then dispatches through authenticated `POST /v1/gateway`: + +- `gateway.oauth.resource_lease.create` +- `gateway.oauth.resource_lease.renew` +- `gateway.oauth.resource_lease.release` + +These are gateway actions, not dedicated `/v1/internal/*` routes. They require +`lab:admin`; lease IDs are opaque secrets and are redacted from diagnostics. + +The default lease TTL is 120 seconds. The proxy renews every 40 seconds plus up +to four seconds of jitter. Renewal failure terminates the proxy. Normal +shutdown releases the lease. Forced termination cannot perform an async +release, so the daemon ignores the expired lease and prunes expired entries on +its 30-second sweep. Configured protected resources and ephemeral leases are +separate registries, so route refresh does not erase active proxy resources. + +The proxy serves RFC 9728 metadata at the public origin root: + +```text +GET https://node.example.ts.net:53147/.well-known/oauth-protected-resource +``` + +That document advertises the exact resource with port and `/mcp`. The +`WWW-Authenticate` challenge points to the same root metadata URL. This direct +proxy does not use the path-suffixed metadata URLs used by configured Gateway +protected routes. Local HTTP OAuth is intentionally rejected because daemon +leases accept HTTPS resources only; there is no bearer or no-auth downgrade. + +## Tailscale Serve ownership and cleanup + +Before publication Labby checks `tailscale version`, connected status, the +node DNS name, and `tailscale serve status --json`. It treats ports present in +either Serve TCP or web maps as occupied. A fixed-port collision fails. Random +mode retries collision-shaped claim failures, up to 32 candidates. + +The owned command is exact: + +```console +tailscale serve --yes --https= http://127.0.0.1: +``` + +Readiness requires the exact DNS-name, port, root handler, and loopback backend +to appear in Serve status. While running, Labby watches both the foreground +Serve child and that exact mapping. A disappeared or changed mapping is a +runtime failure. + +On Ctrl+C or a supervised component failure, cleanup stops HTTP, terminates the +Serve child, waits for the mapping to disappear, reaps the stdio child, and +releases the OAuth lease. If the owned mapping remains, Labby re-reads status +and runs only: + +```console +tailscale serve --yes --https= off +``` + +It does that only while the mapping still points to its recorded loopback +backend. If ownership changed, cleanup refuses to remove it. Labby never calls +`tailscale serve reset` and never rewrites unrelated mappings. + +After an uncatchable process crash or power loss, inspect status before manual +recovery. Remove only the printed port and only after confirming its backend is +the dead proxy's `127.0.0.1:` target. Never use `serve reset` as a +proxy cleanup shortcut. The OAuth lease recovers independently through TTL +expiry; restart the proxy only after resolving any surviving exact-port +mapping. + +## Security rationale + +- The HTTP listener binds only to `127.0.0.1`; remote exposure belongs to + Tailscale Serve. There is no LAN wildcard or public Funnel fallback. +- RMCP Host validation admits loopback authorities and, when applicable, only + the exact public resource host plus port. Origin validation similarly admits + loopback origins and the exact public origin. Unexpected Host or Origin + values are rejected to resist DNS rebinding and browser cross-origin abuse. +- The child is launched as argv without a shell. Its environment starts with + `env_clear`, then a small cross-platform runtime allowlist is restored, + followed by explicitly inherited and explicit values. Ambient Labby/OAuth + and upstream secrets are not inherited by default. +- Child stderr is continuously drained so a full pipe cannot deadlock the MCP + server. Diagnostic tails pass through central stdio redaction before logs. +- Bearer tokens use constant-time comparison and are separate from + `LABBY_MCP_HTTP_TOKEN`. OAuth disables static-admin-token fallback. +- Unix process groups and Windows Job Objects own descendants so normal and + supervised shutdown reap package-runner trees, not only the immediate PID. + +## Troubleshooting + +`proxy command resolution failed` +: Install `node`/`python3`, add the executable to `PATH`, use a valid shebang, + or provide the launcher explicitly after `--`. `.ts` is never guessed. + +`tailnet auth requires Tailscale exposure` +: `--local` does not silently weaken `tailnet`. Select `--auth bearer` or + `--auth none` explicitly for loopback use. + +`Tailscale Serve port ... is already configured` +: Choose another fixed port or return to `port = "random"`. Inspect + `tailscale serve status --json`; do not reset unrelated routes. + +`Tailscale ... offline`, missing DNS name, or Serve capability failure +: Run `labby doctor proxy`, then repair Tailscale connectivity and HTTPS Serve + support. Labby does not fall back to local exposure. + +`bearer auth requires ...` +: Run `labby setup proxy --yes --auth bearer`, export the configured key, or + pipe the secret to `--bearer-token-stdin`. Confirm a current-directory config + is not changing `bearer_token_env`. + +`proxy OAuth requires a stable Labby public issuer` +: Configure OAuth and `LABBY_PUBLIC_URL`, start `labby serve`, and verify the + authorization-server metadata issuer exactly matches. A random proxy URL is + the resource, never the issuer. + +`live Labby daemon does not support proxy OAuth leases` +: The CLI reached an older daemon. Upgrade/restart the daemon and confirm all + three lease actions appear in `GET /v1/gateway/actions`. + +OAuth 401 for a token that works elsewhere +: Obtain a token whose audience is the exact printed URL, including external + port and `/mcp`, and whose scopes cover `proxy.oauth_scopes`. A token for the + same host on another port is intentionally rejected. + +Host or Origin rejected +: Connect to the exact printed URL. Reverse proxies and browser clients must + preserve its authority and origin; do not replace the port or MCP path. + +Proxy exits after startup +: Inspect redacted logs for child closure, HTTP exit, Serve ownership drift, or + OAuth renewal failure. Cleanup errors are attached to the primary failure + rather than replacing it. + +## Related documents + +- [Spec](../specs/stdio-mcp-proxy.md) +- [Stable contract](../contracts/stdio-mcp-proxy.md) +- [Implementation plan](../superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md) +- [Research report](../reports/2026-07-31-stdio-mcp-proxy-research.md) +- [OAuth runtime](../runtime/OAUTH.md) +- [Transport security](../surfaces/TRANSPORT.md) +- [Generated CLI help](../generated/cli-help.md) diff --git a/docs/reports/2026-07-31-stdio-mcp-proxy-research.md b/docs/reports/2026-07-31-stdio-mcp-proxy-research.md index 44a9a959b..c36185c1a 100644 --- a/docs/reports/2026-07-31-stdio-mcp-proxy-research.md +++ b/docs/reports/2026-07-31-stdio-mcp-proxy-research.md @@ -1,9 +1,14 @@ # Stdio MCP Proxy Research Report - **Date:** 2026-07-31 +- **Implementation documentation:** 2026-08-01 - **Labby base:** `eff39c79d97c907f6c9956f4711ecab5cd8df62f` - **Target command:** `labby proxy ` - **Primary UX:** `labby proxy /path/to/dist.js` +- **Related:** [operator guide](../guides/STDIO_MCP_PROXY.md), + [spec](../specs/stdio-mcp-proxy.md), + [contract](../contracts/stdio-mcp-proxy.md), and + [implementation plan](../superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md) ## Executive finding @@ -310,19 +315,26 @@ The current conformance script is pinned to RMCP 3.0.0-beta.2 and a matching com - expected failure baselines where justified; - upstream drift baseline. -## Remaining implementation spikes +## Implementation outcomes -These are bounded engineering spikes, not product questions: +The implementation resolved the research spikes with a dedicated direct-stdio +connector, the shared transparent bridge, a stateless loopback HTTP service, +explicit subscription/cancellation coverage, owned Tailscale Serve status +checks, and daemon-backed OAuth leases. Parser tests retain `OsString` child +arguments, and the stdio connector reuses Unix process-group and Windows Job +Object ownership. -1. Prove the exact RMCP service/factory shape needed to keep one supervised stdio child behind a stateless HTTP handler factory. -2. Prove progress-token replacement and restoration with two concurrent requests using the same downstream token. -3. Prove modern `subscriptions/listen` bridging with two concurrent subscriptions. -4. Prove legacy resource subscription adaptation and ref-counted unsubscribe. -5. Prove Tailscale foreground termination and exact fallback cleanup on all supported Tailscale versions. -6. Prove dynamic OAuth resource lease renewal across a daemon restart. -7. Prove non-UTF-8 command arguments and process-tree cleanup on Linux and Windows. +The shipped operator boundary differs from two early research assumptions: -Each spike has a mandatory test and evidence artifact in the implementation plan. A failed spike changes the implementation mechanism, not the user-facing command contract. +- lease create, renew, and release use authenticated gateway actions over + `POST /v1/gateway`, not new route-local REST endpoints; +- direct-proxy OAuth metadata is always the origin-root + `/.well-known/oauth-protected-resource` document, while configured Gateway + protected routes keep their existing path-suffixed metadata contract. + +The dated proof-pack harness and controlled live release evidence remain owned +by Task 14 of the implementation plan; they do not change the Task 13 operator +or generated-document contract. ## Authoritative sources diff --git a/docs/runtime/CONFIG.md b/docs/runtime/CONFIG.md index 747c7e9fe..e128b7911 100644 --- a/docs/runtime/CONFIG.md +++ b/docs/runtime/CONFIG.md @@ -1,7 +1,7 @@ --- title: "Runtime Configuration" created: "2026-07-30" -updated: "2026-07-30" +updated: "2026-08-01" --- # Runtime Configuration @@ -23,11 +23,18 @@ Runtime precedence is: 3. `config.toml` 4. Built-in defaults +Existing process values win over dotenv files. Labby then loads +`$LABBY_HOME/.env` (normally `~/.labby/.env`) and finally a current-directory +`.env` for names that remain unset. Proxy CLI overrides are applied after the +TOML model loads. + Keep secrets, tokens, passwords, OAuth client secrets, and upstream credential values in `~/.labby/.env`. Keep product preferences in TOML. The annotated example in [../../config/config.example.toml](../../config/config.example.toml) is the canonical hand-written configuration sample. Generated environment metadata lives in [../generated/env-reference.md](../generated/env-reference.md). +The code-owned proxy key inventory lives in +[../generated/proxy-config-reference.md](../generated/proxy-config-reference.md). ## Supported Sections @@ -36,6 +43,9 @@ metadata lives in [../generated/env-reference.md](../generated/env-reference.md) - `[mcp]`: default transport (`stdio`, `http`, or `unix_socket`), HTTP/TCP bind host/port, Unix-socket path/mode/ownership and optional Linux peer-credential allowlists, and allowed hosts. +- `[proxy]`: foreground direct stdio-proxy exposure, auth, endpoint path, + external port selection, bearer secret key name, OAuth scopes, explicit + child-environment inheritance, and shutdown preference. - `[api]`: CORS preferences. - `[web]`: exported asset location and development-only auth bypass. - `[workspace]`: root for the optional filesystem browser. Default: @@ -69,6 +79,20 @@ credentials stay in `bearer_token_env` or `[upstream.oauth]`. Use `labby gateway add`, `update`, `remove`, `reload`, and related commands rather than editing active gateway state concurrently by hand. +## Direct Stdio Proxy + +`labby setup proxy` writes all ten non-secret `[proxy]` keys to +`$LABBY_HOME/config.toml`. Bearer material is stored separately in +`$LABBY_HOME/.env` under the configured `proxy.bearer_token_env` key. The +default key is `LABBY_PROXY_BEARER_TOKEN`; it is separate from the daemon +administrator token. + +There are no implicit `LABBY_PROXY_EXPOSURE`, `LABBY_PROXY_AUTH`, path, port, +range, scopes, inheritance, or shutdown environment aliases. Those preferences +come from one-run CLI options where offered, then TOML, then defaults. Proxy +environment controls and the complete table are documented in the +[stdio MCP proxy guide](../guides/STDIO_MCP_PROXY.md). + ## Authentication `LABBY_AUTH_MODE` selects bearer or OAuth behavior. OAuth deployments also diff --git a/docs/runtime/ENV.md b/docs/runtime/ENV.md index 33c64ab04..3fd946370 100644 --- a/docs/runtime/ENV.md +++ b/docs/runtime/ENV.md @@ -1,7 +1,7 @@ --- title: "Environment Variables" created: "2026-07-30" -updated: "2026-07-30" +updated: "2026-08-01" --- # Environment Variables @@ -9,8 +9,36 @@ updated: "2026-07-30" This document lists the `lab` environment variables that matter for transport and auth setup. The complete per-service env inventory is generated from `PluginMeta` and lives in -[generated/env-reference.md](./generated/env-reference.md) and -[generated/env-reference.json](./generated/env-reference.json). +[generated/env-reference.md](../generated/env-reference.md) and +[generated/env-reference.json](../generated/env-reference.json). + +## Direct Stdio Proxy + +The default bearer secret is separate from the hosted daemon administrator +token: + +```env +LABBY_PROXY_BEARER_TOKEN=replace-with-a-generated-secret +``` + +`proxy.bearer_token_env` may name another key. `labby setup proxy --auth +bearer` generates and writes the value when it is absent; piping a value to +`--bearer-token-stdin` replaces it without writing the literal to TOML. + +Other proxy-adjacent process controls are: + +```env +LABBY_TAILSCALE_BIN=tailscale +LABBY_GW_UPSTREAM_STDERR=debug +``` + +`LABBY_PROXY_TEST_RENEW_MS` exists only under the `proxy-testkit` feature and +is not production configuration. OAuth proxy runs also use the live-daemon +discovery/auth variables described below. There are no environment aliases for +the non-secret `[proxy]` exposure, auth, path, port/range, scope, inheritance, +or shutdown preferences. See the +[stdio MCP proxy guide](../guides/STDIO_MCP_PROXY.md#configuration-and-precedence) +and generated [proxy environment inventory](../generated/env-reference.md). ## HTTP Auth diff --git a/docs/runtime/OAUTH.md b/docs/runtime/OAUTH.md index 43f8a0e37..af907db12 100644 --- a/docs/runtime/OAUTH.md +++ b/docs/runtime/OAUTH.md @@ -1,7 +1,7 @@ --- title: "HTTP Auth Modes" created: "2026-07-30" -updated: "2026-07-30" +updated: "2026-08-01" --- # HTTP Auth Modes @@ -519,6 +519,42 @@ names. Full route configuration and curl verification examples live in [GATEWAY.md](../services/GATEWAY.md#gateway-managed-protected-mcp-routes). +## Direct Stdio Proxy OAuth + +`labby proxy --auth oauth` uses the same stable Labby authorization-server +issuer but creates an ephemeral protected resource for the exact printed proxy +URL. The external port and MCP path are both part of the JWT audience: + +```text +https://node.example.ts.net:53147/mcp +``` + +A random-port run is therefore a new resource on every invocation. Prefer a +fixed proxy port for a long-lived connector configuration. + +Before publication, the CLI verifies the live daemon's issuer metadata, JWKS, +and gateway action inventory, then uses authenticated `POST /v1/gateway` action +dispatch to create, renew, and release the resource lease. There are no +route-local or dedicated `/v1/internal/*` lease endpoints. Normal shutdown +releases the lease; a forced termination recovers through the 120-second TTL +and the daemon's periodic expiry sweep. + +Unlike configured Gateway protected routes, the direct proxy serves metadata +at the origin root, with no route suffix: + +```http +GET https://node.example.ts.net:53147/.well-known/oauth-protected-resource +``` + +The document's `resource` remains the exact `/mcp` URL above, and the +`WWW-Authenticate` challenge points to that root metadata document. A token for +the same host with another port or path is rejected. Local HTTP OAuth is not +supported because resource leases accept HTTPS audiences; startup fails rather +than downgrading to bearer or none. + +See the [stdio MCP proxy guide](../guides/STDIO_MCP_PROXY.md#oauth-resource-lifecycle) +for daemon discovery, lease timing, fixed-port guidance, and troubleshooting. + ## Troubleshooting ChatGPT MCP Connectors Use this checklist when a ChatGPT custom MCP connector fails during dynamic diff --git a/docs/specs/stdio-mcp-proxy.md b/docs/specs/stdio-mcp-proxy.md index 253f11425..aefa5ebf9 100644 --- a/docs/specs/stdio-mcp-proxy.md +++ b/docs/specs/stdio-mcp-proxy.md @@ -1,15 +1,15 @@ --- title: "Spec: Stdio MCP Proxy" created: "2026-07-31" -updated: "2026-07-31" +updated: "2026-08-01" --- # Spec: Stdio MCP Proxy -Status: implementation +Status: implemented Owner: Labby runtime Surfaces: CLI, Streamable HTTP, OAuth, Tailscale Serve -Related: [contract](../contracts/stdio-mcp-proxy.md), [research](../reports/2026-07-31-stdio-mcp-proxy-research.md), [implementation plan](../superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md) +Related: [operator guide](../guides/STDIO_MCP_PROXY.md), [contract](../contracts/stdio-mcp-proxy.md), [research](../reports/2026-07-31-stdio-mcp-proxy-research.md), [implementation plan](../superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md) ## Problem @@ -70,6 +70,11 @@ labby proxy --local --auth none /path/to/dist.js Labby options must appear before the first child token. All remaining tokens belong to the child. An explicit `--` remains accepted but is not required. +`labby setup proxy` persists the table below and securely creates or stores a +bearer secret. `labby doctor proxy` with no routed-check arguments validates +the saved preferences and local dependencies; routed reverse-proxy doctor +arguments retain their existing behavior. + ## Configuration model ```toml @@ -304,6 +309,11 @@ No application bearer challenge is added. Reachability is controlled by Tailscal `/.well-known/oauth-protected-resource`; the challenge points to that exact root document rather than a path beneath `/mcp`. - Failure to create or renew a lease terminates OAuth startup or the running proxy; there is no downgrade. +- Lease create, renew, and release are admin-authenticated gateway actions over + `POST /v1/gateway`, not dedicated lease routes. +- The 120-second lease renews every 40 seconds plus bounded jitter. Normal + shutdown releases it; forced termination recovers through TTL expiry and the + daemon's 30-second prune loop. ## Tailscale exposure @@ -329,7 +339,10 @@ Startup order: 8. Verify unauthenticated and authenticated readiness. 9. Print the endpoint. -Shutdown is idempotent and runs on Ctrl+C, SIGTERM, child exit, HTTP failure, Tailscale exit, or OAuth renewal failure. +Shutdown is idempotent and runs on Ctrl+C, child exit, HTTP failure, Tailscale +exit, or OAuth renewal failure. An uncatchable crash cannot run async cleanup; +OAuth recovers by TTL and any surviving Serve mapping requires exact-port, +ownership-verified recovery rather than `serve reset`. ## Observability diff --git a/docs/superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md b/docs/superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md index 65e48d403..90cfd8c33 100644 --- a/docs/superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md +++ b/docs/superpowers/plans/2026-07-31-stdio-mcp-proxy-implementation.md @@ -6,6 +6,11 @@ **Research report:** `docs/reports/2026-07-31-stdio-mcp-proxy-research.md` +**Implemented operator guide:** `docs/guides/STDIO_MCP_PROXY.md` + +**Stable spec and contract:** `docs/specs/stdio-mcp-proxy.md` and +`docs/contracts/stdio-mcp-proxy.md` + **Base commit used for this plan:** `eff39c79d97c907f6c9956f4711ecab5cd8df62f` ## Release contract @@ -117,8 +122,9 @@ Add a top-level `[proxy]` table: exposure = "tailscale" auth = "tailnet" path = "/mcp" -external_port = "random" -port_range = { start = 49152, end = 65535 } +port = "random" +port_range_start = 49152 +port_range_end = 65535 bearer_token_env = "LABBY_PROXY_BEARER_TOKEN" oauth_scopes = ["mcp:read", "mcp:write"] inherit_env = [] @@ -129,7 +135,7 @@ A fixed external port is represented by an integer: ```toml [proxy] -external_port = 52177 +port = 52177 ``` Recommended model: @@ -140,8 +146,9 @@ pub struct ProxyPreferences { pub exposure: ProxyExposure, pub auth: ProxyAuthMode, pub path: String, - pub external_port: ProxyPortPreference, - pub port_range: ProxyPortRange, + pub port: ProxyPortPreference, + pub port_range_start: u16, + pub port_range_end: u16, pub bearer_token_env: String, pub oauth_scopes: Vec, pub inherit_env: Vec, @@ -821,6 +828,14 @@ Checks: ### Task 13: Documentation and generated CLI inventory +**Completed 2026-08-01.** The operator guide, README quickstart, runtime config, +OAuth, transport, architecture, spec, contract, research outcome, release +notes, and code-owned generated inventories now describe the implemented +surface. Generator drift tests pin top-level `labby proxy`, `setup proxy`, +zero-route and routed `doctor proxy`, all `[proxy]` keys and environment +controls, the CLI-only proxy service entry, generic gateway API/OpenAPI, and +all three OAuth lease actions. + Update: - CLI help inventory; diff --git a/docs/surfaces/CLI.md b/docs/surfaces/CLI.md index e68a6b18c..2687574e7 100644 --- a/docs/surfaces/CLI.md +++ b/docs/surfaces/CLI.md @@ -1,7 +1,7 @@ --- title: "CLI Surface" created: "2026-07-30" -updated: "2026-07-30" +updated: "2026-08-01" --- # CLI Surface @@ -24,6 +24,9 @@ authoritative when this summary and the binary disagree. - `labby docs` generates and verifies code-owned documentation artifacts. - `labby health` performs a quick local health check. - `labby oauth` runs local OAuth callback-relay helpers. +- `labby proxy` runs one explicitly selected stdio MCP child as a foreground + Streamable HTTP endpoint. Labby flags precede the first child token; later + tokens are child arguments and `--` is accepted but optional. - `labby incus` manages the supported Incus gateway container. - `labby update` installs a newer Labby release. - `labby completions` emits shell completion scripts. @@ -32,6 +35,13 @@ The runtime-conditional `lab_admin` service is exposed only when explicitly enabled. The optional `fs` capability is an MCP/API/web service rather than a standalone CLI command group. +`labby setup proxy` persists non-secret direct-proxy defaults and stores or +generates the separate bearer secret. `labby doctor proxy` with no route flags +runs the direct-proxy preflight; supplying the routed doctor URL/path flags +preserves the public Lab/protected-route diagnostic. See the +[stdio MCP proxy guide](../guides/STDIO_MCP_PROXY.md) for launcher inference, +auth/exposure modes, output, precedence, and cleanup. + ## Shared Action Shape Service-oriented commands ultimately dispatch the same action contract used by diff --git a/docs/surfaces/TRANSPORT.md b/docs/surfaces/TRANSPORT.md index e9689eac7..31bd1a619 100644 --- a/docs/surfaces/TRANSPORT.md +++ b/docs/surfaces/TRANSPORT.md @@ -1,7 +1,7 @@ --- title: "Transport Contract" created: "2026-07-30" -updated: "2026-07-30" +updated: "2026-08-01" --- # Transport Contract @@ -36,6 +36,21 @@ assets when available. The generated route inventory in [../generated/api-routes.md](../generated/api-routes.md) is authoritative. +### Direct stdio proxy + +`labby proxy ` is a separate foreground transport adapter. It exposes +only that child over a stateless Streamable HTTP router bound to `127.0.0.1`. +Tailscale Serve may publish the loopback router, but the aggregate Labby +catalog and `/v1/*` APIs are not mounted on the proxy endpoint. + +The proxy keeps RMCP Host and Origin enforcement enabled. Allowed authorities +and origins are loopback plus the exact public resource host and port when one +exists. JSON and request-scoped SSE responses remain enabled. Child launch uses +an argv vector, a scrubbed environment, continuously drained/redacted stderr, +and owned process trees. See the +[stdio MCP proxy guide](../guides/STDIO_MCP_PROXY.md) for the complete security +and cleanup model. + ## Streamable HTTP Over A Unix-Domain Socket `transport = "unix_socket"` serves the same router and middleware stack as