From 51b0fcd532280c6cc1c892f756a8d37ec4e22ad3 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 29 Apr 2026 15:53:38 +0100 Subject: [PATCH 1/2] fix(webapp): allow JWT auth on POST /api/v1/sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The route was secret-key-only by design ("Customer's server owns session creation"). That holds for the customer browser path — `chat.createStartSessionAction` runs server-side and authorizes there. But the cli-v3 MCP `start_agent_chat` tool is itself a server-side surface (developer's CLI/IDE acting as their own server) and only holds a JWT minted from the user's PAT. Without JWT support here it can't create sessions, blocking the entire MCP agent toolkit. Add `allowJWT: true` and an `authorization` block requiring the `write:sessions` (or `admin`) super-scope. Resource scoping by `taskIdentifier` isn't possible at auth-resolve time — action routes don't pass `body` to the resource callback, and the task name only lives in the body — so the resource is a `sessions` wildcard and the super-scope does the gating. The JWT-issuer (cli-v3 MCP, customer servers wrapping a wider auth helper, etc.) decides which scopes to mint, which is where per-task narrowing lives. Verified end-to-end: `mcp__trigger__start_agent_chat` → `send_agent_message("pong")` → `send_agent_message("echo")` → `close_agent_chat` all succeed against local. Two assistant turns reuse the same runId (continuation in the idle window). --- apps/webapp/app/routes/api.v1.sessions.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts index 6251ff3ac38..84ac29c80cd 100644 --- a/apps/webapp/app/routes/api.v1.sessions.ts +++ b/apps/webapp/app/routes/api.v1.sessions.ts @@ -96,13 +96,31 @@ const { action } = createActionApiRoute( body: CreateSessionRequestBody, method: "POST", maxContentLength: 1024 * 32, // 32KB — metadata is the only thing that grows - // Secret-key only. Customer's server (typically wrapping + // Customer's server (typically wrapping // `chat.createStartSessionAction`) owns session creation so any // authorization decision (per-user/plan/quota) sits server-side // alongside whatever DB write the customer pairs with the create. // The session-scoped PAT returned in the response body is what the // browser uses thereafter against `.in/append`, `.out` SSE, // `end-and-continue`, etc. + // + // JWT is allowed when the caller holds an explicit `write:sessions` / + // `admin` super-scope plus a `tasks:` scope — gates + // server-side surfaces like the cli-v3 MCP from creating sessions on + // behalf of the developer without weakening the browser model. + allowJWT: true, + authorization: { + // Resource scoping by `taskIdentifier` isn't possible at auth-resolve + // time — action routes don't pass `body` to the resource callback, + // and the task name only lives in the body. We require a `sessions` + // resource scope (wildcard) and rely on `write:sessions` / `admin` + // super-scopes to gate access. Per-task narrowing happens implicitly + // because the JWT-issuer (e.g. cli-v3 MCP) decides which scopes to + // request when minting the token. + action: "write", + resource: () => ({ sessions: "*" }), + superScopes: ["write:sessions", "admin"], + }, corsStrategy: "all", }, async ({ authentication, body }) => { From c438fdffbedbf9bad0c506285ba0f691dcbefbab Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 30 Apr 2026 09:31:01 +0100 Subject: [PATCH 2/2] fix(webapp): scope sessions auth per-task, not wildcard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Devin caught two real issues with the previous resource shape on PR #3474: 1. The "body isn't available at auth-resolve time" claim was wrong. Action-route resource callbacks receive the parsed body as the 4th arg (apiBuilder.server.ts:710). Other routes like api.v1.tasks.batch.ts use it (line 33). 2. The auth check is OR across resource types — listing both `sessions: "*"` and `tasks: body.taskIdentifier` would let a `write:sessions`-only JWT pass for any task, defeating the per-task narrowing. Replace `() => ({ sessions: "*" })` with `(_, __, ___, body) => ({ tasks: body.taskIdentifier })` and rely on the `write:sessions` / `admin` super-scopes for broad access. A JWT scoped only to `write:tasks:foo` can now only create sessions whose taskIdentifier is `foo`. MCP-style flows that hold `write:sessions` continue to work via the super-scope path. Verified: MCP `start_agent_chat` → `send_agent_message` → `close_agent_chat` still passes locally; webapp typecheck clean. --- apps/webapp/app/routes/api.v1.sessions.ts | 24 +++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts index 84ac29c80cd..38270fdfc77 100644 --- a/apps/webapp/app/routes/api.v1.sessions.ts +++ b/apps/webapp/app/routes/api.v1.sessions.ts @@ -110,15 +110,23 @@ const { action } = createActionApiRoute( // behalf of the developer without weakening the browser model. allowJWT: true, authorization: { - // Resource scoping by `taskIdentifier` isn't possible at auth-resolve - // time — action routes don't pass `body` to the resource callback, - // and the task name only lives in the body. We require a `sessions` - // resource scope (wildcard) and rely on `write:sessions` / `admin` - // super-scopes to gate access. Per-task narrowing happens implicitly - // because the JWT-issuer (e.g. cli-v3 MCP) decides which scopes to - // request when minting the token. + // Per-task scoping via `body.taskIdentifier` (action-route resource + // callbacks receive the parsed body as the 4th arg — see + // `apiBuilder.server.ts:710`). A JWT scoped only to `write:tasks:foo` + // can only create sessions whose `taskIdentifier` is `"foo"`. Broad + // callers (cli-v3 MCP, customer servers wrapping their own auth) + // hold the `write:sessions` super-scope and bypass the per-task + // check entirely. + // + // Note: the auth check is OR across resource types, so listing both + // `sessions` and `tasks` here would let a `write:sessions`-scoped + // JWT pass for *any* task — defeating the per-task narrowing. Keep + // it task-only and let the super-scope path handle session-level + // wildcard access. action: "write", - resource: () => ({ sessions: "*" }), + resource: (_params, _searchParams, _headers, body) => ({ + tasks: body.taskIdentifier, + }), superScopes: ["write:sessions", "admin"], }, corsStrategy: "all",