From d6427d5d6a47878b0d0ec84a289a5e0bd686c396 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:01:21 +0300 Subject: [PATCH 1/2] feat(api): accept agent keys as Authorization: Bearer Most MCP clients and Codex's `bearer_token_env_var` send `Authorization: Bearer `; the API only read `x-api-key`. The principal resolver now takes either (`agentKeyFrom`). `/api/admin/*` keeps its deployment-token bearer and never reads it as an agent key; an unknown bearer is refused rather than falling back to the browser session. Shared suite on better-sqlite3 and D1: REST and /mcp with Bearer, unknown bearer, session fallback, deployment token on /api/admin. Docs: every remote client example sends Bearer (Codex via bearer_token_env_var); x-api-key stays documented as equivalent. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/agent-key-bearer.md | 5 + docs/adr/0011-agent-layer.md | 2 +- docs/agents/README.md | 4 +- docs/agents/clients.md | 34 +++-- docs/security/threat-model.md | 4 +- packages/api/src/auth/instance.ts | 3 +- packages/api/src/auth/principal.ts | 4 +- packages/api/src/auth/types.ts | 16 +++ packages/api/src/testing-agent-key-auth.ts | 151 +++++++++++++++++++++ packages/api/test/agent-key-auth.test.ts | 34 +++++ 10 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 .changeset/agent-key-bearer.md create mode 100644 packages/api/src/testing-agent-key-auth.ts create mode 100644 packages/api/test/agent-key-auth.test.ts diff --git a/.changeset/agent-key-bearer.md b/.changeset/agent-key-bearer.md new file mode 100644 index 00000000..3b720bd7 --- /dev/null +++ b/.changeset/agent-key-bearer.md @@ -0,0 +1,5 @@ +--- +"@cloudhal/api": minor +--- + +Agent keys are also accepted as `Authorization: Bearer ` (what most MCP clients and Codex's `bearer_token_env_var` send), on `/mcp` and wherever `x-api-key` was. `/api/admin/*` keeps its deployment-token bearer and never reads it as an agent key. diff --git a/docs/adr/0011-agent-layer.md b/docs/adr/0011-agent-layer.md index 828b6ea4..fe07754e 100644 --- a/docs/adr/0011-agent-layer.md +++ b/docs/adr/0011-agent-layer.md @@ -12,7 +12,7 @@ Sprint 5 makes Cloudhal usable *by agents*: an MCP client must be able to create 2. **Tools are defined once, independently of the SDK** (`packages/mcp-server/src/tools.ts`): JSON Schema in/out built from the contract schemas, honest annotations, scopes, handler over `SiteGraphStore`. `runTool` is the only execution path: policy → audit → handler. Every mutation auto-snapshots (`reason: "agentMutation"`, label = tool name). The 18 tools are listed deterministically. 3. **The MCP server is a thin wrapper** (`createCloudhalMcpServer`): one instance per principal, `fromJsonSchema` for schemas (no zod in our code), `structuredContent` on every result, skills exposed as `skill:///SKILL.md` (+ `references/`) resources, and a declared skill via `_meta["cloudhal.skill"]` narrowing the call to that skill's scopes. 4. **Approval = multi-round-trip elicitation, with a signed token as fallback.** `approval-required` returns `input_required` carrying an elicitation (the prompt shows the diff summary) and an HMAC-signed `requestState` bound to the tool, the argument hash and the principal. The client retries with the answer; only `accept` + `confirm: true` runs the handler; `decline`/`cancel` is audited as a denial. Clients that cannot elicit (2025-era over stateless HTTP, hosts without elicitation) receive a `confirmToken` to pass on a second identical call — same HMAC state, same binding, 10-minute TTL; can be disabled with `confirmTokenFallback: false`. -5. **Transports:** `cloudhal mcp` (stdio, local developer, default scopes `*`, `--scopes` to narrow) and `/mcp` inside the Hono API (`createMcpHandler`, stateless per request, 2025-era served statelessly). Authentication stays outside the SDK: the API resolves a `Principal` (session cookie → human with all scopes; `x-api-key` → `agent` actor with the key's `cloudhal` permissions as scopes) and passes it via `authInfo.extra`. +5. **Transports:** `cloudhal mcp` (stdio, local developer, default scopes `*`, `--scopes` to narrow) and `/mcp` inside the Hono API (`createMcpHandler`, stateless per request, 2025-era served statelessly). Authentication stays outside the SDK: the API resolves a `Principal` (session cookie → human with all scopes; `x-api-key`, or `Authorization: Bearer ` outside `/api/admin/*` → `agent` actor with the key's `cloudhal` permissions as scopes) and passes it via `authInfo.extra`. 6. **Agent credentials are Better Auth API keys** (`@better-auth/api-key`, table in migration `0003_agent`). Keys are minted server-side through `POST /api/agent-keys {name, scopes}` by a signed-in human (permissions are a server-only property in the plugin), listed and revoked under the same route. 7. **Skills extension (SEP-2640) is not implemented yet.** The TypeScript SDK has no `skills/list`/`skills/get` support in 2.3.1 (PR #2818 open); resources cover discovery for now. Revisit when the SDK ships it. diff --git a/docs/agents/README.md b/docs/agents/README.md index 9940f1dc..25358c39 100644 --- a/docs/agents/README.md +++ b/docs/agents/README.md @@ -9,7 +9,7 @@ Cloudhal is built for agents to work on sites, with people in charge. Agents con | Server | `cloudhal mcp`, started by your client over stdio | `https:///mcp` on a deployment (streamable HTTP) | | Works on | The project folder it runs in (`.cloudhal/` store) | The deployment's sites | | Who the agent is | You, the local developer | An agent key created in Studio | -| Authentication | None (a local process) | `x-api-key: ` header | +| Authentication | None (a local process) | `Authorization: Bearer ` or `x-api-key: ` header | | Approvals | Asked in your MCP client | Asked in your client, or wait in Studio's **Approvals** | | Set up | [Connect an agent](/docs/agents/clients) | [Remote agents](#remote-agents-on-a-deployment) below | @@ -54,7 +54,7 @@ The server publishes skills as MCP resources: `skill:///SKILL.md` and its ## Remote agents on a deployment 1. In Studio, open **Agents** and create a key. Choose its mode and scopes; they can never exceed your own role. The secret is shown once. -2. Point your client at `https:///mcp` with the header `x-api-key: `. See [Connect an agent](/docs/agents/clients#remote-server) for each client's format. +2. Point your client at `https:///mcp` with the header `Authorization: Bearer ` (or `x-api-key: `). See [Connect an agent](/docs/agents/clients#remote-server) for each client's format. 3. The agent's calls run as that key: Studio's **Activity** shows them, **Approvals** collects what waits, and you can change the key's mode or revoke it at any time. Keep keys narrow. A key that reads reader comments should not also publish: comment text is written by strangers and reaches the agent. The [comments guide](/docs/guides/comments) and the [schedules guide](/docs/guides/schedules) (agent tasks with their own keys and budgets) go further. diff --git a/docs/agents/clients.md b/docs/agents/clients.md index 761daed3..8def7a7d 100644 --- a/docs/agents/clients.md +++ b/docs/agents/clients.md @@ -3,7 +3,7 @@ Every MCP client connects to Cloudhal in one of two ways ([which one?](/docs/agents#two-ways-to-connect)): - **Local server:** the client starts `cloudhal mcp` in your project folder. Nothing to authenticate; the agent works on the project's local store. -- **Remote server:** the client calls `https:///mcp` on a deployment, sending an agent key created in Studio under **Agents** in the `x-api-key` header. +- **Remote server:** the client calls `https:///mcp` on a deployment, sending an agent key created in Studio under **Agents** as `Authorization: Bearer ` or in the `x-api-key` header. Both work; use whichever your client supports. Studio shows each client's setup with the key filled in right after you create it. `cloudhal mcp` serves the project folder it starts in. Configure local servers **per project** (the project-scoped files below), or start the client from the project folder. @@ -40,7 +40,7 @@ Remote: ```sh claude mcp add --transport http cloudhal https://studio.example.com/mcp \ - --header "x-api-key: $CLOUDHAL_API_KEY" + --header "Authorization: Bearer $CLOUDHAL_API_KEY" ``` Or in `.mcp.json`, with the key read from the environment when Claude Code starts: @@ -51,7 +51,7 @@ Or in `.mcp.json`, with the key read from the environment when Claude Code start "cloudhal": { "type": "http", "url": "https://studio.example.com/mcp", - "headers": { "x-api-key": "${CLOUDHAL_API_KEY}" } + "headers": { "Authorization": "Bearer ${CLOUDHAL_API_KEY}" } } } } @@ -75,12 +75,18 @@ command = "npx" args = ["cloudhal", "mcp", "--mode", "edits"] ``` -Remote, with the key taken from the environment: +Remote, with the key taken from the environment and sent as `Authorization: Bearer`: + +```sh +codex mcp add cloudhal --url https://studio.example.com/mcp --bearer-token-env-var CLOUDHAL_API_KEY +``` + +Or in `config.toml`: ```toml [mcp_servers.cloudhal] url = "https://studio.example.com/mcp" -env_http_headers = { "x-api-key" = "CLOUDHAL_API_KEY" } +bearer_token_env_var = "CLOUDHAL_API_KEY" ``` ## Cursor @@ -105,7 +111,7 @@ Remote: "mcpServers": { "cloudhal": { "url": "https://studio.example.com/mcp", - "headers": { "x-api-key": "${env:CLOUDHAL_API_KEY}" } + "headers": { "Authorization": "Bearer ${env:CLOUDHAL_API_KEY}" } } } } @@ -138,7 +144,7 @@ Remote. VS Code asks for the key once and stores it securely: "cloudhal": { "type": "http", "url": "https://studio.example.com/mcp", - "headers": { "x-api-key": "${input:cloudhal-key}" } + "headers": { "Authorization": "Bearer ${input:cloudhal-key}" } } } } @@ -146,7 +152,7 @@ Remote. VS Code asks for the key once and stores it securely: ## Claude Desktop and claude.ai -Use the remote server: **Settings › Connectors › Add custom connector**, the URL `https://studio.example.com/mcp`, and a request header `x-api-key` with your key. Claude connects from Anthropic's cloud, so this needs a deployment; `localhost` is not reachable. For a local project, use Claude Code. +Use the remote server: **Settings › Connectors › Add custom connector**, the URL `https://studio.example.com/mcp`, and a request header `Authorization` with the value `Bearer `. Claude connects from Anthropic's cloud, so this needs a deployment; `localhost` is not reachable. For a local project, use Claude Code. ## Windsurf @@ -157,7 +163,7 @@ Remote, in `mcp_config.json` (global, so prefer the remote server): "mcpServers": { "cloudhal": { "serverUrl": "https://studio.example.com/mcp", - "headers": { "x-api-key": "${env:CLOUDHAL_API_KEY}" } + "headers": { "Authorization": "Bearer ${env:CLOUDHAL_API_KEY}" } } } } @@ -178,7 +184,7 @@ In the project's `.zed/settings.json`: } ``` -Remote: `{ "url": "https://studio.example.com/mcp", "headers": { "x-api-key": "…" } }` in place of `command` and `args`. +Remote: `{ "url": "https://studio.example.com/mcp", "headers": { "Authorization": "Bearer …" } }` in place of `command` and `args`. ## Gemini CLI @@ -195,20 +201,20 @@ Local, in the project's `.gemini/settings.json`: Remote (Gemini uses `httpUrl` for streamable HTTP): ```sh -gemini mcp add --transport http -H "x-api-key: $CLOUDHAL_API_KEY" cloudhal https://studio.example.com/mcp +gemini mcp add --transport http -H "Authorization: Bearer $CLOUDHAL_API_KEY" cloudhal https://studio.example.com/mcp ``` ## Any other client - **stdio:** command `npx`, arguments `cloudhal mcp --mode edits`, working directory: your project. -- **Streamable HTTP:** URL `https:///mcp`, header `x-api-key: `. +- **Streamable HTTP:** URL `https:///mcp`, header `Authorization: Bearer ` (or `x-api-key: `). -A client that cannot send headers can reach the remote server through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as a local stdio command: `npx -y mcp-remote https://studio.example.com/mcp --header "x-api-key:${CLOUDHAL_API_KEY}"`. +A client that cannot send headers can reach the remote server through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as a local stdio command: `npx -y mcp-remote https://studio.example.com/mcp --header "Authorization:Bearer ${CLOUDHAL_API_KEY}"`. ## Troubleshooting - **The client lists no tools.** Run the command yourself in the project folder: `npx cloudhal mcp` should start and keep running (it waits for a client on stdin). An error there (Node older than 24, not a Cloudhal project) is what the client sees. - **The agent edits a different site.** The local server serves the folder it started in. Use a project-scoped config, or start the client from the project. -- **`401 unauthorized` remotely.** The key is missing, revoked, or sent in another header. Cloudhal reads `x-api-key`. +- **`401 unauthorized` remotely.** The key is missing, revoked, or sent in another header. Cloudhal reads `Authorization: Bearer ` and `x-api-key`; on `/mcp` a bearer must be an agent key, not the deployment token. - **`403 not_a_member`.** The key's owner no longer belongs to the deployment; create a new key. - **A call says it is waiting for approval.** Accept it in your client, or in Studio under **Approvals** on a deployment. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index c2da910a..5dbf1dfd 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -12,9 +12,9 @@ Staff roles are named sets of scopes (RFC-0005, ADR-0018). A person's role is re | Editor | Session cookie | Content, pages, taxonomies, redirects, publish, rollback, all Activity; invites Contributors and Authors | | Author | Session cookie | Own articles in any status; publishes own-only change sets (anything wider becomes a request an Editor decides) | | Contributor | Session cookie | Own never-published drafts (draft/review); proposes tags; never publishes | -| Agent | API key (`x-api-key`) with explicit scopes | MCP tools only, within its scopes narrowed to its creator's current role; publish/rollback/delete wait for a person | +| Agent | API key (`x-api-key` or `Authorization: Bearer`) with explicit scopes | MCP tools only, within its scopes narrowed to its creator's current role; publish/rollback/delete wait for a person | | Local developer's agent | `cloudhal mcp` over stdio | All scopes on the local project; gated tools ask in the MCP client | -| Automation key | API key (`x-api-key`) minted for one automation by the person who created it (RFC-0008) | An agent key that works only inside its automation's run windows (outside them only `begin_run`, `get_mode` and `pause_automation` answer); narrowed to its owner's current role, capped by the automation's mode cap, never `bypass`; within budgets; read, label and hide only once its run reads visitor-supplied text | +| Automation key | API key (`x-api-key` or `Authorization: Bearer`) minted for one automation by the person who created it (RFC-0008) | An agent key that works only inside its automation's run windows (outside them only `begin_run`, `get_mode` and `pause_automation` answer); narrowed to its owner's current role, capped by the automation's mode cap, never `bypass`; within budgets; read, label and hide only once its run reads visitor-supplied text | | Scheduler | System actor (`sys_scheduler`) inside the admin Worker or the Node process; no credential | Publishes releases on behalf of the person who approved them, runs built-in jobs, opens and closes run windows (RFC-0008) | | Operator | Deployment token (bearer) | Export/import whole sites (`cloudhal push/pull`); recover ownership (`cloudhal owner transfer`); export/import reader comments, with commenters' emails (`cloudhal comments export|import --remote`, RFC-0007); read the scheduler's status and run a tick (`cloudhal schedules status|list|run-due --remote`, RFC-0008) | | Site visitor | — | Published pages; drafts only with a signed preview link; sends analytics beacons, which count only for published pages (RFC-0006); sends comments and reports, which are held for moderation (RFC-0007) | diff --git a/packages/api/src/auth/instance.ts b/packages/api/src/auth/instance.ts index edec91dc..89d8f81f 100644 --- a/packages/api/src/auth/instance.ts +++ b/packages/api/src/auth/instance.ts @@ -129,7 +129,8 @@ export function createInstance(options: AuthOptions, staff: StaffStore): Auth { schema: { invitation: { additionalFields: INVITATION_FIELDS } }, }), admin(), - // Non-interactive credentials for agents/CI: `x-api-key` → `agent` actor (ADR-0011). + // Non-interactive credentials for agents/CI: `x-api-key` or `Authorization: Bearer` → `agent` + // actor (ADR-0011); `resolvePrincipal` reads them (`agentKeyFrom`). apiKey({ apiKeyHeaders: API_KEY_HEADER, enableMetadata: true, diff --git a/packages/api/src/auth/principal.ts b/packages/api/src/auth/principal.ts index e7133fdd..7d3e9b2a 100644 --- a/packages/api/src/auth/principal.ts +++ b/packages/api/src/auth/principal.ts @@ -20,14 +20,14 @@ import { personRef, storedScopes, } from "./key-rows.ts"; -import { API_KEY_HEADER } from "./types.ts"; +import { agentKeyFrom } from "./types.ts"; export function createPrincipalResolver(d: AuthDeps): PrincipalResolver { const { now, staffStore, keyLimit, instance, api } = d; const resolvePrincipal: PrincipalResolver = async ( c: Context, ): Promise => { - const key = c.req.header(API_KEY_HEADER); + const key = agentKeyFrom(c.req.raw.headers, c.req.path); if (key) { const verified = await api.verifyApiKey({ body: { key } }); if (!verified.valid || !verified.key) return undefined; diff --git a/packages/api/src/auth/types.ts b/packages/api/src/auth/types.ts index 4a67812c..89a90826 100644 --- a/packages/api/src/auth/types.ts +++ b/packages/api/src/auth/types.ts @@ -202,6 +202,22 @@ export const NOT_A_MEMBER_MESSAGE = "Your access was removed. Ask an administrator for a new invite."; export const API_KEY_HEADER = "x-api-key"; + +/** `/api/admin/*` takes `Authorization: Bearer `, never an agent key. */ +const DEPLOY_TOKEN_PATHS = /^\/api\/admin(\/|$)/; + +/** + * The agent key a request carries: `x-api-key`, else `Authorization: Bearer ` (what most MCP + * clients and Codex's `bearer_token_env_var` send). On the deployment-token routes the bearer is the + * deploy token, so it is not read as a key there. + */ +export function agentKeyFrom(headers: Headers, path: string): string | undefined { + const key = headers.get(API_KEY_HEADER); + if (key) return key; + if (DEPLOY_TOKEN_PATHS.test(path)) return undefined; + const bearer = /^Bearer\s+(\S+)\s*$/i.exec(headers.get("authorization") ?? "")?.[1]; + return bearer || undefined; +} export const SETUP_TOKEN_HEADER = "x-cloudhal-setup-token"; /** diff --git a/packages/api/src/testing-agent-key-auth.ts b/packages/api/src/testing-agent-key-auth.ts new file mode 100644 index 00000000..365d132a --- /dev/null +++ b/packages/api/src/testing-agent-key-auth.ts @@ -0,0 +1,151 @@ +/** + * Shared suite for how an agent key reaches the API: `x-api-key` or `Authorization: Bearer ` + * (what most MCP clients and Codex's `bearer_token_env_var` send), on REST and `/mcp`, next to the + * deployment-token bearer of `/api/admin/*` and browser sessions. Runs on better-sqlite3 and + * Miniflare D1. + */ +import { AuditLog } from "@cloudhal/agent-runtime"; +import { seedStarterSite } from "@cloudhal/core-ts"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { afterAll, describe, expect, it } from "vitest"; +import { createApi } from "./app.ts"; +import { createAuth } from "./auth.ts"; +import type { RolesBackend } from "./testing-roles.ts"; + +type Json = Record; +const ORIGIN = "http://studio.test"; +const DEPLOY_TOKEN = "key-auth-deploy-token-0123456789abcdefghij"; + +export function describeAgentKeyAuthApi(label: string, backend: RolesBackend): void { + describe(`agent key headers: ${label}`, { timeout: 180_000 }, () => { + afterAll(async () => { + await backend.dispose?.(); + }); + + const setup = async () => { + const b = await backend.create(); + await seedStarterSite(b.store, { name: "Blog", slug: "blog" }); + const auth = createAuth({ + database: b.database, + sql: b.sql, + baseURL: ORIGIN, + secret: "key-auth-secret-0123456789abcdefghijkl", + signUp: "first-user", + }); + const audit = new AuditLog(b.sql); + const app = createApi({ + store: b.store, + resolvePrincipal: auth.resolvePrincipal, + authHandler: auth.handler, + agentKeys: auth.agentKeys, + staff: auth.staff, + signUp: auth.signUp, + audit, + mcp: { stateKey: "key-auth-state-key-0123456789abcdefghi", audit }, + admin: { deployToken: DEPLOY_TOKEN, storage: { sql: b.sql, blobs: b.blobs } }, + }); + let ip = 0; + const call = async (method: string, path: string, headers: Record = {}) => { + const res = await app.request(`${ORIGIN}${path}`, { + method, + headers: { + "content-type": "application/json", + "x-forwarded-for": `10.2.0.${ip++ % 250}`, + ...headers, + }, + }); + const text = await res.text(); + return { status: res.status, body: (text ? JSON.parse(text) : {}) as Json, res }; + }; + const signUp = await app.request(`${ORIGIN}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Olive Owner", + email: "owner@example.com", + password: "correct horse battery staple", + }), + }); + const cookie = + (signUp.headers.getSetCookie?.() ?? []) + .map((c) => c.split(";")[0] as string) + .find((c) => c.startsWith("better-auth.session_token=")) ?? ""; + const created = await app.request(`${ORIGIN}/api/agent-keys`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ name: "bearer agent" }), + }); + expect(created.status).toBe(201); + const key = ((await created.json()) as { key: string }).key; + const connect = async (headers: Record) => { + const client = new Client( + { name: "key-auth-agent", version: "1.0.0" }, + { capabilities: {}, versionNegotiation: { mode: "auto" } }, + ); + await client.connect( + new StreamableHTTPClientTransport(new URL(`${ORIGIN}/mcp`), { + requestInit: { headers }, + fetch: async (input, init) => app.fetch(new Request(input, init as RequestInit)), + }), + ); + return client; + }; + return { call, cookie, key, connect }; + }; + + it("accepts the key as x-api-key or as Authorization: Bearer on REST", async () => { + const { call, key } = await setup(); + for (const headers of [ + { "x-api-key": key }, + { authorization: `Bearer ${key}` }, + { authorization: `bearer ${key}` }, + ]) { + const me = await call("GET", "/api/me", headers); + expect(me.status, JSON.stringify(me.body)).toBe(200); + expect(me.body.actor).toMatchObject({ kind: "agent", name: "bearer agent" }); + } + // Bearer is only another header: REST still sends agents to /mcp (ADR-0011). + const sites = await call("GET", "/api/sites", { authorization: `Bearer ${key}` }); + expect(sites.body.message).toMatch(/\/mcp/); + }); + + it("refuses an unknown bearer and does not fall back to the session", async () => { + const { call, cookie } = await setup(); + expect((await call("GET", "/api/me", { authorization: "Bearer not-a-key" })).status).toBe( + 401, + ); + expect( + (await call("GET", "/api/me", { authorization: "Bearer not-a-key", cookie })).status, + ).toBe(401); + // A browser session without a bearer still answers as the person. + const me = await call("GET", "/api/me", { cookie }); + expect(me.status).toBe(200); + expect(me.body.actor).toMatchObject({ kind: "human" }); + }); + + it("connects an MCP client with Authorization: Bearer", async () => { + const { key, connect } = await setup(); + const client = await connect({ authorization: `Bearer ${key}` }); + const tools = await client.listTools(); + expect(tools.tools.map((t) => t.name)).toContain("get_mode"); + await client.close(); + await expect(connect({ authorization: "Bearer not-a-key" })).rejects.toThrow(); + }); + + it("keeps /api/admin on the deployment token; an agent key is not one", async () => { + const { call, key } = await setup(); + const sites = await call("GET", "/api/admin/sites", { + authorization: `Bearer ${DEPLOY_TOKEN}`, + }); + expect(sites.status, JSON.stringify(sites.body)).toBe(200); + expect( + (await call("GET", "/api/admin/sites", { authorization: `Bearer ${key}` })).status, + ).toBe(401); + expect((await call("GET", "/api/admin/sites", { "x-api-key": key })).status).toBe(401); + // The deployment token is not an agent key elsewhere either. + expect( + (await call("GET", "/api/me", { authorization: `Bearer ${DEPLOY_TOKEN}` })).status, + ).toBe(401); + }); + }); +} diff --git a/packages/api/test/agent-key-auth.test.ts b/packages/api/test/agent-key-auth.test.ts new file mode 100644 index 00000000..e4f10934 --- /dev/null +++ b/packages/api/test/agent-key-auth.test.ts @@ -0,0 +1,34 @@ +import { SiteGraphStore } from "@cloudhal/core-ts"; +import { MemoryBlobStore } from "@cloudhal/storage"; +import { D1Driver } from "@cloudhal/storage/d1"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { createMiniflareD1, createTableReset } from "@cloudhal/storage/testing"; +import Database from "better-sqlite3"; +import { describeAgentKeyAuthApi } from "../src/testing-agent-key-auth.ts"; + +describeAgentKeyAuthApi("better-sqlite3", { + async create() { + const database = new Database(":memory:"); + const sql = new BetterSqlite3Driver(database); + const blobs = new MemoryBlobStore(); + const store = new SiteGraphStore({ sql, blobs }); + await store.init(); + return { store, sql, blobs, database }; + }, +}); + +let d1: Awaited> | undefined; +const reset = createTableReset(); +describeAgentKeyAuthApi("Cloudflare D1 (Miniflare)", { + async create() { + d1 ??= await createMiniflareD1(); + const sql = new D1Driver(d1.db); + const blobs = new MemoryBlobStore(); + const store = new SiteGraphStore({ sql, blobs }); + await reset(sql, () => store.init()); + return { store, sql, blobs, database: d1.db }; + }, + async dispose() { + await d1?.dispose(); + }, +}); From 4d3b375fc79d7d7efbe0db39df9c1eb7ba207308 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:01:21 +0300 Subject: [PATCH 2/2] feat(studio): per-client MCP setup on the Agents page and in the key reveal `McpClientSetup` picks a client (Claude Code, Codex, Cursor, VS Code, any other; local stdio in the page's card) and shows its ready-to-copy setup for this deployment's /mcp, in the formats of docs/agents/clients.md. Right after a key is created the secret is filled in; VS Code prompts for it instead of storing it in the file. All remote snippets send Authorization: Bearer. Strings in en.ts; other languages drafted through i18n:export/import (AI drafts awaiting review). Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .../src/components/CreateAgentKeyDialog.tsx | 36 ++------- apps/studio/src/components/McpClientSetup.tsx | 48 ++++++++++++ apps/studio/src/i18n/ar.ts | 29 +++++-- apps/studio/src/i18n/de.ts | 31 ++++++-- apps/studio/src/i18n/en.ts | 28 ++++++- apps/studio/src/i18n/es.ts | 30 +++++-- apps/studio/src/i18n/fa.ts | 30 +++++-- apps/studio/src/i18n/fr.ts | 31 ++++++-- apps/studio/src/i18n/he.ts | 29 +++++-- apps/studio/src/i18n/hi.ts | 29 +++++-- apps/studio/src/i18n/id.ts | 30 +++++-- apps/studio/src/i18n/it.ts | 30 +++++-- apps/studio/src/i18n/ja.ts | 29 +++++-- apps/studio/src/i18n/ko.ts | 30 +++++-- apps/studio/src/i18n/nl.ts | 31 ++++++-- apps/studio/src/i18n/pl.ts | 30 +++++-- apps/studio/src/i18n/pt-BR.ts | 30 +++++-- apps/studio/src/i18n/ru.ts | 31 ++++++-- apps/studio/src/i18n/tr.ts | 30 +++++-- apps/studio/src/i18n/uk.ts | 30 +++++-- apps/studio/src/i18n/zh-Hans.ts | 29 +++++-- apps/studio/src/lib/agents.ts | 78 ++++++++++++++----- apps/studio/src/routes/AgentsPage.tsx | 33 +------- apps/studio/test/agents.test.tsx | 2 + apps/studio/test/design-rules.test.ts | 6 +- apps/studio/test/lib.test.ts | 30 +++++-- scripts/size-baseline.json | 2 +- 27 files changed, 585 insertions(+), 217 deletions(-) create mode 100644 apps/studio/src/components/McpClientSetup.tsx diff --git a/apps/studio/src/components/CreateAgentKeyDialog.tsx b/apps/studio/src/components/CreateAgentKeyDialog.tsx index 1d894ab8..e9927c60 100644 --- a/apps/studio/src/components/CreateAgentKeyDialog.tsx +++ b/apps/studio/src/components/CreateAgentKeyDialog.tsx @@ -8,26 +8,23 @@ import { Input, InsetPanel, LayerDialog, - Tabs, Text, } from "@cloudhal/ui"; -import { CodeHighlighted } from "@cloudhal/ui/code"; import { ArrowCounterClockwise, Info, Warning } from "@phosphor-icons/react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { listOf, useT } from "../i18n/index.tsx"; import { type GrantableScope, - mcpSnippet, SCOPE_GROUPS, type ScopeGroup, - type SnippetKind, sameScopes, scopeOptions, } from "../lib/agents.ts"; import { api, type CreatedAgentKey, errorCode, type KeyMode } from "../lib/api.ts"; import { apiErrorText } from "../lib/api-errors.ts"; import { AutoModeConfirm, BypassHint, ModeBadge, ModePicker } from "./AgentModes.tsx"; +import { McpClientSetup } from "./McpClientSetup.tsx"; import { useAgentModeRules } from "./useAgentModeRules.ts"; type Step = "mode" | "details"; @@ -61,7 +58,6 @@ export function CreateAgentKeyDialog({ const [advancedOpen, setAdvancedOpen] = useState(false); const [submitted, setSubmitted] = useState(false); const [created, setCreated] = useState(); - const [snippet, setSnippet] = useState("json"); const modeScopes = rules.scopesFor(mode); const customised = !sameScopes(scopes, modeScopes); @@ -89,7 +85,6 @@ export function CreateAgentKeyDialog({ setScopes(rules.scopesFor(rules.defaultMode)); setAdvancedOpen(false); setSubmitted(false); - setSnippet("json"); create.reset(); } else { setCreated(undefined); @@ -160,30 +155,11 @@ export function CreateAgentKeyDialog({ tooltip={{ text: t("common.copy"), copiedText: t("common.copied") }} /> -
-
- - {t("agents.reveal.config")} - - setSnippet(v as SnippetKind)} - tabs={(["json", "claude"] as const).map((k) => ({ - value: k, - label: t(`agents.connect.tabs.${k}`), - }))} - /> -
-
- -
-
+ ) : step === "mode" ? (
diff --git a/apps/studio/src/components/McpClientSetup.tsx b/apps/studio/src/components/McpClientSetup.tsx new file mode 100644 index 00000000..57a6a91f --- /dev/null +++ b/apps/studio/src/components/McpClientSetup.tsx @@ -0,0 +1,48 @@ +import { Select, Text } from "@cloudhal/ui"; +import { CodeHighlighted } from "@cloudhal/ui/code"; +import { useState } from "react"; +import { useT } from "../i18n/index.tsx"; +import { type McpClient, mcpSnippet, REMOTE_CLIENTS, snippetLang } from "../lib/agents.ts"; + +/** + * Ready-to-copy setup for one MCP client at a time (docs/agents/clients.md): a client picker, the + * snippet for this deployment's `/mcp`, and where it goes. With `agentKey` (the one-time reveal) + * the key is filled in; `local` adds the stdio server for the person's own machine. + */ +export function McpClientSetup({ + endpoint, + agentKey, + local = false, + label, +}: { + endpoint: string; + agentKey?: string; + local?: boolean; + /** The picker's label; "Client" by default. */ + label?: string; +}) { + const t = useT(); + const [client, setClient] = useState("claude"); + const clients: McpClient[] = local ? [...REMOTE_CLIENTS, "stdio"] : [...REMOTE_CLIENTS]; + return ( +
+